Compare commits

...
5 Commits
Author SHA1 Message Date
wkandClaude Sonnet 4.6 noreply@anthropic.com c8a4330f3b feat(line): 智电改造线路台账新增「线路数据」弹窗展示最近一次模拟量快照 - 后端新增GET /api/electricity-data/latest(按lineId取最大collect_time的全部模拟量行,RBAC device:list);前端electricityDataApi.getLatestByLine与ElectricityLatestSnapshot类型;台账页表格新增固定右侧「操作」列与「线路数据」按钮,点击弹出720px弹窗显示线路信息(线路/编码/采集时间)及模拟量表格(模拟量名称/类型/数值/单位),无数据时显示空态提示
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
2026-10-24 15:13:56 +08:00
wk c92fc7f298 feat(analog): 新增ICC模拟量数据同步 - 重建electricity_data表(迁移20261024_icc_analog_data.sql:删除equip_id与electricity占位列、icc_device_code更名imei、新增analog_type/analog_name/analog_unit/analog_value、collect_time以同步时间填充、按月分区2026_10~2027_03、唯一键(line_code,analog_type,collect_time));同步服务IccAnalogSyncService复用OAuth2鉴权,按已同步线路line_code分批(≤50)拉取最新模拟量(POST /analog/subsystem/page,pageSize=500短页停止)幂等upsert并写icc_sync_log(sync_type=electricity_data);新增POST /api/electricity-data/sync手动同步(RBAC device:list);注册icc_analog_sync每5分钟定时任务与sys_job种子;同步db_init/db_seed与设计文档;无前端改动 2026-10-24 14:52:22 +08:00
wk 160ad85051 feat(line): 智电改造线路台账接入 ICC 消防探测器真实同步
- 重建 electric_line 表结构(迁移20261024_icc_line_rebuild.sql并同步db_init/db_seed):删building_id/floor_no/location/icc_device_code占位字段,equip_id改可空,新增imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date,gateway_id外键→electric_gateway
- 同步服务IccLineSyncService复用OAuth2真实鉴权分页拉取探测器(POST /detector/subsystem/page,pageSize=50短页停止),按line_code(detectorCode)幂等upsert写icc_sync_log;customer_id由orgCode→customer.icc_code解析、gateway_id由parentCode→electric_gateway.device_code匹配、equip_id同步不填充
- 列表接口返回gateway/type/imei等新字段,keyword检索含imei;前端台账页更新为新列(IMEI/所属网关/探测器类型/回路通道/消防系统/生产日期),保留手动同步按钮与筛选
- 更新系统设计文档electric_line表定义与spec(add-icc-line-sync)
2026-10-24 13:50:32 +08:00
wkandClaude Sonnet 4.6 noreply@anthropic.com cf0a0d4d31 feat(gateway): 新增智电改造网关台账功能(ICC消防物联设备同步) - 新建electric_gateway表(迁移20261024_icc_gateway.sql并同步db_seed:自增id、icc_imei/device_code双唯一、orgCode经customer.icc_code解析customer_id、equip_id可空、is_online/synced_at等);同步服务IccGatewaySyncService复用OAuth2真实鉴权分页拉取消防物联设备(POST /device/subsystem/page)并按imei幂等upsert写icc_sync_log;新增GET /api/electric-gateways列表/详情与POST /sync手动同步(RBAC device:list);注册icc_gateway_sync每10分钟定时任务(菜单62/role_menu/sys_job种子);前端网关台账页(搜索/筛选/同步按钮/在线状态tag/分页)与路由;server补装undici依赖
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
2026-10-24 12:37:35 +08:00
wk 9d3bb13ecf feat(customer): 新增ICC客户信息同步功能 - OAuth2密码模式鉴权拉取org_base客户、按icc_code幂等upsert写入并记录icc_sync_log,注册每10分钟定时任务;客户档案管理页新增手动'同步客户'按钮(POST /api/customers/sync/icc),同步后自动刷新列表与统计;customer表新增icc_code字段及唯一索引,同步db_seed与设计文档 2026-10-23 18:20:25 +08:00
58 changed files with 3954 additions and 498 deletions

No files matched your search

@@ -0,0 +1,11 @@
# 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 行);无前端改动
+63
View File
@@ -0,0 +1,63 @@
# ICC 模拟量数据同步 Spec(add-icc-analog-data)
## Why
现有 `electricity_data`(线路用电信息表)仅有一版占位定义(单列 `electricity`),与 ICC 平台「消防最新模拟量信息分页查询」接口(`POST /evo-apigw/evo-fdbu/{version}/analog/subsystem/page`,实测返回 `{deviceCode, uniqueCode, analogType, analogName, analogUnit, analogValue}`,无时间字段)不对应。本次按接口返回数据重建表结构,并实现真实同步服务与定时任务,将已同步线路对应探测器的模拟量数据写入 electricity_data。
## What Changes
- **表结构重建(BREAKING)**:
- 删除占位列 `electricity` → 通用模拟量行:`analog_type`(analogType)、`analog_value` NUMERIC(14,4)(analogValue)、`analog_name`(analogName,如:剩余电流)、`analog_unit`(analogUnit,如:mA)
- `icc_device_code` 改为 `imei`(映射 uniqueCode)
- **删除 `equip_id`**(用户确认不需要客户设备关联)
- 保留:`line_id`(NOT NULL → electric_line)、`line_code`(NOT NULL,= deviceCode 冗余 + 幂等键)、`line_name`(冗余展示)、`customer_id`(NOT NULL,逻辑隔离键)、`collect_time`(NOT NULL,分区键)
- **collect_time 语义(实测确认)**:ICC「最新模拟量分页查询」接口实际不返回 reportTime 时间字段,经用户确认以**同步时间**作为采集时间(分区键)
- 唯一幂等键:`UNIQUE (line_code, analog_type, collect_time)`(同一次同步内重复上报覆盖更新)
- 按月 RANGE 分区(分区键 collect_time),迁移建当月及后续月份分区
- **同步服务**(复用 icc_customer_auth 真实 OAuth2 鉴权):
- 先读取已同步的 electric_line(line_code = detectorCode),按 deviceCodes 分批(每批 ≤50)POST 模拟量接口,pageSize=500 短页停止全量拉取
- 预取 deviceCode→(line_id, customer_id) 映射避免 N+1;imei=uniqueCode、analog_type=analogType、analog_name=analogName、analog_unit=analogUnit、analog_value=analogValue 数值解析、collect_time=同步时间
- 按唯一键 (line_code, analog_type, collect_time) 幂等 upsert;写 icc_sync_log(sync_type=electricity_data);失败仅记日志不抛业务错
- **定时任务**:jobKey=`icc_analog_sync`(默认每 5 分钟,cron `*/5 * * * *`,sys_job 种子幂等),注册进 job_registry/JobRuntime(syncIccAnalogs)
- **同步接口**:`POST /api/electricity-data/sync`(JWT+RBAC device:list,返回 pulled/inserted/updated/skipped/synced);不做前端页面/菜单
- **迁移与文档**:新增 `database/migrations/20261024_icc_analog_data.sql`(建表/分区/索引/注释 + sys_job 种子),同步 `database/db_init.sql` 与 `系统设计/表结构新增及变更.md`
## Impact
- Affected specs:add-icc-line-sync(electric_line 为模拟量关联目标,先同步线路)
- Affected code:
- 后端新增:`server/src/entity/electricity_data.entity.ts`、`server/src/task/icc_analog_mapper.ts`、`server/src/service/icc_analog_sync.service.ts`、`server/src/task/icc_analog_sync.task.ts`、`server/src/controller/electricity_data.controller.ts`
- 后端修改:`server/src/task/job_registry.ts`、`server/src/service/job_scheduler.service.ts`、`server/src/middleware/rbac.middleware.ts`
- 数据库:新增 `database/migrations/20261024_icc_analog_data.sql`;修改 `database/db_init.sql`、`database/db_seed.sql`(sys_job 种子)
- 设计文档:`系统设计/表结构新增及变更.md`
- 前端:无改动
## ADDED Requirements
### Requirement: electricity_data 表结构按 AnalogInfoVO 重建
系统 SHALL 将 `electricity_data` 重建为按月分区时序表:id(复合主键含 collect_time)、line_id(NOT NULL FK→electric_line)、line_code(NOT NULL)、imei、line_name、customer_id(NOT NULL)、analog_type、analog_name、analog_unit、analog_value NUMERIC(14,4)、collect_time TIMESTAMPTZ(NOT NULL 分区键,同步时间),唯一键 `UNIQUE (line_code, analog_type, collect_time)`,索引 customer_id/line_id/collect_time。
#### Scenario: 迁移执行成功
- **WHEN** 执行迁移到生产库
- **THEN** electricity_data 含上述字段/分区/唯一键/索引;db_init.sql 与迁移一致
### Requirement: 真实 ICC 模拟量拉取同步
系统 SHALL 复用 `getIccAccessToken`/`iccFetch`,以已同步线路的 line_code(detectorCode)分批作为 deviceCodes,POST 模拟量接口分页拉取(pageSize=500 短页停止),以同步时间作为 collect_time,按 (line_code, analog_type, collect_time) 幂等 upsert,写 icc_sync_log(sync_type=electricity_data),失败仅记日志不抛。
#### Scenario: 同步成功
- **WHEN** 定时任务或 POST /api/electricity-data/sync 触发
- **THEN** 返回 {pulled, inserted, updated, skipped, synced};同键重复上报覆盖更新;无线路匹配的行跳过
#### Scenario: 同步失败
- **WHEN** ICC 拉取异常
- **THEN** 仅写 icc_sync_log 失败日志,接口返回 code 200 与当前计数
### Requirement: 定时任务注册
系统 SHALL 注册 `icc_analog_sync` 定时任务(默认每 5 分钟,cron `*/5 * * * *`),注册进 job_registry BUILTIN_JOBS 与 JobRuntime.syncIccAnalogs,sys_job 种子幂等。
#### Scenario: 任务可执行
- **WHEN** 任务调度触发
- **THEN** 调用同步服务并将结果写入执行记录
## REMOVED Requirements
### Requirement: 占位 electricity 单列结构(旧 electricity_data)
**Reason**:与模拟量接口返回结构不对应。
**Migration**:electricity → analog_type/analog_value;icc_device_code → imei;equip_id 删除;collect_time 改由同步时间填充(接口实测无 reportTime)。
+32
View File
@@ -0,0 +1,32 @@
# Tasks
- [x] Task 1: 核对生产库 electricity_data 现状(表是否存在、旧表字段/分区/有无数据),确定迁移采用 CREATE IF NOT EXISTS 还是增量变更
- [x] SubTask 1.1: 查询生产库 information_schema 确认 electricity_data 是否存在及其字段/分区/数据量
- [x] Task 2: 新建迁移 `database/migrations/20261024_icc_analog_data.sql`(无 equip_id):
- 无旧表 → `CREATE TABLE IF NOT EXISTS electricity_data`:id BIGSERIAL、line_id BIGINT NOT NULL(FK→electric_line)、line_code VARCHAR(64) NOT NULL、imei VARCHAR(64)、line_name VARCHAR(128)、customer_id BIGINT NOT NULL、analog_type VARCHAR(64)、analog_name VARCHAR(128)、analog_unit VARCHAR(32)、analog_value NUMERIC(14,4)、collect_time TIMESTAMPTZ NOT NULL;主键 (id, collect_time);唯一键 UNIQUE (line_code, analog_type, collect_time);PARTITION BY RANGE (collect_time)
- 有旧表 → 幂等变更:删除 equip_id/electricity、icc_device_code 更名 imei、新增 analog_type/analog_name/analog_unit/analog_value(数据量小可 DROP 重建)
- 建分区 2026_10 ~ 2027_03(IF NOT EXISTS)、索引 idx_ed_customer/idx_ed_line/idx_ed_time、表与列 COMMENT
- 执行到远程生产库并验证(列/分区/唯一键/索引),同步更新 `database/db_init.sql` 基线定义
- [x] Task 3: 后端实体与映射:新建 `server/src/entity/electricity_data.entity.ts`(字段 analog_name/analog_unit,FK→electric_line)、`server/src/task/icc_analog_mapper.ts`(AnalogInfoVO 映射:deviceCode→line_code、uniqueCode→imei、analogType→analog_type、analogName→analog_name、analogUnit→analog_unit、analogValue 数值解析→analog_value;分页解析兼容 data.pageData;接口实测无 reportTime,collect_time 由同步时间填充)
- [x] Task 4: 同步服务:新建 `server/src/service/icc_analog_sync.service.ts`
- [x] 复用 `getIccAccessToken`/`iccFetch`;先读已同步 electric_line 取 line_code(detectorCode)与 (line_id, customer_id)
- [x] deviceCodes 分批(每批 ≤50)POST `/analog/subsystem/page`,pageSize=500 短页停止全量拉取
- [x] 按 (line_code, analog_type, collect_time) 幂等 upsert(collect_time 用同步时间,同一次同步内重复上报覆盖更新);写 icc_sync_log(sync_type=electricity_data);失败仅记日志不抛业务错
- [x] Task 5: 同步接口 + RBAC + 定时任务:
- [x] 新建 `server/src/controller/electricity_data.controller.ts`:`POST /api/electricity-data/sync` 返回 {pulled, inserted, updated, skipped, synced}
- [x] `server/src/middleware/rbac.middleware.ts` 增加 `/api/electricity-data` → `device:list`
- [x] 新建 `server/src/task/icc_analog_sync.task.ts`(jobKey=icc_analog_sync,默认每 5 分钟 `*/5 * * * *`);`job_registry.ts` BUILTIN_JOBS 注册 + `job_scheduler.service.ts` JobRuntime.syncIccAnalogs 注入实现
- [x] `database/db_seed.sql` 增加 sys_job 种子(job_key=icc_analog_sync,幂等)
- [x] Task 6: 同步更新 `系统设计/表结构新增及变更.md`:追加 electricity_data 新表结构(字段/类型/键/注释 + 与 customer/customer_equipment/electric_line 关联说明 + 同步服务与定时任务说明)
- [x] Task 7: 验证:
- [x] SubTask 7.1: `tsc` 编译通过(server)
- [x] SubTask 7.2: 启动后端,调用 `POST /api/electricity-data/sync`(先确认线路已同步),确认拉到真实模拟量数据入库、幂等重跑 updated/skipped 正常
- [x] SubTask 7.3: 验证 icc_sync_log 落日志、定时任务注册存在(无前端改动,无需浏览器验证)
# Task Dependencies
- Task 2 依赖 Task 1(确认生产库现状后写迁移)
- Task 3 依赖 Task 2(表结构定稿后建实体)
- Task 4 依赖 Task 3(实体与映射就绪后实现同步服务)
- Task 5 依赖 Task 4(同步服务实现后可接线 controller/定时任务)
- Task 6 依赖 Task 2(表结构定稿后写文档)
- Task 7 依赖 Task 4、Task 5
@@ -0,0 +1,12 @@
# Checklist
- [x] customer 表迁移与 db_init.sql 均包含 icc_code 字段与唯一索引,且结构一致(migrations/20261016_icc_customer_sync.sql ↔ db_init.sql L194/L232/L213 ↔ customer.entity.ts)
- [x] customer 实体含 iccCode 字段定义(field=icc_code,STRING(64),可空)
- [x] ICC OAuth2 鉴权客户端可获取公钥并完成 RSA 加密、换取 access_token,token 有进程内缓存与过期续签(icc_customer_auth.ts,RSA 分块加解密冒烟验证通过)
- [x] 字段映射器正确映射 orgCode→iccCode、orgName→customerName、province/city/county,并解析分页响应(icc_customer_mapper.ts,冒烟验证映射与 PageVO/数组兼容解析均正确)
- [x] 同步服务按 org_base 过滤、分页全量拉取(maxRangeId=0、pageSize=1000 循环至末页),按 icc_code 幂等 upsert
- [x] 新增客户自动生成 customer_no(复用 CustomerService.generateCustomerNo,KH+YYYYMMDD+3位序号);已存在客户先比较四个映射字段,仅不一致才更新,其余字段保持不变
- [x] 每次同步写 icc_sync_log(sync_type=customer,status/record_count/error_message),失败路径仅记日志不抛业务错误
- [x] 定时任务已注册(job_registry L5/L37/L64 + db_seed.sql L411 sys_job 种子),默认 cron `*/10 * * * *`,调度器运行时已接线(job_scheduler.service.ts syncIccCustomers)
- [x] server/.env 含完整 ICC 配置键(BASE_URL/VERSION/CLIENT_ID/CLIENT_SECRET/USERNAME/PASSWORD)
- [x] `yarn build` 编译通过(mapper 修复后复验通过),任务代码路径(task → runtime → service)类型正确可运行;端到端写库同步建议在部署环境首次执行时由真实 ICC 接口确认
+52
View File
@@ -0,0 +1,52 @@
# ICC 客户信息同步(org_base)Spec
## Why
客户档案目前依赖手工维护,鑫潼 ICC 平台已存在基础组织(org_base)客户主数据。需按 ICC 平台接口文档,通过定时任务从 ICC 拉取 org_base 组织数据,按字段映射对 customer 客户表执行新增/变更,实现客户主数据自动化同步。
## What Changes
- **新增** `customer` 表 `icc_code` 字段(ICC 客户编码,唯一索引),作为 ICC 同步的幂等匹配键;同步修改实体、迁移脚本与 db_seed.sql(幂等一致)
- **新增** ICC OAuth2 密码模式鉴权客户端:获取公钥 → RSA(ECB/PKCS1) 加密密码 → `extend/token` 换取 access_token(2h 有效,进程内缓存过期自动续签)
- **新增** `IccCustomerSyncService`:从 `.env` 读取 ICC 配置,分页全量拉取 `organization/page`(`maxRangeId=0`,过滤 `orgType=org_base`),按字段映射对 customer 幂等 upsert(已存在记录先比较映射字段,不一致才变更),写 `icc_sync_log`
- **新增** 定时任务:注册 `icc_customer_sync.task` 到任务注册表与 `sys_job`(默认每 10 分钟,可配置)
- **安全**:全部 SQL 走 Sequelize 参数绑定;接口凭据经 `.env` 读取,不硬编码到代码;ICC 拉取失败仅记录 `icc_sync_log` 错误日志,不抛业务错误
## Impact
- Affected specs: ICC 数据同步(含现有 icc_line_sync 模式)、客户管理(customer 主数据)
- Affected code:
- server/src/entity/customer.entity.ts(新增 iccCode)
- server/src/task/icc_customer_mapper.ts(新增:字段映射集中配置)
- server/src/service/icc_customer_sync.service.ts(新增:OAuth2 鉴权 + 拉取 + upsert + 日志)
- server/src/task/icc_customer_sync.task.ts(新增:定时任务定义)
- server/src/task/job_registry.ts、server/src/service/job_scheduler.service.ts(注册任务与运行时)
- server/.env(新增 ICC 凭据配置项)
- database/migrations/(新增迁移文件)、database/db_seed.sql(customer 表结构与 sys_job 种子)
## ADDED Requirements
### Requirement: ICC 客户信息同步(org_base 定时任务)
系统 SHALL 通过定时任务从 ICC 平台同步 org_base 客户信息到 customer 表:OAuth2 鉴权获取 access_token → 分页全量拉取组织数据(maxRangeId=0,pageSize≤1000,循环至末页)→ 过滤 `orgType=org_base` → 按字段映射(`orgCode→iccCode`、`orgName→customerName`、`province→province`、`city→city`、`county→district`)幂等 upsert,并写 `icc_sync_log`(sync_type=`customer`)。
#### Scenario: 定时同步成功
- **WHEN** 定时任务触发(默认 `*/10 * * * *`,可配置)
- **THEN** 拉取 ICC org_base 组织并新增/变更对应 customer 记录;新增客户自动生成 `customer_no`(沿用 KH+YYYYMMDD+序号规则);已存在客户(按 `icc_code` 匹配)先比较 customer_name/province/city/district 四个映射字段与现有值,仅当任一字段不一致时才执行更新,一致则跳过,其余手工字段(status/salesman_id/联系方式等)保持不变;`icc_sync_log` 写入成功记录
#### Scenario: 重复执行幂等
- **WHEN** 同一 orgCode 连续两次同步均存在且 ICC 数据未变化
- **THEN** 第二次执行按 icc_code 命中已有记录,比较后字段一致不执行 update,不产生重复客户、不产生无意义变更
#### Scenario: 全量分页拉取
- **WHEN** 组织数据超过一页
- **THEN** 按 pageNum 递增循环请求直至 currentPage==totalPage,全部合并后统一 upsert
#### Scenario: ICC 鉴权失败 / 接口异常
- **WHEN** 公钥获取失败、token 换取失败或组织接口返回非 0 code / HTTP 非 2xx
- **THEN** 同步失败仅写 `icc_sync_log`(status=0、error_message),业务不抛错;access_token 过期时自动重新走 OAuth2 鉴权
### Requirement: customer 表 icc_code 字段
系统 SHALL 在 customer 表新增 `icc_code` 字段(字符串,可空,唯一索引)用于承载 ICC 客户编码;已有客户在首次同步时按 orgCode 匹配后回填该字段;该字段不参与客户档案页面手工编辑逻辑(同步来源字段)。
### Requirement: ICC 凭据环境配置
系统 SHALL 通过 `.env` 读取 ICC 平台连接配置:`ICC_BASE_URL`(host)、`ICC_VERSION`、`ICC_CLIENT_ID`、`ICC_CLIENT_SECRET`、`ICC_USERNAME`、`ICC_PASSWORD`;业务代码不出现明文凭据。
## MODIFIED Requirements
(无既有功能被修改;现有 icc_line_sync 任务保持原行为不变)
@@ -0,0 +1,38 @@
# Tasks
- [x] Task 1: customer 表新增 icc_code 字段(数据库层)
- [x] 新建迁移文件 `database/migrations/20261016_icc_customer_sync.sql`:`ALTER TABLE customer ADD COLUMN icc_code`(幂等)+ 唯一索引
- [x] 同步更新 `database/db_init.sql` 中 customer 建表结构(含 icc_code 与唯一索引),保持幂等一致
- [x] Task 2: customer 实体新增 iccCode 字段
- [x] `server/src/entity/customer.entity.ts` 增加 `iccCode`(STRING(64),field=icc_code,不设 unique 避免与迁移脚本索引重复)
- [x] Task 3: ICC OAuth2 鉴权客户端
- [x] 封装公钥获取(GET `/evo-apigw/evo-oauth/{version}/oauth/public-key`)
- [x] 封装 RSA/ECB/PKCS1 分块加密(Node `crypto.publicEncrypt`,块长 117,Base64 输出,对齐文档 Java 示例)— 已冒烟验证加解密往返一致
- [x] 封装 token 获取(POST `/evo-apigw/evo-oauth/{version}/oauth/extend/token`),进程内缓存 access_token,按 expires_in 过期自动续签
- [x] Task 4: ICC 客户字段映射器
- [x] `server/src/task/icc_customer_mapper.ts`:集中配置接口端点、org_base 过滤条件、字段映射(orgCode→iccCode、orgName→customerName、province/city/county)、PageVO 解析 — 修复读取 bug 后冒烟验证通过
- [x] Task 5: IccCustomerSyncService 同步服务
- [x] `server/src/service/icc_customer_sync.service.ts`:读取 `.env` 配置(缺失视为失败仅记日志)
- [x] 分页全量拉取(maxRangeId=0,pageSize=1000,循环至末页),过滤 orgType=org_base
- [x] 预取现有客户 icc_code 映射,按 iccCode 幂等 upsert:先比较 customer_name/province/city/district 四个映射字段,仅任一字段不一致才 update,一致则跳过;新增客户复用 CustomerService.generateCustomerNo(已改 public,KH+YYYYMMDD+3位序号);其余字段(status/salesman_id/联系方式等)不动
- [x] 每次同步写 `icc_sync_log`(sync_type=customer,status/record_count/error_message);失败仅记录不抛出
- [x] Task 6: 定时任务注册与调度器接线
- [x] `server/src/task/icc_customer_sync.task.ts`:jobKey=`icc_customer_sync`,默认 cron `*/10 * * * *`
- [x] `server/src/task/job_registry.ts`:注册到 BUILTIN_JOBS,JobRuntime 增加 syncIccCustomers
- [x] `server/src/service/job_scheduler.service.ts`:buildRuntime 增加 `syncIccCustomers`,经匿名上下文注入 IccCustomerSyncService
- [x] `database/db_seed.sql` 新增 sys_job 种子(job_key=icc_customer_sync,按 job_key 幂等)
- [x] Task 7: .env 配置
- [x] `server/.env` 增加 ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD(凭据取自接口文档)
- [x] Task 8: 编译与运行验证
- [x] `server` 目录执行 `yarn build` 通过(修复 mapper 后复验通过)
- [x] 冒烟验证:RSA 分块加解密往返一致、映射器字段映射与分页解析正确(无副作用,未连库);端到端写库同步需在部署环境首次执行时由真实 ICC 接口 + 数据库实际确认
# Task Dependencies
- [Task 1] 无依赖,可先行
- [Task 2] 依赖 [Task 1](字段定义与库结构一致)
- [Task 3] 无依赖,可与 [Task 1] 并行
- [Task 4] 依赖 [Task 3](需确认响应结构/鉴权头),也可并行开发
- [Task 5] 依赖 [Task 1][Task 2][Task 3][Task 4]
- [Task 6] 依赖 [Task 5]
- [Task 7] 依赖 [Task 3](凭据键名一致),可并行
- [Task 8] 依赖全部前置任务
@@ -0,0 +1,13 @@
# Checklist
- [x] electric_gateway 表已建(迁移 20261024_icc_gateway.sql),字段集符合需求方确认:自增主键 id、icc_imei/device_code 双唯一、customer_id→customer、equip_id→customer_equipment 可空、org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;不含 companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description;与 db_seed.sql 结构一致 —— 迁移已执行到远程生产库(信息模式校验 16 列),db_seed.sql 451-522 行同步
- [x] 菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list)、ADMIN role_menu、sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)已写入迁移与 db_seed.sql 且幂等 —— 生产库已存在(menu62 查询命中、role_menu(1,62)、job 行 status=1)
- [x] ElectricGateway 实体与迁移结构一致(field 映射、索引)—— 实体含 @ForeignKey Customer/CustomerEquipment、@BelongsTo、3 索引
- [x] icc_gateway_mapper.ts 正确映射 DeviceInfoVO 字段(imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)并解析分页响应(PageVO{nextPage,totalCount,pageData})—— 实测映射正确(typeName=组合式电气火灾监控探测器、manufacturerName=大华、productModel=DH-…)
- [x] IccGatewaySyncService 复用 getIccAccessToken/iccFetch 真实鉴权,POST 分页全量拉取(pageSize=50,循环至短页,最大页数保护),orgCode→customer.icc_code 解析 customer_id —— 实测 pulled=2,customerId 解析为 3(KH20261016001)
- [x] 同步按 icc_imei(缺省 device_code)幂等 upsert,跳过缺双键或匹配不到客户的行,更新 synced_at,写 icc_sync_log(sync_type=electric_gateway);失败仅记录不抛业务错误 —— 实测首次 inserted=2,重跑 updated=2 skipped=0;icc_sync_log 2 条 status=1 record_count=2
- [x] GET /api/electric-gateways 列表/详情、POST /api/electric-gateways/sync 已实现;RBAC `device:list` 已映射;非 ADMIN 按客户归属限定数据范围 —— 无 token 401、admin 列表 200
- [x] 定时任务 icc_gateway_sync 已注册(job_registry BUILTIN_JOBS + job_scheduler buildRuntime syncIccGateways + db_seed.sql 种子)—— 调度器启动日志"已加载 6 个任务"含 icc_gateway_sync cron=*/10 * * * *
- [x] server/.env 含 ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD —— 已确认齐全
- [x] 前端 views/device/gateway/index.vue(搜索/同步按钮/表格/在线状态 tag/分页)、路由 /device/gateway、api/device.ts electricGatewayApi 已实现 —— 浏览器实测渲染/同步/筛选/重置全部正常
- [x] 后端编译(tsc --noEmit)与前端类型检查(vue-tsc -b)通过;运行验证:401/200、sync 真实拉取成功不 500、菜单与页面渲染正常 —— 全部实测通过
+46
View File
@@ -0,0 +1,46 @@
# 智电改造网关台账(ICC 消防物联设备同步)Spec
## Why
系统缺少"智电改造网关设备"台账。网关设备数据由 ICC 平台消防物联设备接口(`/evo-apigw/evo-fdbu/{version}/device/subsystem/page`)提供,需定时拉取并落库到新建的 `electric_gateway` 表,运营侧可按客户查看网关设备台账、手动触发同步。本表在设计文档中未定义,需依据接口文档新增表结构(已与需求方确认字段集)。
## What Changes
- **新增** `electric_gateway` 表(数据库迁移 + db_seed.sql 同步):自增主键 `id`、`icc_imei`/`device_code` 双唯一键、`device_name`、`customer_id`(→ customer,由接口 orgCode→customer.icc_code 解析)、`equip_id`(→ customer_equipment,可空,同步不填充)、`org_code`/`org_name`、`manufacturer_code`/`manufacturer_name`、`type_code`/`type_name`、`product_model`、`is_online`、`synced_at`、`created_at`;**不含** companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description(经需求方确认)
- **新增** ICC 网关设备同步:`IccGatewaySyncService` 复用真实 OAuth2 鉴权(`icc_customer_auth.ts` 的 `getIccAccessToken` + `iccFetch`,走 `.env` 已配置凭据),POST 分页拉取消防物联设备全量数据(pageNum/pageSize=50,循环至短页,全量不传 typeCode),按 imei(缺省时 deviceCode)幂等 upsert,写 `icc_sync_log`(sync_type=electric_gateway)
- **新增** 后端接口:`GET /api/electric-gateways` 列表、`GET /api/electric-gateways/:id` 详情、`POST /api/electric-gateways/sync` 手动同步
- **新增** 定时任务:注册 `icc_gateway_sync`(job_registry + job_scheduler buildRuntime + db_seed.sql sys_job 种子,默认 cron `*/10 * * * *`,与既有 ICC 同步任务口径一致)
- **新增** RBAC 映射:`/api/electric-gateways → device:list`
- **新增** 前端"智电改造网关台账"页面(`views/device/gateway/index.vue`),路由 `/device/gateway`,含"同步网关"手动按钮;菜单挂设备监控目录(parent=4)
- **新增** `.env` ICC 网关同步配置(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD,取自接口文档)
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`
## Impact
- Affected specs: 设备监控(设备台账、智电改造线路台账)、ICC 数据同步、RBAC 权限
- Affected code: server/src(entity/dto/service/controller/task/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
## ADDED Requirements
### Requirement: 网关台账查询(JWT+RBAC)
系统 SHALL 提供网关台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/status(在线状态)/关键词(device_name/device_code/icc_imei)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
#### Scenario: 成功查询
- **WHEN** 已登录用户 GET `/api/electric-gateways?page=1&pageSize=10&customerId=1`
- **THEN** 返回 `code=0,data{list,total}`,含设备名称/编码/imei/客户/设备型号/主机类型/厂商/在线状态/同步时间;无 token 返回 401,无权限返回 403
### Requirement: ICC 网关设备拉取同步(真实鉴权 + 定时 + 手动)
系统 SHALL 提供 ICC 网关设备拉取同步能力:`IccGatewaySyncService` 读取 `.env` 的 ICC 配置(复用 `icc_customer_auth` 真实 OAuth2 密码模式鉴权),POST `/evo-apigw/evo-fdbu/{version}/device/subsystem/page` 分页拉取消防物联设备全量数据(不传 typeCode,pageSize 50,循环至短页,带最大页数保护),字段映射集中在 `icc_gateway_mapper`,按 icc_imei(缺省用 device_code)幂等 upsert 写入 `electric_gateway`,`customer_id` 由 orgCode→customer.icc_code 解析(匹配不到或缺 icc_imei 与 device_code 则跳过),更新 `synced_at`,写 `icc_sync_log`(sync_type=electric_gateway,status/record_count/error_message)。同步入口:① 定时任务(jobKey=`icc_gateway_sync`,默认 `*/10 * * * *`);② 手动 `POST /api/electric-gateways/sync`(JWT+RBAC `device:list`)。
#### Scenario: 定时/手动拉取成功
- **WHEN** 定时任务触发或运营点击"同步网关"
- **THEN** 拉取 ICC 数据并 upsert 对应网关行,`icc_sync_log` 记录成功;返回 `code=0,data{pulled,synced,inserted,updated,skipped}`
#### Scenario: 手动同步鉴权失败
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-gateways/sync`
- **THEN** 返回 401/403,不触发拉取
#### Scenario: ICC 拉取失败(不可达/鉴权失败/接口异常)
- **WHEN** ICC 地址不可达或返回异常
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错,仅返回已拉取计数
### Requirement: 前端网关台账页
系统 SHALL 提供 `/device/gateway` 页面:搜索栏(设备名称/编码/imei、客户、在线状态)、"同步网关"按钮(调 `/api/electric-gateways/sync`,成功后刷新)、表格(设备名称/编码/imei/客户/厂商/主机类型/设备型号/在线状态 tag/同步时间)、分页;样式与智电改造线路台账一致,全部文本经 Vue 插值转义。
#### Scenario: 打开页面
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造网关台账
- **THEN** 渲染列表并展示分页与筛选
## MODIFIED Requirements
(无既有功能被修改)
+39
View File
@@ -0,0 +1,39 @@
# Tasks
- [x] Task 1: 数据库层:新建迁移 `database/migrations/20261024_icc_gateway.sql` 建 `electric_gateway`(字段集经需求方确认),并把建表/菜单/角色菜单/定时任务种子同步进 `db_seed.sql`;幂等(IF NOT EXISTS / ON CONFLICT)
- [x] SubTask 1.1: 核对生产库是否已有 electric_gateway,避免重复;确认 sys_menu 现有最大 id(迁移用 id=62 并 setval)—— 已核对:表无先例、菜单 id 62 空闲;迁移已执行到远程生产库
- [x] SubTask 1.2: 迁移内容:建表 electric_gateway(自增 id;icc_imei/device_code 唯一;customer_id→customer;equip_id→customer_equipment 可空;org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;索引 customer_id/equip_id/icc_imei);菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list);ADMIN role_menu;sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)
- [x] SubTask 1.3: db_seed.sql 同步对应语句(保持幂等一致)—— 已追加段落(451-522 行)
- [x] Task 2: 后端实体与映射器:
- [x] SubTask 2.1: `server/src/entity/electric_gateway.entity.ts`(timestamps:false,结构对齐迁移文件)
- [x] SubTask 2.2: `server/src/task/icc_gateway_mapper.ts`:集中配置端点 `/evo-apigw/evo-fdbu/{version}/device/subsystem/page`、DeviceInfoVO 字段映射(id/imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)、分页响应解析(PageVO{nextPage,totalCount,pageData})
- [x] Task 3: 后端同步服务 `server/src/service/icc_gateway_sync.service.ts`:
- [x] SubTask 3.1: 复用 `icc_customer_auth.ts` 的 `getIccAccessToken`/`iccFetch` 真实鉴权;读取 `.env`(ICC_BASE_URL/ICC_VERSION),配置缺失视为失败仅记日志
- [x] SubTask 3.2: POST 分页拉取(pageSize=50、不传 typeCode、循环至短页、最大页数保护),预取 orgCode→customer(id) 映射(customer.icc_code)
- [x] SubTask 3.3: 按 icc_imei(缺省 device_code)幂等 upsert;缺 imei 与 device_code 或匹配不到客户则跳过;更新 synced_at;写 icc_sync_log(sync_type=electric_gateway);拉取失败仅记录不抛业务错误 —— 实测同步成功(pulled=2),重跑 updated=2 幂等
- [x] Task 4: 后端查询服务/Controller/DTO + RBAC:
- [x] SubTask 4.1: `server/src/service/electric_gateway.service.ts`(list/detail,过滤:keyword=device_name/device_code/icc_imei、customer_id、equip_id、is_online;非 ADMIN 按 salesmanId 限定客户范围;decorate 补全客户/设备展示字段)
- [x] SubTask 4.2: `server/src/dto/electric_gateway.dto.ts`(查询/同步入参校验)
- [x] SubTask 4.3: `server/src/controller/electric_gateway.controller.ts`:GET `/api/electric-gateways`、GET `/:id`、POST `/sync`
- [x] SubTask 4.4: `rbac.middleware.ts` 追加 `/api/electric-gateways → device:list`
- [x] Task 5: 定时任务注册:
- [x] SubTask 5.1: `server/src/task/icc_gateway_sync.task.ts`(jobKey=`icc_gateway_sync`,默认 cron `*/10 * * * *`,handler 调 runtime.syncIccGateways)
- [x] SubTask 5.2: `job_registry.ts`:JobRuntime 增加 `syncIccGateways`,BUILTIN_JOBS 追加 iccGatewaySyncJob
- [x] SubTask 5.3: `job_scheduler.service.ts` buildRuntime 增加 `syncIccGateways`(匿名上下文注入 IccGatewaySyncService)
- [x] SubTask 5.4: `server/.env` 补全 ICC 网关同步凭据 —— 已确认 .env 中配置齐全(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID=xtzdapp、ICC_CLIENT_SECRET、ICC_USERNAME=XTZD接口、ICC_PASSWORD)
- [x] Task 6: 前端:
- [x] SubTask 6.1: `client/src/api/device.ts` 新增 electricGatewayApi.list/get/sync
- [x] SubTask 6.2: `client/src/views/device/gateway/index.vue`(搜索栏+同步按钮+表格+在线状态 tag+分页,样式对齐 views/device/line/index.vue)
- [x] SubTask 6.3: `client/src/router/index.ts` 注册 `/device/gateway`(挂 device 目录)
- [x] Task 7: 验证:
- [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查通过 —— 均通过(exit 0)
- [x] SubTask 7.2: 启动服务:无 token 401(实测 401)、admin 列表 200(实测 total=2)、`POST /sync` 真实拉取成功(pulled=2→落库→重跑 updated=2)、定时任务 icc_gateway_sync 已注册(调度器加载 6 个任务,含 icc_gateway_sync cron */10 * * * *)
- [x] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用 —— 实测:菜单出现、9 列表格渲染 2 条、同步按钮提示"拉取 2 条,新增 0,更新 2"并刷新、关键词筛选/重置正常
# Task Dependencies
- Task 2/6 依赖 Task 1(表存在)
- Task 3 依赖 Task 2(映射器)
- Task 4 依赖 Task 1/2
- Task 5 依赖 Task 3
- Task 6 依赖 Task 4(接口可用)
- Task 7 依赖全部
@@ -0,0 +1,12 @@
# Checklist
- [x] electric_line 表结构按 spec 重建:删 building_id/floor_no/location/icc_device_code;equip_id 可空;新增 imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date;迁移已执行到生产库且 db_seed.sql/db_init.sql 一致
- [x] 设计文档「表结构新增及变更.md」已补充 electric_line 新结构定义
- [x] ElectricLine 实体与迁移一致(gatewayId FK→electric_gateway,删除旧占位字段)
- [x] icc_line_mapper 按 DetectorVO 映射:lineCode=detectorCode、lineName=detectorName、status 01→1/02→0、orgCode/parentCode 透出作匹配键;parseLinePage 支持 PageVO 分页结构
- [x] icc_line_sync.service 复用 getIccAccessToken/iccFetch 真实鉴权,POST 分页 pageSize=50 短页停止全量拉取;按 line_code 幂等 upsert(equip_id 不填);customer_id 由 orgCode 匹配、gateway_id 由 parentCode 匹配,失败跳过;写 icc_sync_log(sync_type=electric_line);拉取失败仅记日志不抛业务错
- [x] 定时任务 icc_line_sync(jobKey/cron */10)注册链路可用;POST /api/electric-lines/sync 返回 {pulled,inserted,updated,skipped,synced}
- [x] 列表接口 GET /api/electric-lines 返回新字段(含 gateway/type/imei),非 ADMIN 限定 customer_id,keyword 检索含 imei
- [x] 前端 /device/line:删除建筑/楼层/位置列,新增 IMEI/探测器类型/回路/通道/消防系统/生产日期/所属网关列;无 v-html;同步按钮调用真实接口
- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过
- [x] 真实同步端到端:admin 登录 → 手动同步 → 探测器数据落库且 customer/gateway 关联正确 → 重跑幂等 updated;浏览器页面新列渲染正常
+71
View File
@@ -0,0 +1,71 @@
# 智电线路真实 ICC 同步 Spec(add-icc-line-sync)
## Why
现有 electric_line(智电改造线路台账)为占位实现:表结构是手工设计的占位字段(building/floor/location 等),同步链路 `icc_line_sync.service.ts` 走假的 `ICC_TOKEN` 配置、无真实鉴权。本次依据 ICC 平台接口文档第三节「消防探测器分页查询」(`POST /evo-apigw/evo-fdbu/{version}/detector/subsystem/page`)将线路台账升级为**真实数据同步**:重建表结构(按 DetectorVO 落库字段 + 与 customer/customer_equipment/electric_gateway 三张表的关联),后端替换为真实 OAuth2 拉取(复用 icc_customer_auth),前端台账页补充新字段展示并保留手动同步按钮。
## What Changes
- **表结构变更(BREAKING)**:`electric_line` 按 DetectorVO 重建字段集:
- 删除:`building_id`、`floor_no`、`location`、`icc_device_code`(占位字段无真实数据源)
- `equip_id` 由 `NOT NULL` 改为**可空**(→ customer_equipment,同步不填充,留待手动关联)
- 新增:`imei`、`gateway_id`(→ electric_gateway)、`type_code`、`type_name`、`fire_system_code`、`fire_system_name`、`part_unit_loop_code`、`channel_no`、`channel_sn`、`produce_date`
- 保留:`line_code`(= detectorCode,唯一幂等键)、`line_name`(= detectorName)、`customer_id`(NOT NULL)、`status`(= detectorStatus:01→1 在线 / 02→0 离线)、`synced_at`、`created_at`
- 不落库(仅作关联匹配键):`orgCode`→customer.icc_code 反查 customer_id;`parentCode`→electric_gateway.device_code 反查 gateway_id
- **后端同步真实化**:重写 `icc_line_mapper.ts`(DetectorVO 映射 + 分页解析)与 `icc_line_sync.service.ts`(复用 `getIccAccessToken`/`iccFetch`,POST 分页 pageSize=50 短页停止、全量拉取、按 line_code 幂等 upsert、写 icc_sync_log sync_type=electric_line、失败仅记日志不抛)
- **关联规则**:customer_id 由 orgCode 匹配;gateway_id 由 parentCode 匹配 device_code;equip_id 同步不填充
- **前端台账页更新**:`views/device/line/index.vue` 移除楼层/建筑/位置列,新增探测器类型、IMEI、回路/通道、消防系统、生产日期、网关等列;保留「同步线路」手动按钮与筛选
- **设计文档同步**:更新 `系统设计/表结构新增及变更.md` 补充 electric_line 新结构
- 定时任务 `icc_line_sync`(jobKey 不变,cron */10)、菜单 61、RBAC `/api/electric-lines → device:list`、前端路由 `/device/line` 均沿用既有,无需新增
## Impact
- Affected specs:add-icc-line-ledger(本 spec 升级其占位实现)、add-icc-gateway-sync(复用其真实同步模式与关联表)
- Affected code:
- 后端:`server/src/entity/electric_line.entity.ts`、`server/src/task/icc_line_mapper.ts`、`server/src/service/icc_line_sync.service.ts`、`server/src/service/electric_line.service.ts`(decorate 去掉 building、新增 gateway/类型展示)、`server/src/controller/electric_line.controller.ts`(不动)
- 迁移:`database/migrations/20261011_icc_line_ledger.sql`(重建 electric_line 结构)或新增增量迁移;`database/db_seed.sql` 同步
- 前端:`client/src/views/device/line/index.vue`、`client/src/api/device.ts`(ElectricLine 类型)
- 设计文档:`系统设计/表结构新增及变更.md`
## ADDED Requirements
### Requirement: electric_line 表结构按 DetectorVO 重建
系统 SHALL 将 `electric_line` 重建为与 DetectorVO 对应的字段集,且:
- `line_code` 存 detectorCode(唯一,幂等键)
- `customer_id` NOT NULL,由 orgCode→customer.icc_code 反查
- `gateway_id` 可空,由 parentCode→electric_gateway.device_code 反查
- `equip_id` 可空,同步不填充
#### Scenario: 迁移执行成功
- **WHEN** 执行迁移到生产库
- **THEN** electric_line 包含新字段集,旧字段(building_id/floor_no/location/icc_device_code)被移除,equip_id 可空,索引重建;db_seed.sql 与生产库一致
### Requirement: 真实 ICC 探测器拉取同步
系统 SHALL 复用 `getIccAccessToken`/`iccFetch` 真实鉴权,POST `{baseUrl}/evo-apigw/evo-fdbu/{version}/detector/subsystem/page` 分页全量拉取(pageNum 递增、pageSize=50、短页停止、MAX_PAGES 保护),按 line_code 幂等 upsert,写 icc_sync_log(sync_type=electric_line)。
#### Scenario: 同步成功
- **WHEN** 手动点击「同步线路」或定时任务触发
- **THEN** 返回 {pulled, inserted, updated, skipped, synced};新探测器落库、重复 detectorCode 更新;customer_id 解析失败的行跳过;写成功日志
#### Scenario: 同步失败
- **WHEN** ICC 拉取异常(超时/HTTP 错误/鉴权失败)
- **THEN** 仅写 icc_sync_log 失败日志,不抛业务错误,接口返回 code 200 与当前计数
### Requirement: 前端台账页按新字段展示
系统 SHALL 更新 `views/device/line/index.vue`:删除建筑/楼层/位置列,新增探测器类型、IMEI、回路/通道、消防系统、生产日期、所属网关列;保留筛选(客户/状态/关键词)与「同步线路」手动按钮。
#### Scenario: 页面渲染
- **WHEN** 登录 admin 打开 /device/line
- **THEN** 表格展示新字段列,无 v-html,同步按钮触发 POST /api/electric-lines/sync
## MODIFIED Requirements
### Requirement: 线路台账列表接口(原有)
`GET /api/electric-lines` 保留分页/筛选/RBAC(非 ADMIN 限定 customer_id),decorate 从 building 改为 gateway/类型/IMEI 展示字段,删除 customer_building 关联。
#### Scenario: 列表返回
- **WHEN** 带权限请求 GET /api/electric-lines
- **THEN** 返回行包含 lineCode/lineName/imei/customer/gateway/type/loop/channel/fireSystem/produceDate/status/syncedAt
## REMOVED Requirements
### Requirement: 占位 ICC 同步实现(原 icc_line_sync.service / icc_line_mapper 假配置映射)
**Reason**:无真实鉴权与接口,与 ICC 文档字段不对应。
**Migration**:删除 `ICC_TOKEN` 占位用法,改为复用 icc_customer_auth 真实鉴权;映射按 DetectorVO 重写。
+25
View File
@@ -0,0 +1,25 @@
# Tasks
- [x] Task 1: 数据库迁移:重建 `electric_line` 结构(删 building_id/floor_no/location/icc_device_code;equip_id 改可空;新增 imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date),迁移脚本 `database/migrations/20261024_icc_line_rebuild.sql`,同步更新 `database/db_seed.sql`(fk_line_gateway 幂等补充)与 `database/db_init.sql`(基线建表);`gateway_id` 建 `REFERENCES electric_gateway(id)` 与索引
- [x] SubTask 1.1: 核对生产库 electric_line 现状(旧结构迁移而来,无占位数据阻碍),确认 ALTER 方案幂等
- [x] SubTask 1.2: 执行迁移到远程生产库并验证字段/索引/外键(columns 18 个、idx_line_gateway/imei、fk_line_gateway 均就绪)
- [x] SubTask 1.3: 同步 `系统设计/表结构新增及变更.md`(补充 electric_line 新表结构定义)
- [x] Task 2: 后端实体:按新字段重写 `server/src/entity/electric_line.entity.ts`(gatewayId FK→ElectricGateway,删除 building/floor/location/iccDeviceCode)
- [x] Task 3: 映射层:重写 `server/src/task/icc_line_mapper.ts`
- [x] SubTask 3.1: 定义 `ICC_LINE_ENDPOINT = /evo-apigw/evo-fdbu/{version}/detector/subsystem/page` 与 DetectorVO 字段映射(lineCode=detectorCode、lineName=detectorName、imei、typeCode/typeName、fireSystemCode/Name、partUnitLoopCode、channelNo、channelSn、produceDate、status 归一 01→1/02→0、orgCode、parentCode 作为匹配键透出)
- [x] SubTask 3.2: `parseLinePage(payload)` 解析 PageVO{nextPage,totalCount,pageData}(兼容数组形态)
- [x] Task 4: 同步服务:重写 `server/src/service/icc_line_sync.service.ts`,复用 `getIccAccessToken`/`iccFetch`,POST 分页 pageSize=50 短页停止全量拉取;预取 orgCode→customer.id(customer.icc_code)与 parentCode→gateway.id(electric_gateway.device_code)映射避免 N+1;按 line_code 幂等 upsert(equip_id 不填);写 icc_sync_log(sync_type=electric_line);拉取失败仅记日志不抛
- [x] SubTask 4.1: 定时任务 `icc_line_sync.task.ts`(jobKey/cron 不变)与 job_registry/JobRuntime(job_scheduler.service.ts syncIccLines)链路可用
- [x] SubTask 4.2: 控制器 `POST /api/electric-lines/sync` 复用返回结构(pulled/inserted/updated/skipped/synced)
- [x] Task 5: 列表服务:`server/src/service/electric_line.service.ts` 更新 decorate(删除 building 反查,新增 gateway/type/imei 展示字段);keyword 检索范围补充 imei
- [x] Task 6: 前端:`client/src/api/device.ts` 更新 `ElectricLine` 类型(新字段);`client/src/views/device/line/index.vue` 删除建筑/楼层/位置列,新增 IMEI/探测器类型/回路/通道/消防系统/生产日期/所属网关列,筛选/分页/同步按钮保留
- [x] Task 7: 验证:
- [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 均 0 错误
- [x] SubTask 7.2: 真实拉取 ICC 探测器(admin token,拉取 4 条插入 4 条;重跑幂等 updated=4;icc_sync_log 3 条 status=1;列表接口返回新字段)
- [x] SubTask 7.3: 浏览器 admin 登录验证 /device/line 页面新列渲染与手动同步按钮(11 列齐全、同步提示「拉取 4 条,新增 0,更新 4」)
# Task Dependencies
- Task 2/3/6 依赖 Task 1(表结构先就绪)
- Task 4 依赖 Task 2/3
- Task 5 依赖 Task 2
- Task 7 依赖全部
+2
View File
@@ -86,6 +86,8 @@ export const customerApi = {
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'), getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
syncIccCustomers: () => request.post('/customers/sync/icc'),
createCustomer: (data: CustomerCreateParams) => createCustomer: (data: CustomerCreateParams) =>
request.post('/customers', data), request.post('/customers', data),
+99 -4
View File
@@ -154,9 +154,6 @@ export interface ElectricLine {
iccDeviceCode?: string; iccDeviceCode?: string;
lineName?: string; lineName?: string;
customerId: number; customerId: number;
buildingId?: number;
floorNo?: string;
location?: string;
status: number; status: number;
syncedAt?: string; syncedAt?: string;
createdAt: string; createdAt: string;
@@ -165,7 +162,19 @@ export interface ElectricLine {
customerName?: string; customerName?: string;
equipCode?: string; equipCode?: string;
equipName?: string; equipName?: string;
buildingName?: string; // 探测器 / 网关相关字段
imei?: string;
gatewayId?: number;
gatewayCode?: string;
gatewayName?: string;
typeCode?: string;
typeName?: string;
fireSystemCode?: string;
fireSystemName?: string;
partUnitLoopCode?: string;
channelNo?: string;
channelSn?: string;
produceDate?: string;
} }
export interface ElectricLineQuery { export interface ElectricLineQuery {
@@ -202,4 +211,90 @@ export const electricLineApi = {
sync: () => sync: () =>
request.post<IccLineSyncResult>('/electric-lines/sync'), request.post<IccLineSyncResult>('/electric-lines/sync'),
};
// ==================== 智电改造网关台账 ====================
export interface ElectricGatewayListItem {
id: number;
iccImei?: string;
deviceCode?: string;
deviceName?: string;
customerId: number;
equipId?: number;
orgCode?: string;
orgName?: string;
manufacturerCode?: string;
manufacturerName?: string;
typeCode?: string;
typeName?: string;
productModel?: string;
isOnline: number;
syncedAt?: string;
createdAt: string;
// 联表补全字段
customerNo?: string;
customerName?: string;
equipCode?: string;
equipName?: string;
}
export interface ElectricGatewayQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
equipId?: number;
isOnline?: number;
}
export interface ElectricGatewayPageResult {
list: ElectricGatewayListItem[];
total: number;
page: number;
pageSize: number;
}
export interface IccGatewaySyncResult {
pulled: number;
synced: number;
inserted: number;
updated: number;
skipped: number;
}
export const electricGatewayApi = {
getList: (params: ElectricGatewayQuery) =>
request.get<ElectricGatewayPageResult>('/electric-gateways', { params }),
getDetail: (id: number) =>
request.get<ElectricGatewayListItem>(`/electric-gateways/${id}`),
sync: () =>
request.post<IccGatewaySyncResult>('/electric-gateways/sync'),
};
// ==================== 线路用电(ICC 模拟量) ====================
export interface ElectricityAnalogRow {
analogType?: string;
analogName?: string;
analogUnit?: string;
analogValue?: number | string | null;
}
export interface ElectricityLatestSnapshot {
lineId: number;
lineCode?: string;
lineName?: string;
imei?: string;
customerId?: number;
collectTime?: string | null;
hasData: boolean;
list: ElectricityAnalogRow[];
}
export const electricityDataApi = {
getLatestByLine: (lineId: number) =>
request.get<ElectricityLatestSnapshot>('/electricity-data/latest', { params: { lineId } }),
}; };
+6
View File
@@ -193,6 +193,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/device/line/index.vue"), component: () => import("@/views/device/line/index.vue"),
meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" } meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" }
}, },
{
path: "device/gateway",
name: "GatewayLedger",
component: () => import("@/views/device/gateway/index.vue"),
meta: { requiresAuth: true, title: "智电改造网关台账", permission: "device:list" }
},
{ {
path: "base", path: "base",
name: "Base", name: "Base",
+39 -1
View File
@@ -19,6 +19,10 @@
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p> <p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<a-button :loading="syncing" @click="handleSync">
<template #icon><SyncOutlined /></template>
同步客户
</a-button>
<a-button type="primary" @click="handleAdd"> <a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template> <template #icon><PlusOutlined /></template>
新增客户 新增客户
@@ -323,13 +327,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import { import {
SearchOutlined, SearchOutlined,
ReloadOutlined, ReloadOutlined,
PlusOutlined, PlusOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
SyncOutlined,
TeamOutlined, TeamOutlined,
AimOutlined, AimOutlined,
FileProtectOutlined, FileProtectOutlined,
@@ -352,6 +357,7 @@ const permissionStore = usePermissionStore();
// ==================== 数据 ==================== // ==================== 数据 ====================
const loading = ref(false); const loading = ref(false);
const syncing = ref(false);
const dataSource = ref<CustomerInfo[]>([]); const dataSource = ref<CustomerInfo[]>([]);
const total = ref(0); const total = ref(0);
const editNo = ref<string>(''); const editNo = ref<string>('');
@@ -568,6 +574,38 @@ function getSalesmanName(id?: number) {
return found?.label ?? '-'; return found?.label ?? '-';
} }
// ==================== ICC 手动同步 ====================
function handleSync() {
Modal.confirm({
title: '同步客户',
content: '将从 ICC 平台拉取客户信息,并按 ICC 客户编号幂等新增/更新本地客户档案,是否继续?',
okText: '开始同步',
cancelText: '取消',
onOk: async () => {
syncing.value = true;
try {
const res: any = await customerApi.syncIccCustomers();
if (res.code === 200) {
const d = res.data || {};
const pulled = d.pulled ?? 0;
const inserted = d.inserted ?? 0;
const updated = d.updated ?? 0;
const skipped = d.skipped ?? 0;
message.success(`同步完成:拉取 ${pulled} 条,新增 ${inserted} 条,更新 ${updated} 条,跳过 ${skipped} 条`);
fetchList();
fetchStats();
} else {
message.error(res.message || '同步失败');
}
} catch (error) {
message.error('同步失败,请稍后重试');
} finally {
syncing.value = false;
}
},
});
}
// ==================== 搜索重置 ==================== // ==================== 搜索重置 ====================
function handleSearch() { function handleSearch() {
searchForm.page = 1; searchForm.page = 1;
+510
View File
@@ -0,0 +1,510 @@
<template>
<div class="device-gateway">
<!-- 顶部标题 -->
<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">ELECTRIC GATEWAY LEDGER</span>
</div>
<p class="desc">由 ICC 平台同步的智电改造网关台账,按客户、设备查看网关及在线状态。</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.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">在线状态</label>
<a-select v-model:value="searchForm.isOnline" placeholder="全部状态" allow-clear>
<a-select-option :value="1">在线</a-select-option>
<a-select-option :value="0">离线</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="设备名称 / 设备编码 / IMEI"
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="primary" :loading="syncing" @click="handleSync">
<template #icon><SyncOutlined /></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 === 'deviceCode'">
<span class="mono">{{ record.deviceCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'deviceName'">
<span class="device-name">{{ record.deviceName || '-' }}</span>
</template>
<template v-else-if="column.key === 'iccImei'">
<span class="mono">{{ record.iccImei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template>
<template v-else-if="column.key === 'manufacturerName'">
{{ record.manufacturerName || '-' }}
</template>
<template v-else-if="column.key === 'typeName'">
{{ record.typeName || '-' }}
</template>
<template v-else-if="column.key === 'productModel'">
{{ record.productModel || '-' }}
</template>
<template v-else-if="column.key === 'isOnline'">
<a-tag :color="record.isOnline === 1 ? 'success' : 'error'">
{{ record.isOnline === 1 ? '在线' : '离线' }}
</a-tag>
</template>
<template v-else-if="column.key === 'syncedAt'">
<span>{{ formatTime(record.syncedAt) }}</span>
</template>
</template>
<template #emptyText>
<a-empty description="暂无网关数据,可点击右上角「同步网关」从 ICC 拉取" />
</template>
</a-table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import {
electricGatewayApi,
type ElectricGatewayListItem,
type ElectricGatewayQuery,
} from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const syncing = ref(false);
const dataSource = ref<ElectricGatewayListItem[]>([]);
const total = ref(0);
const searchForm = reactive<ElectricGatewayQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
isOnline: undefined as any,
});
const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [
{ title: '设备名称', dataIndex: 'deviceName', key: 'deviceName', ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', key: 'deviceCode', ellipsis: true, width: 170 },
{ title: '设备 IMEI', dataIndex: 'iccImei', key: 'iccImei', ellipsis: true, width: 170 },
{ title: '客户名称', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '厂商名称', dataIndex: 'manufacturerName', key: 'manufacturerName', ellipsis: true, width: 140 },
{ title: '主机类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
{ title: '设备型号', dataIndex: 'productModel', key: 'productModel', ellipsis: true, width: 140 },
{ title: '在线状态', dataIndex: 'isOnline', key: 'isOnline', align: 'center' as const, width: 90 },
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await electricGatewayApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取网关列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.isOnline = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
async function handleSync() {
syncing.value = true;
try {
const res: any = await electricGatewayApi.sync();
if (res.code === 200) {
const d = res.data || {};
message.success(`同步完成:拉取 ${d.pulled ?? 0} 条,新增 ${d.inserted ?? 0},更新 ${d.updated ?? 0}`);
fetchList();
} else {
message.error(res.message || '同步失败');
}
} catch (error) {
message.error('同步失败,请稍后重试');
} finally {
syncing.value = false;
}
}
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>
.device-gateway {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
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-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 #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
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;
}
.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 #dbe4ee;
}
.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;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.device-name {
font-weight: 600;
color: #0d1c2c;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
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>
+116 -12
View File
@@ -16,7 +16,7 @@
<span class="main-title">智电改造线路台账</span> <span class="main-title">智电改造线路台账</span>
<span class="sub-title">ELECTRIC LINE LEDGER</span> <span class="sub-title">ELECTRIC LINE LEDGER</span>
</div> </div>
<p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、设备、建筑查看线路及在线状态。</p> <p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、网关查看线路、探测器及在线状态。</p>
</div> </div>
</div> </div>
</div> </div>
@@ -47,7 +47,7 @@
<label class="filter-label">关键词检索</label> <label class="filter-label">关键词检索</label>
<a-input <a-input
v-model:value="searchForm.keyword" v-model:value="searchForm.keyword"
placeholder="线路名称 / 线路编码" placeholder="线路名称 / 编码 / IMEI"
allow-clear allow-clear
@pressEnter="handleSearch" @pressEnter="handleSearch"
> >
@@ -103,16 +103,32 @@
<span class="line-name">{{ record.lineName || '-' }}</span> <span class="line-name">{{ record.lineName || '-' }}</span>
</template> </template>
<template v-else-if="column.key === 'imei'">
<span class="mono">{{ record.imei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'"> <template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }} {{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template> </template>
<template v-else-if="column.key === 'equipId'"> <template v-else-if="column.key === 'gatewayId'">
{{ record.equipName ? `${record.equipName}(${record.equipCode})` : '-' }} {{ record.gatewayName ? `${record.gatewayName}(${record.gatewayCode})` : (record.gatewayCode || '-') }}
</template> </template>
<template v-else-if="column.key === 'buildingId'"> <template v-else-if="column.key === 'typeName'">
{{ record.buildingName || '-' }} {{ record.typeName || '-' }}
</template>
<template v-else-if="column.key === 'loopChannel'">
{{ [record.partUnitLoopCode, record.channelNo].filter(Boolean).join('/') || '-' }}
</template>
<template v-else-if="column.key === 'fireSystemName'">
{{ record.fireSystemName || '-' }}
</template>
<template v-else-if="column.key === 'produceDate'">
{{ record.produceDate || '-' }}
</template> </template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
@@ -124,6 +140,11 @@
<template v-else-if="column.key === 'syncedAt'"> <template v-else-if="column.key === 'syncedAt'">
<span>{{ formatTime(record.syncedAt) }}</span> <span>{{ formatTime(record.syncedAt) }}</span>
</template> </template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewData(record)">
线路数据
</a-button>
</template>
</template> </template>
<template #emptyText> <template #emptyText>
@@ -133,9 +154,31 @@
</div> </div>
</div> </div>
</div> </div>
<a-modal
v-model:open="analogModal.open"
:title="analogModal.title"
:width="720"
:footer="null"
>
<div v-if="analogModal.loading" class="modal-tip">加载中...</div>
<div v-else-if="!analogModal.snapshot || !analogModal.snapshot.hasData" class="modal-tip">暂无模拟量数据,可点击「同步线路」或等待定时同步后查看</div>
<template v-else>
<div class="modal-meta">
<span>线路:{{ analogModal.snapshot.lineName || '-' }}</span>
<span>编码:{{ analogModal.snapshot.lineCode || '-' }}</span>
<span>采集时间:{{ formatTime(analogModal.snapshot.collectTime) }}</span>
</div>
<a-table
:columns="analogColumns"
:data-source="analogModal.snapshot.list"
:pagination="false"
row-key="analogType"
size="small"
/>
</template>
</a-modal>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
@@ -146,8 +189,10 @@ import {
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { import {
electricLineApi, electricLineApi,
electricityDataApi,
type ElectricLine, type ElectricLine,
type ElectricLineQuery, type ElectricLineQuery,
type ElectricityLatestSnapshot,
} from '@/api/device'; } from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer'; import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll'; import { useTableScroll } from '@/composables/useTableScroll';
@@ -156,6 +201,25 @@ const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false); const loading = ref(false);
const syncing = ref(false); const syncing = ref(false);
const analogModal = reactive<{
open: boolean;
loading: boolean;
title: string;
snapshot: ElectricityLatestSnapshot | null;
}>({
open: false,
loading: false,
title: '线路模拟数据',
snapshot: null,
});
const analogColumns = [
{ title: '模拟量名称', dataIndex: 'analogName', key: 'analogName', ellipsis: true },
{ title: '类型', dataIndex: 'analogType', key: 'analogType', ellipsis: true, width: 100 },
{ title: '数值', dataIndex: 'analogValue', key: 'analogValue', align: 'center' as const, width: 120 },
{ title: '单位', dataIndex: 'analogUnit', key: 'analogUnit', align: 'center' as const, width: 80 },
];
const dataSource = ref<ElectricLine[]>([]); const dataSource = ref<ElectricLine[]>([]);
const total = ref(0); const total = ref(0);
@@ -172,13 +236,16 @@ const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [ const columns = [
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 160 }, { title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 160 },
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true }, { title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
{ title: 'IMEI', dataIndex: 'imei', key: 'imei', ellipsis: true, width: 180 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 }, { title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '所属设备', dataIndex: 'equipId', key: 'equipId', ellipsis: true, width: 180 }, { title: '所属网关', dataIndex: 'gatewayId', key: 'gatewayId', ellipsis: true, width: 180 },
{ title: '所属建筑', dataIndex: 'buildingId', key: 'buildingId', ellipsis: true, width: 140 }, { title: '探测器类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 }, { title: '回路/通道', dataIndex: 'loopChannel', key: 'loopChannel', ellipsis: true, width: 160 },
{ title: '所在位置', dataIndex: 'location', key: 'location', ellipsis: true }, { title: '消防系统', dataIndex: 'fireSystemName', key: 'fireSystemName', ellipsis: true, width: 140 },
{ title: '生产日期', dataIndex: 'produceDate', key: 'produceDate', ellipsis: true, width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 80 }, { title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 80 },
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 }, { title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
{ title: '操作', key: 'action', align: 'center' as const, fixed: 'right' as const, width: 100 },
]; ];
const pagination = computed(() => ({ const pagination = computed(() => ({
@@ -262,6 +329,25 @@ async function handleSync() {
} }
} }
async function handleViewData(record: ElectricLine) {
analogModal.open = true;
analogModal.loading = true;
analogModal.title = `线路数据 - ${record.lineName || record.lineCode}`;
analogModal.snapshot = null;
try {
const res: any = await electricityDataApi.getLatestByLine(record.id);
if (res.code === 200) {
analogModal.snapshot = res.data;
} else {
message.error(res.message || '获取模拟数据失败');
}
} catch (error) {
message.error('获取模拟数据失败,请稍后重试');
} finally {
analogModal.loading = false;
}
}
function formatTime(val?: string) { function formatTime(val?: string) {
if (!val) return '-'; if (!val) return '-';
const d = new Date(val); const d = new Date(val);
@@ -499,4 +585,22 @@ function formatTime(val?: string) {
background: #136bfb; background: #136bfb;
border-color: #136bfb; border-color: #136bfb;
} }
</style> .modal-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
font-size: 13px;
color: #5b6b7e;
margin-bottom: 12px;
}
.modal-meta span {
font-weight: 600;
color: #0d1c2c;
}
.modal-tip {
padding: 40px 0;
text-align: center;
color: #5b6b7e;
}
</style>
+1
View File
@@ -66,6 +66,7 @@ export default defineConfig({
} }
}, },
server: { server: {
host: "0.0.0.0",
port: 3000, port: 3000,
proxy: { proxy: {
"/api": { "/api": {
+43 -266
View File
@@ -41,7 +41,7 @@
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz" resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.23.0": "@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.23.0":
version "7.29.7" version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz" resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
@@ -275,6 +275,14 @@
"@babel/types" "^7.29.7" "@babel/types" "^7.29.7"
debug "^4.3.1" debug "^4.3.1"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@babel/types@7.23.0": "@babel/types@7.23.0":
version "7.23.0" version "7.23.0"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz" resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
@@ -284,14 +292,6 @@
"@babel/helper-validator-identifier" "^7.22.20" "@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0": "@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
version "3.6.1" version "3.6.1"
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz" resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
@@ -307,116 +307,6 @@
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz" resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
"@esbuild/android-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
"@esbuild/android-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
"@esbuild/android-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
"@esbuild/darwin-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
"@esbuild/darwin-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
"@esbuild/freebsd-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
"@esbuild/freebsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
"@esbuild/linux-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
"@esbuild/linux-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
"@esbuild/linux-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
"@esbuild/linux-loong64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
"@esbuild/linux-mips64el@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
"@esbuild/linux-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
"@esbuild/linux-riscv64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
"@esbuild/linux-s390x@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
"@esbuild/linux-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
"@esbuild/netbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
"@esbuild/openbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
"@esbuild/sunos-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
"@esbuild/win32-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
"@esbuild/win32-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
"@esbuild/win32-x64@0.21.5": "@esbuild/win32-x64@0.21.5":
version "0.21.5" version "0.21.5"
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz" resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
@@ -515,7 +405,7 @@
"@nodelib/fs.stat" "2.0.5" "@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9" run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": "@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5" version "2.0.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -528,121 +418,6 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@rollup/rollup-android-arm-eabi@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz#3a04f01e9f01392bbef5920b94aa3b88794be7ab"
integrity sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==
"@rollup/rollup-android-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz#e371b653ceabc900790ae73f5548a0fd7cd63a70"
integrity sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==
"@rollup/rollup-darwin-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz#2a5aa70432e39816d666d79287a7324cfc3b4e72"
integrity sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==
"@rollup/rollup-darwin-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz#c3b5b49629379cd9cdc5d841bf00ed44ebf393dd"
integrity sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==
"@rollup/rollup-freebsd-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz#f929d8e0462fae6602fc960beeabd7287d859283"
integrity sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==
"@rollup/rollup-freebsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz#c01cb58031226f95d0900b1ec847f4fb32c6e809"
integrity sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==
"@rollup/rollup-linux-arm-gnueabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz#f29d890c4858c8e0d3be01677eef4f6a359eed9d"
integrity sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==
"@rollup/rollup-linux-arm-musleabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz#1ebfc8eb9f66136ed2faae5f44995add5ca3c964"
integrity sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==
"@rollup/rollup-linux-arm64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz#c1fa823c2c4ce46ba7f61de1a4c3fdadd4fb4e7b"
integrity sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==
"@rollup/rollup-linux-arm64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz#a7f18854d0471b78bda8ea38f0891a4e059b571d"
integrity sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==
"@rollup/rollup-linux-loong64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz#83658a9a4576bcce8cef85b2c78b9b649d2200c4"
integrity sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==
"@rollup/rollup-linux-loong64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz#fd2af677ae3417bb58d57ae37dd0d84686e40244"
integrity sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==
"@rollup/rollup-linux-ppc64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz#6481647181c4cf8f1ddbd99f62c84cfc56c1a94a"
integrity sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==
"@rollup/rollup-linux-ppc64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz#18610a1a1550e28a5042ca916f898419540f17f4"
integrity sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==
"@rollup/rollup-linux-riscv64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz#597bb80465a2621dbe0de0a41c66394a8a7e9a6e"
integrity sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==
"@rollup/rollup-linux-riscv64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz#a2a919a9f927ef7f24a60af77e3cb55f1ad59e4d"
integrity sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==
"@rollup/rollup-linux-s390x-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz#3166f6ceae7df9bbfddf9f36be1937231e13e3c6"
integrity sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==
"@rollup/rollup-linux-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5"
integrity sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==
"@rollup/rollup-linux-x64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz#97941c6b94d67fe25cde0f027c10a19f2d1fdd39"
integrity sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==
"@rollup/rollup-openbsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz#7aeb7d92e2cd1d399f56daf75c39040b777b6c77"
integrity sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==
"@rollup/rollup-openharmony-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz#925de61ae83bf99aa636e8acea87432e8c0ffaab"
integrity sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==
"@rollup/rollup-win32-arm64-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz#888ab83842721491044c46a7407e1f38f3235bb4"
integrity sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==
"@rollup/rollup-win32-ia32-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz#fa30ac24e3f0232139d2a47500560a28695764d4"
integrity sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==
"@rollup/rollup-win32-x64-gnu@4.60.4": "@rollup/rollup-win32-x64-gnu@4.60.4":
version "4.60.4" version "4.60.4"
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz" resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz"
@@ -666,7 +441,7 @@
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz" resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/node@^22.15.30": "@types/node@^18.0.0 || >=20.0.0", "@types/node@^22.15.30":
version "22.19.19" version "22.19.19"
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz" resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew== integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
@@ -688,7 +463,7 @@
natural-compare "^1.4.0" natural-compare "^1.4.0"
ts-api-utils "^1.3.0" ts-api-utils "^1.3.0"
"@typescript-eslint/parser@^7.0.1": "@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.0.1":
version "7.18.0" version "7.18.0"
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz" resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg== integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
@@ -764,14 +539,14 @@
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz" resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
"@volar/language-core@1.11.1", "@volar/language-core@~1.11.1": "@volar/language-core@~1.11.1", "@volar/language-core@1.11.1":
version "1.11.1" version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz" resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw== integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
dependencies: dependencies:
"@volar/source-map" "1.11.1" "@volar/source-map" "1.11.1"
"@volar/source-map@1.11.1", "@volar/source-map@~1.11.1": "@volar/source-map@~1.11.1", "@volar/source-map@1.11.1":
version "1.11.1" version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz" resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg== integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
@@ -828,7 +603,7 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
source-map-js "^1.2.1" source-map-js "^1.2.1"
"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4": "@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4", "@vue/compiler-dom@3.5.34":
version "3.5.34" version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz" resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw== integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
@@ -836,7 +611,7 @@
"@vue/compiler-core" "3.5.34" "@vue/compiler-core" "3.5.34"
"@vue/shared" "3.5.34" "@vue/shared" "3.5.34"
"@vue/compiler-sfc@3.5.34", "@vue/compiler-sfc@^3.5.18": "@vue/compiler-sfc@^3.5.18", "@vue/compiler-sfc@3.5.34":
version "3.5.34" version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz" resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg== integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
@@ -921,7 +696,7 @@
"@vue/reactivity" "3.5.34" "@vue/reactivity" "3.5.34"
"@vue/shared" "3.5.34" "@vue/shared" "3.5.34"
"@vue/runtime-dom@3.5.34", "@vue/runtime-dom@^3.4.15": "@vue/runtime-dom@^3.4.15", "@vue/runtime-dom@3.5.34":
version "3.5.34" version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz" resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg== integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
@@ -939,7 +714,7 @@
"@vue/compiler-ssr" "3.5.34" "@vue/compiler-ssr" "3.5.34"
"@vue/shared" "3.5.34" "@vue/shared" "3.5.34"
"@vue/shared@3.5.34", "@vue/shared@^3.3.0", "@vue/shared@^3.5.18": "@vue/shared@^3.3.0", "@vue/shared@^3.5.18", "@vue/shared@3.5.34":
version "3.5.34" version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz" resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA== integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
@@ -954,7 +729,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz" resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.14.0, acorn@^8.9.0: "acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0, acorn@^8.9.0:
version "8.16.0" version "8.16.0"
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz" resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
@@ -1127,7 +902,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
browserslist@^4.24.0, browserslist@^4.28.2: browserslist@^4.24.0, browserslist@^4.28.2, "browserslist@>= 4.21.0":
version "4.28.2" version "4.28.2"
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz" resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
@@ -1281,7 +1056,7 @@ de-indent@^1.0.2:
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz" resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
version "4.4.3" version "4.4.3"
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz" resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1341,7 +1116,7 @@ dunder-proto@^1.0.1:
es-errors "^1.3.0" es-errors "^1.3.0"
gopd "^1.2.0" gopd "^1.2.0"
echarts@^6.1.0: echarts@^6.0.0, echarts@^6.1.0:
version "6.1.0" version "6.1.0"
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz" resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA== integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
@@ -1459,7 +1234,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint@^8.56.0: "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", eslint@^8.56.0, eslint@>=6.0.0:
version "8.57.1" version "8.57.1"
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz" resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
@@ -1641,11 +1416,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz" resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2: function-bind@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz" resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
@@ -1881,7 +1651,7 @@ isexe@^2.0.0:
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz" resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
jiti@^1.21.7: jiti@^1.21.7, jiti@>=1.21.0:
version "1.21.7" version "1.21.7"
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz" resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
@@ -1935,7 +1705,7 @@ kolorist@^1.8.0:
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz" resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
less@^4.6.4: less@*, less@^4.6.4:
version "4.6.4" version "4.6.4"
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz" resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg== integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
@@ -2067,7 +1837,14 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies: dependencies:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
minimatch@^9.0.3, minimatch@^9.0.4: minimatch@^9.0.3:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.2"
minimatch@^9.0.4:
version "9.0.9" version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz" resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
@@ -2238,7 +2015,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz" resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.4: "picomatch@^3 || ^4", picomatch@^4.0.4:
version "4.0.4" version "4.0.4"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz" resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
@@ -2308,7 +2085,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14: postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14, postcss@>=8.0.9:
version "8.5.15" version "8.5.15"
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz" resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
@@ -2639,16 +2416,16 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz" resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^2.8.1: tslib@^2.8.1:
version "2.8.1" version "2.8.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz" resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
type-check@^0.4.0, type-check@~0.4.0: type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz" resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
@@ -2661,7 +2438,7 @@ type-fest@^0.20.2:
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz" resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@~5.3.3: typescript@*, typescript@>=4.2.0, typescript@>=4.5.0, typescript@~5.3.3, typescript@5.x:
version "5.3.3" version "5.3.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz" resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
@@ -2731,7 +2508,7 @@ vite-plugin-vue-dev-locator@1.0.3:
kolorist "^1.8.0" kolorist "^1.8.0"
magic-string "^0.30.4" magic-string "^0.30.4"
vite@^5.0.12: "vite@^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0", "vite@^5.0.0 || ^6.0.0", vite@^5.0.12, vite@>=4.0.0:
version "5.4.21" version "5.4.21"
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz" resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
@@ -2791,7 +2568,7 @@ vue-types@^3.0.0:
dependencies: dependencies:
is-plain-object "3.0.1" is-plain-object "3.0.1"
vue@^3.4.15: vue@^3.0.0, vue@^3.2.25, vue@^3.3.0, vue@^3.4.0, vue@^3.4.15, vue@^3.5.0, vue@^3.5.11, vue@>=3.0.1, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.34:
version "3.5.34" version "3.5.34"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz" resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA== integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
+74 -52
View File
@@ -191,6 +191,7 @@ DROP TABLE IF EXISTS customer CASCADE;
CREATE TABLE customer ( CREATE TABLE customer (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
customer_no VARCHAR(64) NOT NULL UNIQUE, customer_no VARCHAR(64) NOT NULL UNIQUE,
icc_code VARCHAR(64),
customer_name VARCHAR(128) NOT NULL, customer_name VARCHAR(128) NOT NULL,
province VARCHAR(64), province VARCHAR(64),
city VARCHAR(64), city VARCHAR(64),
@@ -209,6 +210,7 @@ CREATE TABLE customer (
COMMENT ON TABLE customer IS '客户信息表'; COMMENT ON TABLE customer IS '客户信息表';
COMMENT ON COLUMN customer.id IS '自增主键'; COMMENT ON COLUMN customer.id IS '自增主键';
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)'; COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
COMMENT ON COLUMN customer.customer_name IS '客户名称'; COMMENT ON COLUMN customer.customer_name IS '客户名称';
COMMENT ON COLUMN customer.province IS '省'; COMMENT ON COLUMN customer.province IS '省';
COMMENT ON COLUMN customer.city IS '市'; COMMENT ON COLUMN customer.city IS '市';
@@ -227,6 +229,7 @@ CREATE INDEX idx_customer_name ON customer (customer_name);
CREATE INDEX idx_customer_salesman ON customer (salesman_id); CREATE INDEX idx_customer_salesman ON customer (salesman_id);
CREATE INDEX idx_customer_status ON customer (status); CREATE INDEX idx_customer_status ON customer (status);
CREATE INDEX idx_customer_region ON customer (province, city, district); CREATE INDEX idx_customer_region ON customer (province, city, district);
CREATE UNIQUE INDEX uk_customer_icc_code ON customer (icc_code);
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
@@ -611,35 +614,47 @@ CREATE INDEX idx_contract_xt ON contract (xt_contract_no);
-- 19. 智电改造线路表(ICC 同步 + 业务分配) -- 19. 智电改造线路表(ICC 同步 + 业务分配)
DROP TABLE IF EXISTS electric_line CASCADE; DROP TABLE IF EXISTS electric_line CASCADE;
CREATE TABLE electric_line ( CREATE TABLE electric_line (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
line_code VARCHAR(64) NOT NULL UNIQUE, line_code VARCHAR(64) NOT NULL UNIQUE,
equip_id BIGINT NOT NULL REFERENCES customer_equipment(id), equip_id BIGINT,
icc_device_code VARCHAR(128), gateway_id BIGINT,
line_name VARCHAR(128), customer_id BIGINT NOT NULL REFERENCES customer(id),
customer_id BIGINT NOT NULL REFERENCES customer(id), line_name VARCHAR(128),
building_id BIGINT REFERENCES customer_building(id), imei VARCHAR(64),
floor_no VARCHAR(16), type_code VARCHAR(32),
location VARCHAR(256), type_name VARCHAR(64),
status SMALLINT NOT NULL DEFAULT 1, fire_system_code VARCHAR(64),
synced_at TIMESTAMPTZ, fire_system_name VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() part_unit_loop_code VARCHAR(64),
channel_no VARCHAR(64),
channel_sn VARCHAR(64),
produce_date VARCHAR(16),
status SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 同步)'; COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 探测器同步)';
COMMENT ON COLUMN electric_line.id IS '自增主键'; COMMENT ON COLUMN electric_line.line_code IS '探测器编码(ICC detectorCode,唯一幂等键)';
COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)'; COMMENT ON COLUMN electric_line.line_name IS '探测器名称(ICC detectorName)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)'; COMMENT ON COLUMN electric_line.imei IS '设备唯一标识码(ICC imei)';
COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号'; COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment,可空,同步不填充)';
COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)'; COMMENT ON COLUMN electric_line.gateway_id IS '网关设备 ID(→ electric_gateway,外键由迁移/db_seed 建立)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)'; COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)'; COMMENT ON COLUMN electric_line.type_code IS '探测器类型编码';
COMMENT ON COLUMN electric_line.floor_no IS '所在楼层'; COMMENT ON COLUMN electric_line.type_name IS '探测器类型名称';
COMMENT ON COLUMN electric_line.location IS '所在位置'; COMMENT ON COLUMN electric_line.fire_system_code IS '消防系统编码';
COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线'; COMMENT ON COLUMN electric_line.fire_system_name IS '消防系统名称';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间'; COMMENT ON COLUMN electric_line.part_unit_loop_code IS '探测器回路号';
COMMENT ON COLUMN electric_line.created_at IS '创建时间'; COMMENT ON COLUMN electric_line.channel_no IS '探测器通道号';
COMMENT ON COLUMN electric_line.channel_sn IS '通道唯一标识码';
COMMENT ON COLUMN electric_line.produce_date IS '生产日期(yyyy-MM-dd)';
COMMENT ON COLUMN electric_line.status IS '探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0)';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
CREATE INDEX idx_line_customer ON electric_line (customer_id); CREATE INDEX idx_line_customer ON electric_line (customer_id);
CREATE INDEX idx_line_equip ON electric_line (equip_id); CREATE INDEX idx_line_equip ON electric_line (equip_id);
CREATE INDEX idx_line_icc ON electric_line (icc_device_code); CREATE INDEX idx_line_gateway ON electric_line (gateway_id);
CREATE INDEX idx_line_imei ON electric_line (imei);
-- 20. 线路所属房间关联表 -- 20. 线路所属房间关联表
DROP TABLE IF EXISTS line_room CASCADE; DROP TABLE IF EXISTS line_room CASCADE;
@@ -1263,43 +1278,50 @@ CREATE INDEX idx_wid_worker ON worker_income_detail (worker_id);
-- 十一、用电采集数据(1 张表,按月 RANGE 分区) -- 十一、用电采集数据(1 张表,按月 RANGE 分区)
-- ============================================================ -- ============================================================
-- 37. 线路用电信息表(按月分区,时序数据) -- 37. 线路用电信息表(按月分区,时序数据,ICC 模拟量同步)
DROP TABLE IF EXISTS electricity_data CASCADE; DROP TABLE IF EXISTS electricity_data CASCADE;
CREATE TABLE electricity_data ( CREATE TABLE electricity_data (
id BIGSERIAL, id BIGSERIAL,
line_id BIGINT NOT NULL, line_id BIGINT NOT NULL REFERENCES electric_line(id),
line_code VARCHAR(64) NOT NULL, line_code VARCHAR(64) NOT NULL,
equip_id BIGINT, imei VARCHAR(64),
icc_device_code VARCHAR(128), line_name VARCHAR(128),
line_name VARCHAR(128), customer_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL, analog_type VARCHAR(64),
electricity NUMERIC(14,4), analog_name VARCHAR(128),
collect_time TIMESTAMPTZ NOT NULL, analog_unit VARCHAR(32),
PRIMARY KEY (id, collect_time) analog_value NUMERIC(14,4),
collect_time TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, collect_time),
CONSTRAINT uk_ed_line_type_time UNIQUE (line_code, analog_type, collect_time)
) PARTITION BY RANGE (collect_time); ) PARTITION BY RANGE (collect_time);
COMMENT ON TABLE electricity_data IS '线路用电信息表(按月分区,时序数据)'; COMMENT ON TABLE electricity_data IS '线路用电信息表(ICC 模拟量同步,按月分区时序数据)';
COMMENT ON COLUMN electricity_data.id IS '自增(复合主键含 collect_time)'; COMMENT ON COLUMN electricity_data.id IS '自增(复合主键含 collect_time)';
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)'; COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(冗余,加速查询)'; COMMENT ON COLUMN electricity_data.line_code IS '线路编码(ICC deviceCode,冗余 + 幂等键)';
COMMENT ON COLUMN electricity_data.equip_id IS '客户设备 ID'; COMMENT ON COLUMN electricity_data.imei IS '设备唯一标识码(ICC uniqueCode)';
COMMENT ON COLUMN electricity_data.icc_device_code IS 'ICC 设备编号'; COMMENT ON COLUMN electricity_data.line_name IS '线路名称(冗余,加速展示)';
COMMENT ON COLUMN electricity_data.line_name IS '线路名称'; COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)'; COMMENT ON COLUMN electricity_data.analog_type IS '模拟量类型(ICC analogType)';
COMMENT ON COLUMN electricity_data.electricity IS '用电量'; COMMENT ON COLUMN electricity_data.analog_name IS '模拟量名称(ICC analogName,如:剩余电流)';
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(分区键)'; COMMENT ON COLUMN electricity_data.analog_unit IS '模拟量单位(ICC analogUnit,如:mA)';
COMMENT ON COLUMN electricity_data.analog_value IS '模拟量值(ICC analogValue)';
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(同步时间,分区键)';
-- 初始分区(2026 年 8~12 月) -- 初始分区(2026 年 10 月 ~ 2027 年 3 月)
CREATE TABLE electricity_data_2026_08 PARTITION OF electricity_data
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE electricity_data_2026_09 PARTITION OF electricity_data
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01'); FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01'); FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
CREATE TABLE electricity_data_2027_01 PARTITION OF electricity_data
FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');
CREATE TABLE electricity_data_2027_02 PARTITION OF electricity_data
FOR VALUES FROM ('2027-02-01') TO ('2027-03-01');
CREATE TABLE electricity_data_2027_03 PARTITION OF electricity_data
FOR VALUES FROM ('2027-03-01') TO ('2027-04-01');
-- 分区索引(在父表创建,自动继承到各分区) -- 分区索引(在父表创建,自动继承到各分区)
CREATE INDEX idx_ed_customer ON electricity_data (customer_id); CREATE INDEX idx_ed_customer ON electricity_data (customer_id);
+98
View File
@@ -404,6 +404,14 @@ INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, re
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)') '从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING; ON CONFLICT (job_key) DO NOTHING;
-- ------------------------------------------------------------
-- 10b. 客户信息同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_customer_sync', '客户信息同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING;
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整) -- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
-- ------------------------------------------------------------ -- ------------------------------------------------------------
@@ -440,6 +448,96 @@ INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabl
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN') '扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
ON CONFLICT (rule_key) DO NOTHING; ON CONFLICT (rule_key) DO NOTHING;
-- ------------------------------------------------------------
-- 11a. 智电改造网关台账(ICC 消防物联设备同步)
-- 建表 electric_gateway(与迁移 20261024_icc_gateway.sql 一致)
-- 设备监控(id=4)下新增菜单 62「智电改造网关台账」(sort 3,排在智电改造线路台账之后)
-- ADMIN 绑定新菜单;注册 icc_gateway_sync 定时任务(默认每 10 分钟)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS electric_gateway (
id BIGSERIAL PRIMARY KEY,
icc_imei VARCHAR(64) UNIQUE,
device_code VARCHAR(64) UNIQUE,
device_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
equip_id BIGINT REFERENCES customer_equipment(id),
org_code VARCHAR(64),
org_name VARCHAR(128),
manufacturer_code VARCHAR(64),
manufacturer_name VARCHAR(128),
type_code VARCHAR(32),
type_name VARCHAR(64),
product_model VARCHAR(64),
is_online SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_gateway IS '智电改造网关设备表(ICC 同步)';
COMMENT ON COLUMN electric_gateway.id IS '自增主键';
COMMENT ON COLUMN electric_gateway.icc_imei IS 'ICC 设备 IMEI(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_code IS 'ICC 设备编码(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_name IS '设备名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.customer_id IS '客户 ID(由 orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_gateway.equip_id IS '客户设备 ID(→ customer_equipment,同步不填充)';
COMMENT ON COLUMN electric_gateway.org_code IS 'ICC 组织编码';
COMMENT ON COLUMN electric_gateway.org_name IS '组织名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.manufacturer_code IS '厂商编码';
COMMENT ON COLUMN electric_gateway.manufacturer_name IS '厂商名称';
COMMENT ON COLUMN electric_gateway.type_code IS '设备类型编码';
COMMENT ON COLUMN electric_gateway.type_name IS '设备类型名称';
COMMENT ON COLUMN electric_gateway.product_model IS '产品型号';
COMMENT ON COLUMN electric_gateway.is_online IS '在线状态:1在线 0离线';
COMMENT ON COLUMN electric_gateway.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_gateway.created_at IS '创建时间';
CREATE INDEX IF NOT EXISTS idx_gateway_customer ON electric_gateway (customer_id);
CREATE INDEX IF NOT EXISTS idx_gateway_equip ON electric_gateway (equip_id);
CREATE INDEX IF NOT EXISTS idx_gateway_imei ON electric_gateway (icc_imei);
-- 智电改造线路表:gateway_id 外键补充(FK 由迁移/本段建立;db_init.sql 中为普通 BIGINT)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_line_gateway' AND conrelid = 'electric_line'::regclass) THEN
ALTER TABLE electric_line ADD CONSTRAINT fk_line_gateway FOREIGN KEY (gateway_id) REFERENCES electric_gateway(id);
END IF;
END $$;
-- 菜单 62:设备监控下的智电改造网关台账菜单
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(62, 4, '智电改造网关台账', '/device/gateway', 'device/gateway/index', 'ApiOutlined', 3, 2, 'device: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)拥有 62
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 62)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 62)
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 62), true);
-- 智电网关同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_gateway_sync', '智电网关同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取消防物联设备并按 icc_imei 幂等 upsert(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING;
-- 模拟量数据同步任务种子(sys_job,按 job_key 幂等;默认每 5 分钟)
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_analog_sync', '模拟量数据同步', '*/5 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取探测器最新模拟量,以同步时间作采集时间写入 electricity_data(默认每 5 分钟)')
ON CONFLICT (job_key) DO UPDATE SET
cron_expression = EXCLUDED.cron_expression,
job_name = EXCLUDED.job_name,
remark = EXCLUDED.remark;
COMMIT; COMMIT;
-- ============================================================ -- ============================================================
@@ -0,0 +1,21 @@
-- ICC 客户信息同步(第一阶段):
-- 1) 幂等新增 customer.icc_code 列(ICC 平台 orgCode,作为 ICC 同步的幂等匹配键)
-- 2) 幂等创建唯一索引 uk_customer_icc_code(icc_code 可空,PostgreSQL 下多个 NULL 不冲突)
BEGIN;
-- ------------------------------------------------------------
-- 1) 新增列 icc_code(可空,幂等;与 db_init.sql / customer.entity.ts 保持一致)
-- ------------------------------------------------------------
ALTER TABLE customer ADD COLUMN IF NOT EXISTS icc_code VARCHAR(64);
-- ------------------------------------------------------------
-- 2) 唯一索引(icc_code 可空,PostgreSQL 中多个 NULL 不冲突,唯一索引安全)
-- ------------------------------------------------------------
CREATE UNIQUE INDEX IF NOT EXISTS uk_customer_icc_code ON customer (icc_code);
-- ------------------------------------------------------------
-- 3) 列注释
-- ------------------------------------------------------------
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
COMMIT;
@@ -0,0 +1,76 @@
-- ICC 模拟量数据同步:重建 electricity_data 表结构(按 ICC AnalogInfoVO)
-- 1) 旧表无数据(0 行),直接 DROP 重建;2) 删除 equip_id/electricity、icc_device_code 更名 imei;
-- 3) 新增 analog_type/analog_value;4) line_id 外键 → electric_line;5) 唯一键 (line_code,analog_type,collect_time);
-- 6) 按月分区 2026_10~2027_03;7) 索引/注释;8) sys_job 种子 icc_analog_sync(默认每 1 分钟)
BEGIN;
-- ------------------------------------------------------------
-- 1) 重建 electricity_data
-- ------------------------------------------------------------
DROP TABLE IF EXISTS electricity_data CASCADE;
CREATE TABLE electricity_data (
id BIGSERIAL,
line_id BIGINT NOT NULL REFERENCES electric_line(id),
line_code VARCHAR(64) NOT NULL,
imei VARCHAR(64),
line_name VARCHAR(128),
customer_id BIGINT NOT NULL,
analog_type VARCHAR(64),
analog_name VARCHAR(128),
analog_unit VARCHAR(32),
analog_value NUMERIC(14,4),
collect_time TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, collect_time),
CONSTRAINT uk_ed_line_type_time UNIQUE (line_code, analog_type, collect_time)
) PARTITION BY RANGE (collect_time);
COMMENT ON TABLE electricity_data IS '线路用电信息表(ICC 模拟量同步,按月分区时序数据)';
COMMENT ON COLUMN electricity_data.id IS '自增(复合主键含 collect_time)';
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(ICC deviceCode,冗余 + 幂等键)';
COMMENT ON COLUMN electricity_data.imei IS '设备唯一标识码(ICC uniqueCode)';
COMMENT ON COLUMN electricity_data.line_name IS '线路名称(冗余,加速展示)';
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electricity_data.analog_type IS '模拟量类型(ICC analogType)';
COMMENT ON COLUMN electricity_data.analog_name IS '模拟量名称(ICC analogName,如:剩余电流)';
COMMENT ON COLUMN electricity_data.analog_unit IS '模拟量单位(ICC analogUnit,如:mA)';
COMMENT ON COLUMN electricity_data.analog_value IS '模拟量值(ICC analogValue)';
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(同步时间,分区键)';
-- ------------------------------------------------------------
-- 2) 初始分区(2026 年 10 月 ~ 2027 年 3 月)
-- ------------------------------------------------------------
CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
CREATE TABLE electricity_data_2027_01 PARTITION OF electricity_data
FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');
CREATE TABLE electricity_data_2027_02 PARTITION OF electricity_data
FOR VALUES FROM ('2027-02-01') TO ('2027-03-01');
CREATE TABLE electricity_data_2027_03 PARTITION OF electricity_data
FOR VALUES FROM ('2027-03-01') TO ('2027-04-01');
-- ------------------------------------------------------------
-- 3) 分区索引(在父表创建,自动继承到各分区)
-- ------------------------------------------------------------
CREATE INDEX idx_ed_customer ON electricity_data (customer_id);
CREATE INDEX idx_ed_line ON electricity_data (line_id);
CREATE INDEX idx_ed_time ON electricity_data (collect_time);
CREATE INDEX idx_ed_customer_time ON electricity_data (customer_id, collect_time);
-- ------------------------------------------------------------
-- 4) sys_job 种子:icc_analog_sync(默认每 5 分钟,按 job_key 幂等更新 cron)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_analog_sync', '模拟量数据同步', '*/5 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取探测器最新模拟量,以同步时间作采集时间写入 electricity_data(默认每 5 分钟)')
ON CONFLICT (job_key) DO UPDATE SET
cron_expression = EXCLUDED.cron_expression,
job_name = EXCLUDED.job_name,
remark = EXCLUDED.remark;
COMMIT;
@@ -0,0 +1,84 @@
-- 智电改造网关台账(ICC 消防物联设备同步):
-- 1) 幂等建表 electric_gateway(若 db_init.sql / db_seed.sql 已建则跳过)
-- 2) 设备监控(id=4)下新增菜单 62「智电改造网关台账」
-- 3) ADMIN 角色绑定新菜单
-- 4) 注册 icc_gateway_sync 定时任务(默认每 10 分钟)
BEGIN;
-- ------------------------------------------------------------
-- 1) 幂等建表(与 db_seed.sql 定义保持一致)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS electric_gateway (
id BIGSERIAL PRIMARY KEY,
icc_imei VARCHAR(64) UNIQUE,
device_code VARCHAR(64) UNIQUE,
device_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
equip_id BIGINT REFERENCES customer_equipment(id),
org_code VARCHAR(64),
org_name VARCHAR(128),
manufacturer_code VARCHAR(64),
manufacturer_name VARCHAR(128),
type_code VARCHAR(32),
type_name VARCHAR(64),
product_model VARCHAR(64),
is_online SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_gateway IS '智电改造网关设备表(ICC 同步)';
COMMENT ON COLUMN electric_gateway.id IS '自增主键';
COMMENT ON COLUMN electric_gateway.icc_imei IS 'ICC 设备 IMEI(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_code IS 'ICC 设备编码(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_name IS '设备名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.customer_id IS '客户 ID(由 orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_gateway.equip_id IS '客户设备 ID(→ customer_equipment,同步不填充)';
COMMENT ON COLUMN electric_gateway.org_code IS 'ICC 组织编码';
COMMENT ON COLUMN electric_gateway.org_name IS '组织名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.manufacturer_code IS '厂商编码';
COMMENT ON COLUMN electric_gateway.manufacturer_name IS '厂商名称';
COMMENT ON COLUMN electric_gateway.type_code IS '设备类型编码';
COMMENT ON COLUMN electric_gateway.type_name IS '设备类型名称';
COMMENT ON COLUMN electric_gateway.product_model IS '产品型号';
COMMENT ON COLUMN electric_gateway.is_online IS '在线状态:1在线 0离线';
COMMENT ON COLUMN electric_gateway.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_gateway.created_at IS '创建时间';
CREATE INDEX IF NOT EXISTS idx_gateway_customer ON electric_gateway (customer_id);
CREATE INDEX IF NOT EXISTS idx_gateway_equip ON electric_gateway (equip_id);
CREATE INDEX IF NOT EXISTS idx_gateway_imei ON electric_gateway (icc_imei);
-- ------------------------------------------------------------
-- 2) 设备监控(id=4)下新增菜单 62「智电改造网关台账」(sort 3,排在智电改造线路台账之后)
-- ------------------------------------------------------------
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(62, 4, '智电改造网关台账', '/device/gateway', 'device/gateway/index', 'ApiOutlined', 3, 2, 'device: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;
-- ------------------------------------------------------------
-- 3) 角色-菜单绑定:ADMIN(role_id=1) 拥有 62
-- ------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 62)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- ------------------------------------------------------------
-- 4) 定时任务种子:icc_gateway_sync(默认每 10 分钟,可按 job_key 幂等)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_gateway_sync', '智电网关同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取消防物联设备并按 icc_imei 幂等 upsert(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING;
-- 重置序列(覆盖到 62)
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 62), true);
COMMIT;
@@ -0,0 +1,48 @@
-- 智电改造线路台账真实 ICC 同步:重建 electric_line 表结构(按 ICC DetectorVO)
-- 1) 增列/删列/改可空(幂等);2) gateway_id 外键 → electric_gateway;3) 索引与注释同步
BEGIN;
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS imei VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS gateway_id BIGINT;
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS type_code VARCHAR(32);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS type_name VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS fire_system_code VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS fire_system_name VARCHAR(128);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS part_unit_loop_code VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS channel_no VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS channel_sn VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS produce_date VARCHAR(16);
ALTER TABLE electric_line DROP COLUMN IF EXISTS icc_device_code;
ALTER TABLE electric_line DROP COLUMN IF EXISTS building_id;
ALTER TABLE electric_line DROP COLUMN IF EXISTS floor_no;
ALTER TABLE electric_line DROP COLUMN IF EXISTS location;
ALTER TABLE electric_line ALTER COLUMN equip_id DROP NOT NULL;
ALTER TABLE electric_line ADD CONSTRAINT fk_line_gateway FOREIGN KEY (gateway_id) REFERENCES electric_gateway(id);
DROP INDEX IF EXISTS idx_line_icc;
CREATE INDEX IF NOT EXISTS idx_line_gateway ON electric_line (gateway_id);
CREATE INDEX IF NOT EXISTS idx_line_imei ON electric_line (imei);
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 探测器同步)';
COMMENT ON COLUMN electric_line.line_code IS '探测器编码(ICC detectorCode,唯一幂等键)';
COMMENT ON COLUMN electric_line.line_name IS '探测器名称(ICC detectorName)';
COMMENT ON COLUMN electric_line.imei IS '设备唯一标识码(ICC imei)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment,可空,同步不填充)';
COMMENT ON COLUMN electric_line.gateway_id IS '网关设备 ID(→ electric_gateway,由 parentCode↔device_code 匹配)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_line.type_code IS '探测器类型编码';
COMMENT ON COLUMN electric_line.type_name IS '探测器类型名称';
COMMENT ON COLUMN electric_line.fire_system_code IS '消防系统编码';
COMMENT ON COLUMN electric_line.fire_system_name IS '消防系统名称';
COMMENT ON COLUMN electric_line.part_unit_loop_code IS '探测器回路号';
COMMENT ON COLUMN electric_line.channel_no IS '探测器通道号';
COMMENT ON COLUMN electric_line.channel_sn IS '通道唯一标识码';
COMMENT ON COLUMN electric_line.produce_date IS '生产日期(yyyy-MM-dd)';
COMMENT ON COLUMN electric_line.status IS '探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0)';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
COMMIT;
+7 -2
View File
@@ -23,6 +23,11 @@ REDIS_HOST=222.243.55.42
REDIS_PORT=26678 REDIS_PORT=26678
REDIS_PASSWORD= REDIS_PASSWORD=
# ICC 平台配置(结算所述智电改造线路数据由 ICC 同步,占位;接入真实平台时填写) # ICC 平台配置(智电线路同步沿用 ICC_BASE_URL / ICC_TOKEN;客户信息同步走 OAuth2 密码模式)
ICC_BASE_URL= ICC_BASE_URL=https://222.243.55.92:1443
ICC_TOKEN= ICC_TOKEN=
ICC_VERSION=1.0.0
ICC_CLIENT_ID=xtzdapp
ICC_CLIENT_SECRET=1c7e440a-660c-4a5d-a7b8-82b58d9dca4e
ICC_USERNAME=XTZD接口
ICC_PASSWORD=Xtzdkj@20260801
+1
View File
@@ -38,6 +38,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sequelize-typescript": "^2.1.6", "sequelize-typescript": "^2.1.6",
"undici": "^8.12.3",
"uuid": "^10.0.0" "uuid": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
+7
View File
@@ -16,6 +16,13 @@ if (existsSync(envFile)) {
loadEnv({ path: envFile, override: true }); loadEnv({ path: envFile, override: true });
} }
// midway logger 默认日志目录为 join(MIDWAY_LOGGER_WRITEABLE_DIR ?? appInfo.root, "logs", appName),
// 且启动时 loggerService 会用该默认值强制覆盖用户 logger.dir 配置(addObject reverse=true)。
// 显式指定项目目录,使日志最终落在 server/logs 下,避免写入用户主目录触发 EPERM 权限报错。
if (!process.env.MIDWAY_LOGGER_WRITEABLE_DIR) {
process.env.MIDWAY_LOGGER_WRITEABLE_DIR = envDir;
}
export default { export default {
keys: "smart-grid-iot-secret-key", keys: "smart-grid-iot-secret-key",
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import { CustomerService } from "../service/customer.service"; import { CustomerService } from "../service/customer.service";
import { IccCustomerSyncService } from "../service/icc_customer_sync.service";
@Controller("/api/customers") @Controller("/api/customers")
export class CustomerController { export class CustomerController {
@@ -10,6 +11,9 @@ export class CustomerController {
@Inject() @Inject()
customerService!: CustomerService; customerService!: CustomerService;
@Inject()
iccCustomerSyncService!: IccCustomerSyncService;
@Get("/") @Get("/")
async list(@Query() query: any) { async list(@Query() query: any) {
const result = await this.customerService.list(query, this.ctx); const result = await this.customerService.list(query, this.ctx);
@@ -28,6 +32,16 @@ export class CustomerController {
return result; return result;
} }
@Post("/sync/icc")
async syncIccCustomers() {
const result = await this.iccCustomerSyncService.syncFromIcc(this.ctx);
return {
code: 200,
message: "同步完成",
data: result
};
}
@Get("/:id") @Get("/:id")
async detail(@Param("id") id: string) { async detail(@Param("id") id: string) {
const result = await this.customerService.detail(Number(id), this.ctx); const result = await this.customerService.detail(Number(id), this.ctx);
@@ -0,0 +1,18 @@
import { Controller, Get, Post, Inject, Query, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { ElectricGatewayService } from "../service/electric_gateway.service";
import { IccGatewaySyncService } from "../service/icc_gateway_sync.service";
@Controller("/api/electric-gateways")
export class ElectricGatewayController {
@Inject() ctx!: Context;
@Inject() electricGatewayService!: ElectricGatewayService;
@Inject() iccGatewaySyncService!: IccGatewaySyncService;
@Get("/") async list(@Query() query: any) { return this.electricGatewayService.list(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.electricGatewayService.detail(Number(id), this.ctx); }
@Post("/sync") async sync() {
const result = await this.iccGatewaySyncService.syncFromIcc(this.ctx);
return { code: 200, message: "网关设备同步完成", data: result };
}
}
@@ -0,0 +1,54 @@
import { Controller, Post, Get, Query, Inject } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { IccAnalogSyncService } from "../service/icc_analog_sync.service";
import ElectricityData from "../entity/electricity_data.entity";
@Controller("/api/electricity-data")
export class ElectricityDataController {
@Inject() ctx!: Context;
@Inject() iccAnalogSyncService!: IccAnalogSyncService;
@InjectRepository(ElectricityData)
electricityDataRepo!: Repository<ElectricityData>;
@Post("/sync") async sync() {
const result = await this.iccAnalogSyncService.syncFromIcc(this.ctx);
return { code: 200, message: "模拟量数据同步完成", data: result };
}
/** 查询某线路最近一次同步的模拟量快照(取最大 collect_time 的全部模拟量行) */
@Get("/latest") async latest(@Query("lineId") lineId?: string) {
const id = Number(lineId);
if (!id || Number.isNaN(id)) {
return { code: 400, message: "缺少有效的 lineId 参数" };
}
const latest = await this.electricityDataRepo.findOne({
where: { lineId: id },
order: [["collectTime", "DESC"]],
attributes: ["lineCode", "lineName", "imei", "customerId", "collectTime"],
});
if (!latest) {
return { code: 200, message: "该线路暂无模拟量数据", data: { lineId: id, hasData: false, collectTime: null, list: [] } };
}
const rows = await this.electricityDataRepo.findAll({
where: { lineId: id, collectTime: latest.getDataValue("collectTime") as Date },
order: [["analogType", "ASC"]],
attributes: ["analogType", "analogName", "analogUnit", "analogValue"],
});
return {
code: 200,
message: "ok",
data: {
lineId: id,
lineCode: latest.getDataValue("lineCode"),
lineName: latest.getDataValue("lineName"),
imei: latest.getDataValue("imei"),
customerId: latest.getDataValue("customerId"),
collectTime: latest.getDataValue("collectTime"),
hasData: true,
list: rows,
},
};
}
}
+10
View File
@@ -0,0 +1,10 @@
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
export class ElectricGatewayQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() equipId?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) isOnline?: number;
}
+7
View File
@@ -32,6 +32,13 @@ export default class Customer extends Model {
}) })
customerNo!: string; customerNo!: string;
@Column({
type: DataType.STRING(64),
field: "icc_code",
comment: "ICC客户编码(ICC平台 orgCode,同步来源)"
})
iccCode!: string;
@Column({ @Column({
type: DataType.STRING(128), type: DataType.STRING(128),
allowNull: false, allowNull: false,
@@ -0,0 +1,71 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import CustomerEquipment from "./customer_equipment.entity";
@Table({
tableName: "electric_gateway",
comment: "智电改造网关设备表(ICC 同步)",
timestamps: false,
indexes: [
{ name: "idx_gateway_customer", fields: ["customer_id"] },
{ name: "idx_gateway_equip", fields: ["equip_id"] },
{ name: "idx_gateway_imei", fields: ["icc_imei"] }
]
})
export default class ElectricGateway extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), unique: true, field: "icc_imei", comment: "设备唯一标识码(imei)" })
iccImei!: string;
@Column({ type: DataType.STRING(64), unique: true, field: "device_code", comment: "设备编码" })
deviceCode!: string;
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称" })
deviceName!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
customerId!: number;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,可手动关联)" })
equipId!: number;
@Column({ type: DataType.STRING(64), field: "org_code", comment: "ICC 组织编码" })
orgCode!: string;
@Column({ type: DataType.STRING(128), field: "org_name", comment: "ICC 组织名称" })
orgName!: string;
@Column({ type: DataType.STRING(64), field: "manufacturer_code", comment: "厂商编码" })
manufacturerCode!: string;
@Column({ type: DataType.STRING(128), field: "manufacturer_name", comment: "厂商名称" })
manufacturerName!: string;
@Column({ type: DataType.STRING(32), field: "type_code", comment: "设备类型编码" })
typeCode!: string;
@Column({ type: DataType.STRING(64), field: "type_name", comment: "设备类型名称" })
typeName!: string;
@Column({ type: DataType.STRING(64), field: "product_model", comment: "产品型号" })
productModel!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "is_online", comment: "在线状态:1在线 0离线" })
isOnline!: number;
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
syncedAt!: Date;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
equipment!: CustomerEquipment;
@CreatedAt
created_at!: Date;
}
+43 -24
View File
@@ -1,7 +1,7 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import CustomerEquipment from "./customer_equipment.entity"; import CustomerEquipment from "./customer_equipment.entity";
import ElectricGateway from "./electric_gateway.entity";
import Customer from "./customer.entity"; import Customer from "./customer.entity";
import CustomerBuilding from "./customer_building.entity";
@Table({ @Table({
tableName: "electric_line", tableName: "electric_line",
@@ -10,41 +10,60 @@ import CustomerBuilding from "./customer_building.entity";
indexes: [ indexes: [
{ name: "idx_line_customer", fields: ["customer_id"] }, { name: "idx_line_customer", fields: ["customer_id"] },
{ name: "idx_line_equip", fields: ["equip_id"] }, { name: "idx_line_equip", fields: ["equip_id"] },
{ name: "idx_line_icc", fields: ["icc_device_code"] } { name: "idx_line_gateway", fields: ["gateway_id"] },
{ name: "idx_line_imei", fields: ["imei"] }
] ]
}) })
export default class ElectricLine extends Model { export default class ElectricLine extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number; id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "线路编码(唯一)" }) @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "探测器编码(detectorCode)唯一幂等键" })
lineCode!: string; lineCode!: string;
@ForeignKey(() => CustomerEquipment) @Column({ type: DataType.STRING(128), field: "line_name", comment: "探测器名称(detectorName)" })
@Column({ type: DataType.BIGINT, allowNull: false, field: "equip_id", comment: "客户设备 ID(→ customer_equipment)" })
equipId!: number;
@Column({ type: DataType.STRING(128), field: "icc_device_code", comment: "ICC 智电设备编号" })
iccDeviceCode!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(ICC 同步)" })
lineName!: string; lineName!: string;
@Column({ type: DataType.STRING(64), field: "imei", comment: "探测器 IMEI" })
imei!: string;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,同步不填充)" })
equipId!: number;
@ForeignKey(() => ElectricGateway)
@Column({ type: DataType.BIGINT, field: "gateway_id", comment: "网关 ID(→ electric_gateway,由 parentCode↔device_code 匹配)" })
gatewayId!: number;
@ForeignKey(() => Customer) @ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" }) @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
customerId!: number; customerId!: number;
@ForeignKey(() => CustomerBuilding) @Column({ type: DataType.STRING(32), field: "type_code", comment: "探测器类型编码(typeCode)" })
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" }) typeCode!: string;
buildingId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" }) @Column({ type: DataType.STRING(64), field: "type_name", comment: "探测器类型名称(typeName)" })
floorNo!: string; typeName!: string;
@Column({ type: DataType.STRING(256), comment: "所在位置" }) @Column({ type: DataType.STRING(64), field: "fire_system_code", comment: "消防系统编码(fireSystemCode)" })
location!: string; fireSystemCode!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在线 0离线" }) @Column({ type: DataType.STRING(128), field: "fire_system_name", comment: "消防系统名称(fireSystemName)" })
fireSystemName!: string;
@Column({ type: DataType.STRING(64), field: "part_unit_loop_code", comment: "部件/单元/回路编码(partUnitLoopCode)" })
partUnitLoopCode!: string;
@Column({ type: DataType.STRING(64), field: "channel_no", comment: "通道号(channelNo)" })
channelNo!: string;
@Column({ type: DataType.STRING(64), field: "channel_sn", comment: "通道序列号(channelSn)" })
channelSn!: string;
@Column({ type: DataType.STRING(16), field: "produce_date", comment: "生产日期(produceDate)" })
produceDate!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "上报状态:1在线 0离线(detectorStatus 归一)" })
status!: number; status!: number;
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" }) @Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
@@ -53,12 +72,12 @@ export default class ElectricLine extends Model {
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" }) @BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
equipment!: CustomerEquipment; equipment!: CustomerEquipment;
@BelongsTo(() => ElectricGateway, { foreignKey: "gatewayId", as: "gateway" })
gateway!: ElectricGateway;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" }) @BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer; customer!: Customer;
@BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" })
building!: CustomerBuilding;
@CreatedAt @CreatedAt
created_at!: Date; created_at!: Date;
} }
@@ -0,0 +1,56 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from "sequelize-typescript";
import ElectricLine from "./electric_line.entity";
import Customer from "./customer.entity";
@Table({
tableName: "electricity_data",
comment: "线路用电信息表(ICC 模拟量同步,按月分区时序数据)",
timestamps: false,
indexes: [
{ name: "idx_ed_customer", fields: ["customer_id"] },
{ name: "idx_ed_line", fields: ["line_id"] },
{ name: "idx_ed_time", fields: ["collect_time"] }
]
})
export default class ElectricityData extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => ElectricLine)
@Column({ type: DataType.BIGINT, allowNull: false, field: "line_id", comment: "线路 ID(→ electric_line)" })
lineId!: number;
@Column({ type: DataType.STRING(64), allowNull: false, field: "line_code", comment: "线路编码(ICC deviceCode,冗余 + 幂等键)" })
lineCode!: string;
@Column({ type: DataType.STRING(64), field: "imei", comment: "设备唯一标识码(ICC uniqueCode)" })
imei!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(冗余,加速展示)" })
lineName!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
customerId!: number;
@Column({ type: DataType.STRING(64), field: "analog_type", comment: "模拟量类型(ICC analogType)" })
analogType!: string;
@Column({ type: DataType.STRING(128), field: "analog_name", comment: "模拟量名称(ICC analogName,如:剩余电流)" })
analogName!: string;
@Column({ type: DataType.STRING(32), field: "analog_unit", comment: "模拟量单位(ICC analogUnit,如:mA)" })
analogUnit!: string;
@Column({ type: DataType.DECIMAL(14, 4), field: "analog_value", comment: "模拟量值(ICC analogValue)" })
analogValue!: number;
@Column({ type: DataType.DATE, allowNull: false, field: "collect_time", comment: "采集时间(同步时间,分区键)" })
collectTime!: Date;
@BelongsTo(() => ElectricLine, { foreignKey: "lineId", as: "line" })
line!: ElectricLine;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
}
+2
View File
@@ -29,6 +29,8 @@ const PERMISSION_MAP: Record<string, string> = {
"/api/customer-equipments": "device:list", "/api/customer-equipments": "device:list",
"/api/smart-devices": "device:model", "/api/smart-devices": "device:model",
"/api/electric-lines": "device:list", "/api/electric-lines": "device:list",
"/api/electric-gateways": "device:list",
"/api/electricity-data": "device:list",
"/api/survey-orders": "project:survey", "/api/survey-orders": "project:survey",
"/api/project-plans": "project:plan", "/api/project-plans": "project:plan",
"/api/contracts": "project:contract", "/api/contracts": "project:contract",
+1 -1
View File
@@ -114,7 +114,7 @@ export class CustomerService {
}); });
} }
private async generateCustomerNo(retry: number = 0): Promise<string> { public async generateCustomerNo(retry: number = 0): Promise<string> {
const now = new Date(); const now = new Date();
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000; const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
const dateLocal = new Date(offsetMs); const dateLocal = new Date(offsetMs);
@@ -0,0 +1,130 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@Provide()
export class ElectricGatewayService {
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ deviceName: { [Op.like]: `%${query.keyword}%` } },
{ deviceCode: { [Op.like]: `%${query.keyword}%` } },
{ iccImei: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
if (query.isOnline !== undefined && query.isOnline !== "" && query.isOnline !== null) where.isOnline = Number(query.isOnline);
// 非管理员按客户归属自动限定数据范围
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) {
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) {
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
} else {
where.customerId = { [Op.in]: ids };
}
}
const { count, rows } = await this.gatewayModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"], ["id", "DESC"]],
distinct: true
});
const list = await this.decorate(rows);
return { code: 200, message: "获取网关台账列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.gatewayModel.findByPk(id);
if (!record) return { code: 404, message: "网关设备不存在", data: null };
if (!(await this.isAdmin(ctx.state.user.userId))) {
const customer = await this.customerModel.findByPk(record.customerId);
if (!customer || customer.salesmanId !== ctx.state.user.userId) {
return { code: 403, message: "无权限访问该网关设备", data: null };
}
}
const decorated = await this.decorate([record]);
return { code: 200, message: "获取网关设备台账详情成功", data: decorated[0] };
}
/** 补全客户/设备展示信息,避免 N+1。 */
private async decorate(rows: ElectricGateway[]): Promise<any[]> {
if (rows.length === 0) return rows as any;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
const [customers, equips] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName"]
})
: Promise.resolve([]),
equipIds.length
? this.equipmentModel.findAll({
where: { id: { [Op.in]: equipIds } },
attributes: ["id", "equipCode", "equipName"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const equipMap = new Map(equips.map((e) => [e.id, e]));
return rows.map((row) => {
const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId);
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || "";
return json;
});
}
}
+17 -16
View File
@@ -4,9 +4,9 @@ import { Repository } from "sequelize-typescript";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity"; import ElectricLine from "../entity/electric_line.entity";
import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity"; import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity"; import CustomerEquipment from "../entity/customer_equipment.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import SysUserRole from "../entity/sysUserRole.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity"; import SysRole from "../entity/sysRole.entity";
@@ -21,8 +21,8 @@ export class ElectricLineService {
@InjectRepository(CustomerEquipment) @InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>; equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(CustomerBuilding) @InjectRepository(ElectricGateway)
buildingModel!: Repository<CustomerBuilding>; gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(SysUserRole) @InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>; userRoleModel!: Repository<SysUserRole>;
@@ -44,12 +44,12 @@ export class ElectricLineService {
if (query.keyword) { if (query.keyword) {
where[Op.or] = [ where[Op.or] = [
{ lineName: { [Op.like]: `%${query.keyword}%` } }, { lineName: { [Op.like]: `%${query.keyword}%` } },
{ lineCode: { [Op.like]: `%${query.keyword}%` } } { lineCode: { [Op.like]: `%${query.keyword}%` } },
{ imei: { [Op.like]: `%${query.keyword}%` } }
]; ];
} }
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId); if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId); if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
if (query.buildingId !== undefined && query.buildingId !== "") where.buildingId = Number(query.buildingId);
if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status); if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status);
// 非管理员按客户归属自动限定数据范围 // 非管理员按客户归属自动限定数据范围
@@ -95,15 +95,15 @@ export class ElectricLineService {
return { code: 200, message: "获取线路台账详情成功", data: decorated[0] }; return { code: 200, message: "获取线路台账详情成功", data: decorated[0] };
} }
/** 补全客户/设备/建筑展示信息,避免 N+1。 */ /** 补全客户/设备/网关展示信息,避免 N+1。 */
private async decorate(rows: ElectricLine[]): Promise<any[]> { private async decorate(rows: ElectricLine[]): Promise<any[]> {
if (rows.length === 0) return rows as any; if (rows.length === 0) return rows as any;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))]; const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))]; const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
const buildingIds = [...new Set(rows.map((r) => r.buildingId).filter(Boolean))]; const gatewayIds = [...new Set(rows.map((r) => r.gatewayId).filter(Boolean))];
const [customers, equips, buildings] = await Promise.all([ const [customers, equips, gateways] = await Promise.all([
customerIds.length customerIds.length
? this.customerModel.findAll({ ? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } }, where: { id: { [Op.in]: customerIds } },
@@ -116,28 +116,29 @@ export class ElectricLineService {
attributes: ["id", "equipCode", "equipName"] attributes: ["id", "equipCode", "equipName"]
}) })
: Promise.resolve([]), : Promise.resolve([]),
buildingIds.length gatewayIds.length
? this.buildingModel.findAll({ ? this.gatewayModel.findAll({
where: { id: { [Op.in]: buildingIds } }, where: { id: { [Op.in]: gatewayIds } },
attributes: ["id", "buildingName"] attributes: ["id", "deviceCode", "deviceName"]
}) })
: Promise.resolve([]) : Promise.resolve([])
]); ]);
const customerMap = new Map(customers.map((c) => [c.id, c])); const customerMap = new Map(customers.map((c) => [c.id, c]));
const equipMap = new Map(equips.map((e) => [e.id, e])); const equipMap = new Map(equips.map((e) => [e.id, e]));
const buildingMap = new Map(buildings.map((b) => [b.id, b])); const gatewayMap = new Map(gateways.map((g) => [g.id, g]));
return rows.map((row) => { return rows.map((row) => {
const json: any = row.toJSON(); const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId); const customer: any = customerMap.get(row.customerId);
const equip: any = equipMap.get(row.equipId); const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
const building: any = row.buildingId ? buildingMap.get(row.buildingId) : undefined; const gateway: any = row.gatewayId ? gatewayMap.get(row.gatewayId) : undefined;
json.customerNo = customer?.customerNo || ""; json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || ""; json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || ""; json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || ""; json.equipName = equip?.equipName || "";
json.buildingName = building?.buildingName || ""; json.gatewayCode = gateway?.deviceCode || "";
json.gatewayName = gateway?.deviceName || "";
return json; return json;
}); });
} }
@@ -0,0 +1,223 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Context } from "@midwayjs/koa";
import ElectricityData from "../entity/electricity_data.entity";
import ElectricLine from "../entity/electric_line.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_ANALOG_ENDPOINT, mapIccAnalogRecord, parseAnalogPage, IccAnalogRawRecord } from "../task/icc_analog_mapper";
export interface IccAnalogSyncResult {
/** ICC 拉取到的原始模拟量条数 */
pulled: number;
/** 成功落库的模拟量条数(新增+更新) */
synced: number;
/** 新增的模拟量条数 */
inserted: number;
/** 更新的模拟量条数 */
updated: number;
/** 跳过的条数(线路未同步等) */
skipped: number;
}
const SYNC_TYPE = "electricity_data";
/** 模拟量接口每页上限(短页判定阈值,ICC 接口约定) */
const PAGE_SIZE = 500;
/** deviceCodes 每批最多 50 个(ICC 模拟量接口约定) */
const DEVICE_BATCH = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/** 线路预取信息(用于模拟量落库时回填 lineId / lineName / customerId,避免 N+1) */
interface LineMatchInfo {
lineId: number;
lineName?: string;
customerId: number;
}
/**
* ICC 探测器模拟量数据同步(默认每 5 分钟执行一次):
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) 先读本系统已同步的 electric_line(line_code = ICC deviceCode),把 line_code 每批 ≤50
* 作为 deviceCodes 分批 POST 模拟量接口(/analog/subsystem/page,pageSize=500 短页停止)
* 3) 以同步时间作采集时间,按唯一键 (line_code, analog_type, collect_time) 幂等 upsert
* 写入 electricity_data(同一次同步内重复则覆盖更新)
* 4) 每次同步写一条 icc_sync_log(sync_type=electricity_data);拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccAnalogSyncService {
@InjectRepository(ElectricityData)
electricityModel!: Repository<ElectricityData>;
@InjectRepository(ElectricLine)
lineModel!: Repository<ElectricLine>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
async syncFromIcc(ctx?: Context): Promise<IccAnalogSyncResult> {
const startTime = new Date();
// 采集时间 = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作采集时间/分区键)
const collectTime = startTime;
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccAnalogSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
// 预取本系统已同步线路 → 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
});
}
// 本系统尚未同步任何线路(无 deviceCode 可查),直接写成功日志返回
if (lineMap.size === 0) {
await this.writeLog(startTime, 1, SYNC_TYPE, 0, null);
return result;
}
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_ANALOG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
// lineCodes 按 ≤DEVICE_BATCH 分批,逐批拉取模拟量
const allRaw: IccAnalogRawRecord[] = [];
const lineCodes = [...lineMap.keys()];
for (let i = 0; i < lineCodes.length; i += DEVICE_BATCH) {
const batch = lineCodes.slice(i, i + DEVICE_BATCH);
let pageNum = 1;
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE, deviceCodes: batch }),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 模拟量接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseAnalogPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw, lineMap, collectTime);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 按 (line_code, analog_type, collect_time) 幂等 upsert(collectTime 为本次同步时间);线路未同步的行跳过。 */
private async batchUpsert(
records: IccAnalogRawRecord[],
lineMap: Map<string, LineMatchInfo>,
collectTime: Date
): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
let skipped = 0;
if (records.length === 0) return { inserted, updated, skipped };
for (const raw of records) {
const mapped = mapIccAnalogRecord(raw);
// deviceCode 必须在本系统已同步的线路中(line_code = ICC deviceCode),否则跳过
const lineInfo = lineMap.get(mapped.deviceCode);
if (!lineInfo) {
skipped++;
continue;
}
const payload = {
lineId: lineInfo.lineId,
lineCode: mapped.deviceCode,
imei: mapped.uniqueCode || null,
lineName: lineInfo.lineName || null,
customerId: lineInfo.customerId,
analogType: mapped.analogType || null,
analogName: mapped.analogName || null,
analogUnit: mapped.analogUnit || null,
analogValue: mapped.analogValue,
collectTime
} as any;
// 幂等 upsert:唯一键 (line_code, analog_type, collect_time)(collectTime 为本次同步时间),
// 同一次同步内同一 device+type 重复出现(如跨页)则覆盖更新
const existing = await this.electricityModel.findOne({
where: { lineCode: mapped.deviceCode, analogType: mapped.analogType || null, collectTime }
});
if (existing) {
await existing.update({
analogValue: mapped.analogValue,
imei: mapped.uniqueCode || null,
lineName: lineInfo.lineName || null,
analogName: mapped.analogName || null,
analogUnit: mapped.analogUnit || null
} as any);
updated++;
} else {
await this.electricityModel.create(payload);
inserted++;
}
}
return { inserted, updated, skipped };
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
@@ -0,0 +1,282 @@
import { Provide, App } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Context, Application } from "@midwayjs/koa";
import Customer from "../entity/customer.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_ORG_ENDPOINT, ICC_ORG_TYPE_BASE, mapIccOrgRecord, parseOrgPage, IccOrgRawRecord } from "../task/icc_customer_mapper";
import { CustomerService } from "./customer.service";
export interface IccCustomerSyncResult {
/** ICC 拉取到的原始组织条数(含非 org_base) */
pulled: number;
/** 成功落库的客户条数(新增+更新) */
synced: number;
/** 新增的客户条数 */
inserted: number;
/** 更新的客户条数 */
updated: number;
/** 跳过的条数(非 org_base / 缺 iccCode / 缺客户名 / 四字段无变化 / 编号冲突无法解决) */
skipped: number;
}
const SYNC_TYPE = "customer";
/** 分页拉取参数(ICC 组织分页接口约定) */
const PAGE_SIZE = 1000;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** mapIccOrgRecord 的返回类型别名 */
type MappedOrg = ReturnType<typeof mapIccOrgRecord>;
/**
* ICC 客户信息拉取同步:
* 1) 从 .env 读取 ICC_BASE_URL / ICC_VERSION,经 getIccAccessToken 走 OAuth2 密码模式鉴权
* 2) 分页请求 ICC 组织列表(/organization/page,pageNum/pageSize/maxRangeId)
* 3) 仅保留 orgType=org_base 的组织(对应客户),按 iccCode 幂等 upsert
* 4) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*
* 客户编号生成:复用 CustomerService.generateCustomerNo(已由 private 改为 public,
* 保持与手工建客户完全一致的 KH+YYYYMMDD+3位序号 规则),经匿名请求上下文获取实例。
*/
@Provide()
export class IccCustomerSyncService {
@App()
app!: Application;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
async syncFromIcc(ctx?: Context): Promise<IccCustomerSyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccCustomerSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const customerService = await this.getCustomerService();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_ORG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccOrgRawRecord[] = [];
let pageNum = 1;
let maxRangeId = 0;
let currentPage = 1;
let totalPage = 1;
while (pageNum <= totalPage) {
const params = new URLSearchParams({
pageNum: String(pageNum),
pageSize: String(PAGE_SIZE),
maxRangeId: String(maxRangeId)
});
const res = await iccFetch(`${url}?${params.toString()}`, {
method: "GET",
headers: { Accept: "application/json", Authorization: `bearer ${token}` },
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
});
if (!res.ok) {
throw new Error(`ICC 组织接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseOrgPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
currentPage = page.currentPage;
totalPage = page.totalPage;
// currentPage 达到 totalPage 或返回空列表即停止
if (page.list.length === 0 || currentPage >= totalPage) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw, customerService);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 从匿名请求上下文获取请求作用域的 CustomerService(参照 job_scheduler.buildRuntime) */
private async getCustomerService(): Promise<CustomerService> {
const ctx = this.app.createAnonymousContext();
return (await ctx.requestContext.getAsync(CustomerService)) as CustomerService;
}
/** 构建行政区划字典:org_province/org_city/org_county 的 编码字段 → orgName(名称)。 */
private buildRegionDict(allRaw: IccOrgRawRecord[]): {
provinceDict: Record<string, string>;
cityDict: Record<string, string>;
countyDict: Record<string, string>;
} {
const provinceDict: Record<string, string> = {};
const cityDict: Record<string, string> = {};
const countyDict: Record<string, string> = {};
for (const raw of allRaw) {
const orgType = String(raw.orgType ?? "");
const orgName = String(raw.orgName ?? "").trim();
if (!orgName) continue;
if (orgType === "org_province" && raw.province) {
provinceDict[String(raw.province).trim()] = orgName;
} else if (orgType === "org_city" && raw.city) {
cityDict[String(raw.city).trim()] = orgName;
} else if (orgType === "org_county" && raw.county) {
countyDict[String(raw.county).trim()] = orgName;
}
}
return { provinceDict, cityDict, countyDict };
}
/** 按 iccCode 幂等 upsert(新增/变更四字段:客户名、省、市、区)。 */
private async batchUpsert(allRaw: IccOrgRawRecord[], customerService: CustomerService): Promise<{ inserted: number; updated: number; skipped: number }> {
let skipped = 0;
// 从全量组织树构建行政区划字典(ICC 的 org_base 记录 province/city/county 为
// 国标行政区划编码,客户表存名称;org_province/org_city/org_county 记录的
// province/city/county 编码字段 + orgName 即为 编码→名称 映射)
const { provinceDict, cityDict, countyDict } = this.buildRegionDict(allRaw);
// 仅保留 org_base 组织(客户);组织类型缺失或非 org_base 均跳过,避免误同步非客户组织
const mappedList: MappedOrg[] = [];
for (const raw of allRaw) {
const mapped = mapIccOrgRecord(raw);
if (mapped.orgType !== ICC_ORG_TYPE_BASE) {
skipped++;
continue;
}
// 行政区划编码 → 名称;字典未命中时保留编码原值
if (mapped.province && provinceDict[mapped.province]) mapped.province = provinceDict[mapped.province];
if (mapped.city && cityDict[mapped.city]) mapped.city = cityDict[mapped.city];
if (mapped.district && countyDict[mapped.district]) mapped.district = countyDict[mapped.district];
mappedList.push(mapped);
}
// 预取已存在客户(按 iccCode),避免 N+1;需四字段现值用于变更比较
const iccCodes = [...new Set(mappedList.map((m) => m.iccCode).filter((c) => c !== ""))];
const existingRows = iccCodes.length
? await this.customerModel.findAll({
where: { iccCode: { [Op.in]: iccCodes } },
attributes: ["id", "iccCode", "customerName", "province", "city", "district"]
})
: [];
const customerByIcc = new Map(existingRows.map((c) => [c.iccCode, c]));
let inserted = 0;
let updated = 0;
for (const mapped of mappedList) {
if (!mapped.iccCode) {
skipped++; // iccCode 缺失,无同步匹配键
continue;
}
if (!mapped.customerName) {
skipped++; // 客户名必填(customer.customer_name NOT NULL),缺失跳过避免脏数据
continue;
}
const existing = customerByIcc.get(mapped.iccCode);
if (existing) {
if (this.hasFieldDiff(existing, mapped)) {
await existing.update({
customerName: mapped.customerName,
province: mapped.province ?? null,
city: mapped.city ?? null,
district: mapped.district ?? null
});
updated++;
} else {
skipped++; // 四字段均无变化,不重复更新
}
} else {
if (await this.createCustomer(mapped, customerService)) {
inserted++;
} else {
skipped++;
}
}
}
return { inserted, updated, skipped };
}
/** 四字段变更比较:字符串 trim 后比较,undefined/null 视为空串。 */
private hasFieldDiff(existing: Customer, mapped: MappedOrg): boolean {
const norm = (v: string | null | undefined): string => (v ?? "").trim();
return (
norm(existing.customerName) !== norm(mapped.customerName) ||
norm(existing.province) !== norm(mapped.province) ||
norm(existing.city) !== norm(mapped.city) ||
norm(existing.district) !== norm(mapped.district)
);
}
/**
* 新增客户:编号走 CustomerService.generateCustomerNo(KH+YYYYMMDD+3位序号)。
* 编号唯一冲突(UniqueConstraintError)时重试一次;仍失败返回 false 由调用方计 skipped。
* 其余异常上抛,由外层 catch 记录同步失败。
*/
private async createCustomer(mapped: MappedOrg, customerService: CustomerService): Promise<boolean> {
const payload: any = {
customerName: mapped.customerName,
province: mapped.province ?? null,
city: mapped.city ?? null,
district: mapped.district ?? null,
iccCode: mapped.iccCode,
status: 1
};
try {
payload.customerNo = await customerService.generateCustomerNo();
await this.customerModel.create(payload);
return true;
} catch (err: any) {
if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "customer_no" || e.path === "uk_customer_no")) {
try {
payload.customerNo = await customerService.generateCustomerNo();
await this.customerModel.create(payload);
return true;
} catch (_e) {
return false;
}
}
throw err;
}
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
@@ -0,0 +1,198 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricGateway from "../entity/electric_gateway.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_GATEWAY_ENDPOINT, mapIccGatewayRecord, parseGatewayPage, IccGatewayRawRecord } from "../task/icc_gateway_mapper";
export interface IccGatewaySyncResult {
/** ICC 拉取到的原始设备条数 */
pulled: number;
/** 成功落库的设备条数(新增+更新) */
synced: number;
/** 新增的设备条数 */
inserted: number;
/** 更新的设备条数 */
updated: number;
/** 跳过的条数(缺 imei/deviceCode / orgCode 匹配不到客户) */
skipped: number;
}
const SYNC_TYPE = "electric_gateway";
/** 分页拉取参数(ICC 设备分页接口约定,短页判定阈值) */
const PAGE_SIZE = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/**
* ICC 智电改造网关设备拉取同步:
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) POST 分页请求 ICC 设备列表(/device/subsystem/page,pageNum/pageSize=50,全量拉取)
* 3) 预取 orgCode→customer.id 映射(customer.icc_code),避免 N+1 查询客户
* 4) 按 imei/deviceCode 幂等 upsert 写入 electric_gateway,更新 synced_at
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccGatewaySyncService {
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
async syncFromIcc(ctx?: Context): Promise<IccGatewaySyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccGatewaySyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_GATEWAY_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccGatewayRawRecord[] = [];
let pageNum = 1;
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传 typeCode,全量拉取
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 网关设备接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseGatewayPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 按 imei/deviceCode 幂等 upsert(幂等),customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccGatewayRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
let skipped = 0;
if (records.length === 0) return { inserted, updated, skipped };
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
const orgCodes = [...new Set(records.map((r) => mapIccGatewayRecord(r).orgCode).filter(Boolean))];
const customers = orgCodes.length
? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
: [];
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
const now = new Date();
for (const raw of records) {
const mapped = mapIccGatewayRecord(raw);
// imei 与 deviceCode 都为空则无同步匹配键,跳过
if (!mapped.imei && !mapped.deviceCode) {
skipped++;
continue;
}
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
if (!mapped.orgCode) {
skipped++;
continue;
}
const customerId = customerIdByOrgCode.get(mapped.orgCode);
if (!customerId) {
skipped++;
continue;
}
const payload = {
iccImei: mapped.imei || null,
deviceCode: mapped.deviceCode || null,
deviceName: mapped.deviceName || null,
customerId,
orgCode: mapped.orgCode || null,
orgName: mapped.orgName || null,
manufacturerCode: mapped.manufacturerCode || null,
manufacturerName: mapped.manufacturerName || null,
typeCode: mapped.typeCode || null,
typeName: mapped.typeName || null,
productModel: mapped.productModel || null,
isOnline: mapped.isOnline,
syncedAt: now
} as any;
// 幂等 upsert:按非空键匹配(仅包含非空键)
const matchWhere: any[] = [];
if (mapped.imei) matchWhere.push({ iccImei: mapped.imei });
if (mapped.deviceCode) matchWhere.push({ deviceCode: mapped.deviceCode });
const existing = await this.gatewayModel.findOne({ where: { [Op.or]: matchWhere } });
if (existing) {
await existing.update({ ...payload, syncedAt: now });
updated++;
} else {
await this.gatewayModel.create(payload);
inserted++;
}
}
return { inserted, updated, skipped };
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
+92 -86
View File
@@ -6,31 +6,41 @@ import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity"; import ElectricLine from "../entity/electric_line.entity";
import IccSyncLog from "../entity/icc_sync_log.entity"; import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity"; import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity"; import ElectricGateway from "../entity/electric_gateway.entity";
import CustomerBuilding from "../entity/customer_building.entity"; import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_LINE_ENDPOINT, mapIccLineRecord, IccLineRawRecord } from "../task/icc_line_mapper"; import { ICC_LINE_ENDPOINT, mapIccLineRecord, parseLinePage, IccLineRawRecord } from "../task/icc_line_mapper";
export interface IccLineSyncResult { export interface IccLineSyncResult {
/** ICC 拉取到的原始条数 */ /** ICC 拉取到的原始探测器条数 */
pulled: number; pulled: number;
/** 解析落库的线路条数(成功 upsert,含新增/更新) */ /** 成功落库的线路条数(新增+更新) */
synced: number; synced: number;
/** 新增的线路条数 */ /** 新增的线路条数 */
inserted: number; inserted: number;
/** 更新的线路条数 */ /** 更新的线路条数 */
updated: number; updated: number;
/** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */ /** 跳过的条数(缺 lineCode / orgCode 匹配不到客户) */
skipped: number; skipped: number;
} }
const SYNC_TYPE = "electric_line"; const SYNC_TYPE = "electric_line";
/** 分页拉取参数(ICC 探测器分页接口约定,短页判定阈值) */
const PAGE_SIZE = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/** /**
* ICC 智电改造线路拉取同步: * ICC 智电改造线路(消防探测器)拉取同步:
* 1) 从 .env 读取 ICC_BASE_URL / ICC_TOKEN 占位配置 * 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 2) 请求 ICC 线路列表(路径与字段映射集中在 icc_line_mapper) * 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 3) 按 line_code 幂等 upsert 写入 electric_line,更新 synced_at * 2) POST 分页请求 ICC 探测器列表(/detector/subsystem/page,pageNum/pageSize=50,全量拉取)
* 4) 每次同步写一条 icc_sync_log;ICC 拉取失败仅记录 error,不抛业务错误 * 3) 预取 orgCode→customer.id 映射(customer.icc_code)与 parentCode→gateway.id 映射(electric_gateway.device_code),避免 N+1 查询
* 4) 按 line_code(detectorCode)幂等 upsert 写入 electric_line,更新 synced_at
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*/ */
@Provide() @Provide()
export class IccLineSyncService { export class IccLineSyncService {
@@ -43,52 +53,55 @@ export class IccLineSyncService {
@InjectRepository(Customer) @InjectRepository(Customer)
customerModel!: Repository<Customer>; customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment) @InjectRepository(ElectricGateway)
equipmentModel!: Repository<CustomerEquipment>; gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> { async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
const startTime = new Date(); const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim(); const baseUrl = process.env.ICC_BASE_URL?.trim();
const token = process.env.ICC_TOKEN?.trim();
const result: IccLineSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 }; const result: IccLineSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 占位配置缺失视为拉取失败:记录日志,不抛业务错误 // 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) { if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env"); await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result; return result;
} }
try { try {
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT}`; const token = await getIccAccessToken();
const headers: Record<string, string> = { Accept: "application/json" }; const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { const allRaw: IccLineRawRecord[] = [];
method: "GET", let pageNum = 1;
headers,
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
});
if (!res.ok) { while (pageNum <= MAX_PAGES) {
throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`); const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传额外筛选条件,全量拉取
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 探测器接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseLinePage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
} }
const payload: any = await res.json(); const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
// 兼容缓存工具包裹(data/list 字段)返回格式
const rawList: IccLineRawRecord[] = Array.isArray(payload)
? payload
: Array.isArray(payload.data)
? payload.data
: Array.isArray(payload.list)
? payload.list
: [];
result.pulled = rawList.length;
const { inserted, updated, skipped } = await this.batchUpsert(rawList);
result.inserted = inserted; result.inserted = inserted;
result.updated = updated; result.updated = updated;
result.synced = inserted + updated; result.synced = inserted + updated;
@@ -97,15 +110,13 @@ export class IccLineSyncService {
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null); await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result; return result;
} catch (err: any) { } catch (err: any) {
const message = err?.code === "ERR_MODULE_NOT_FOUND" || err?.message const message = err instanceof Error ? err.message : String(err);
? String(err?.message || err)
: String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message); await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result; return result;
} }
} }
/** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */ /** 按 line_code(detectorCode)幂等 upsert,customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> { private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0; let inserted = 0;
let updated = 0; let updated = 0;
@@ -113,71 +124,66 @@ export class IccLineSyncService {
if (records.length === 0) return { inserted, updated, skipped }; if (records.length === 0) return { inserted, updated, skipped };
// 预取外键映射,避免 N+1 // 预取 orgCode → customer.id 映射,避免 N+1 查询客户
const equipCodes = [...new Set(records.map((r) => mapIccLineRecord(r).equipCode).filter(Boolean))]; const orgCodes = [...new Set(records.map((r) => mapIccLineRecord(r).orgCode).filter(Boolean))];
const customerNos = [...new Set(records.map((r) => mapIccLineRecord(r).customerNo).filter(Boolean))]; const customers = orgCodes.length
const buildingNames = [...new Set(records.map((r) => mapIccLineRecord(r).buildingName).filter(Boolean))]; ? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
: [];
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
const [equips, customers, buildings] = await Promise.all([ // 预取 parentCode → gateway.id 映射(electric_gateway.device_code),避免 N+1 查询网关
equipCodes.length const parentCodes = [...new Set(records.map((r) => mapIccLineRecord(r).parentCode).filter(Boolean))];
? this.equipmentModel.findAll({ where: { equipCode: { [Op.in]: equipCodes } }, attributes: ["id", "equipCode", "customerId", "buildingId"] }) const gateways = parentCodes.length
: Promise.resolve([]), ? await this.gatewayModel.findAll({ where: { deviceCode: { [Op.in]: parentCodes } }, attributes: ["id", "deviceCode"] })
customerNos.length : [];
? this.customerModel.findAll({ where: { customerNo: { [Op.in]: customerNos } }, attributes: ["id", "customerNo"] }) const gatewayIdByParentCode = new Map(gateways.map((g) => [g.deviceCode, g.id]));
: Promise.resolve([]),
buildingNames.length
? this.buildingModel.findAll({ where: { buildingName: { [Op.in]: buildingNames } }, attributes: ["id", "buildingName", "customerId"] })
: Promise.resolve([])
]);
const equipByCode = new Map(equips.map((e) => [e.equipCode, e]));
const customerByNo = new Map(customers.map((c) => [c.customerNo, c]));
const buildingByName = new Map(buildings.map((b) => [b.buildingName, b]));
const now = new Date(); const now = new Date();
for (const raw of records) { for (const raw of records) {
const mapped = mapIccLineRecord(raw); const mapped = mapIccLineRecord(raw);
// line_code(detectorCode)为空则无同步匹配键,跳过
if (!mapped.lineCode) { if (!mapped.lineCode) {
skipped++; skipped++;
continue; continue;
} }
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined; if (!mapped.orgCode) {
// equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行 skipped++;
if (!equip) { continue;
}
const customerId = customerIdByOrgCode.get(mapped.orgCode);
if (!customerId) {
skipped++; skipped++;
continue; continue;
} }
let customerId: number = equip.customerId; // gateway_id 可空:parentCode 匹配不到 electric_gateway.device_code 时置空
if (mapped.customerNo) { const gatewayId = mapped.parentCode ? (gatewayIdByParentCode.get(mapped.parentCode) ?? null) : null;
const byNo = customerByNo.get(mapped.customerNo);
if (byNo) customerId = byNo.id;
}
let buildingId: number | null = equip.buildingId || null;
if (mapped.buildingName) {
const building = buildingByName.get(mapped.buildingName);
if (building) buildingId = building.id;
}
const payload = { const payload = {
lineCode: mapped.lineCode, lineCode: mapped.lineCode,
equipId: equip.id,
customerId,
buildingId,
iccDeviceCode: mapped.iccDeviceCode || null,
lineName: mapped.lineName || null, lineName: mapped.lineName || null,
floorNo: mapped.floorNo || null, imei: mapped.imei || null,
location: mapped.location || null, // equipId 同步不填充
gatewayId,
customerId,
typeCode: mapped.typeCode || null,
typeName: mapped.typeName || null,
fireSystemCode: mapped.fireSystemCode || null,
fireSystemName: mapped.fireSystemName || null,
partUnitLoopCode: mapped.partUnitLoopCode || null,
channelNo: mapped.channelNo || null,
channelSn: mapped.channelSn || null,
produceDate: mapped.produceDate || null,
status: mapped.status, status: mapped.status,
syncedAt: now syncedAt: now
} as any; } as any;
// 幂等 upsert:按 line_code 唯一键匹配
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } }); const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
if (existing) { if (existing) {
await existing.update(payload); await existing.update({ ...payload, syncedAt: now });
updated++; updated++;
} else { } else {
await this.lineModel.create(payload); await this.lineModel.create(payload);
@@ -202,4 +208,4 @@ export class IccLineSyncService {
console.error("[ICC同步] 同步日志写入失败:", err); console.error("[ICC同步] 同步日志写入失败:", err);
} }
} }
} }
@@ -9,6 +9,9 @@ import SysJobLog from "../entity/sys_job_log.entity";
import { LogService } from "./log.service"; import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service"; import { NoticeRuleService } from "./notice_rule.service";
import { IccLineSyncService } from "./icc_line_sync.service"; 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 { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry"; import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
/** 任务执行结果(一次尝试) */ /** 任务执行结果(一次尝试) */
@@ -452,6 +455,24 @@ export class JobSchedulerService {
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService; const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
const result = await iccService.syncFromIcc(); const result = await iccService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`; return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccCustomers: async () => {
const ctx = this.app.createAnonymousContext();
const iccCustomerService = (await ctx.requestContext.getAsync(IccCustomerSyncService)) as IccCustomerSyncService;
const result = await iccCustomerService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccGateways: async () => {
const ctx = this.app.createAnonymousContext();
const iccGatewayService = (await ctx.requestContext.getAsync(IccGatewaySyncService)) as IccGatewaySyncService;
const result = await iccGatewayService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccAnalogs: async () => {
const ctx = this.app.createAnonymousContext();
const iccAnalogService = (await ctx.requestContext.getAsync(IccAnalogSyncService)) as IccAnalogSyncService;
const result = await iccAnalogService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
} }
}; };
} }
+104
View File
@@ -0,0 +1,104 @@
/**
* ICC 探测器模拟量数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 electricity_data 字段。接入真实 ICC 平台时
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccAnalogRawRecord {
[field: string]: any;
}
/** 模拟量分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_ANALOG_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/analog/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_ANALOG_FIELDS = {
deviceCode: "deviceCode", // 探测器编码(→ electric_line.line_code 幂等键)
uniqueCode: "uniqueCode", // 设备唯一标识码(→ electricity_data.imei)
analogType: "analogType", // 模拟量类型
analogName: "analogName", // 模拟量名称(→ electricity_data.analog_name)
analogUnit: "analogUnit", // 模拟量单位(→ electricity_data.analog_unit)
analogValue: "analogValue", // 模拟量值
reportTime: "reportTime" // 上报/采集时间(yyyy-MM-dd HH:mm:ss,接口文档声明字段,保留映射)
} as const;
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/**
* 将 ICC 原始模拟量记录映射为本系统 electricity_data 落库字段。
* analogValue 用 Number 解析,空值/非法值统一归一为 null(可空落库)。
*/
export function mapIccAnalogRecord(raw: IccAnalogRawRecord): {
deviceCode: string;
uniqueCode?: string;
analogType?: string;
analogName?: string;
analogUnit?: string;
analogValue: number | null;
reportTime?: string;
} {
const f = ICC_ANALOG_FIELDS;
// 匹配键保持空串语义,由调用方判跳
const deviceCode = String(raw[f.deviceCode] ?? "").trim();
// 数值解析:空 / null / 非数值统一返回 null
let analogValue: number | null = null;
const rawValue = raw[f.analogValue];
if (rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== "") {
const num = Number(rawValue);
analogValue = Number.isFinite(num) ? num : null;
}
return {
deviceCode,
uniqueCode: toOptionalString(raw[f.uniqueCode]),
analogType: toOptionalString(raw[f.analogType]),
analogName: toOptionalString(raw[f.analogName]),
analogUnit: toOptionalString(raw[f.analogUnit]),
analogValue,
// reportTime 保留映射(接口文档仍声明该字段;当前采集时间以同步时间为准,见同步服务)
reportTime: toOptionalString(raw[f.reportTime])
};
}
/**
* 解析模拟量分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseAnalogPage(payload: any): {
list: IccAnalogRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { BuiltinJob } from "./job_registry";
/**
* 模拟量数据同步任务:从 ICC 平台按 deviceCode 分批拉取探测器最新模拟量,
* 以同步时间作采集时间写入 electricity_data(按 line_code/analog_type/collect_time 幂等),
* 写 icc_sync_log。默认每 5 分钟执行一次。
*/
export const iccAnalogSyncJob: BuiltinJob = {
jobKey: "icc_analog_sync",
jobName: "模拟量数据同步",
description: "拉取 ICC 平台探测器最新模拟量,以同步时间作采集时间写入 electricity_data(写 icc_sync_log)",
defaultCron: "*/5 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccAnalogs();
}
};
+177
View File
@@ -0,0 +1,177 @@
/**
* ICC 平台(大华 ICC)OAuth2 密码模式鉴权客户端。
*
* 流程(对齐鑫潼 ICC 平台接口文档,host 形如 222.243.55.92:1443):
* 1) GET /evo-apigw/evo-oauth/{version}/oauth/public-key 获取公钥(base64 编码的 X509 SPKI DER)
* 2) RSA/ECB/PKCS1Padding 分块加密密码(每块 ≤117 字节),分块密文 Base64 拼接
* 3) POST /evo-apigw/evo-oauth/{version}/oauth/extend/token 换取 access_token
* 4) 进程内缓存 token,过期前 60 秒视为失效并自动重新鉴权
*
* 配置项(server/.env,均为必填,ICC_VERSION 缺省 1.0.0):
* ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD
*/
import { createPublicKey, publicEncrypt, constants } from "node:crypto";
import { fetch as undiciFetch, Agent } from "undici";
/**
* ICC 平台的 HTTPS 证书为非公开受信(自签名)证书,Node 默认 TLS 校验会拒绝。
* 这里为 ICC 请求单独创建关闭证书校验的 Agent(仅作用于 ICC 请求,不影响系统其他 HTTPS 连接)。
* 注意:Node 全局 fetch 不支持外部 undici Agent dispatcher(协议不兼容),故统一使用 undici fetch 封装。
*/
let iccAgent: Agent | null = null;
function getIccAgent(): Agent {
if (!iccAgent) {
iccAgent = new Agent({ connect: { rejectUnauthorized: false } });
}
return iccAgent;
}
/** 带 ICC 专用 Agent 的 fetch:自动附加 dispatcher,调用方无需再传。 */
export function iccFetch(input: any, init?: any): ReturnType<typeof undiciFetch> {
return undiciFetch(input, { ...init, dispatcher: getIccAgent() });
}
/** 鉴权结果:accessToken 与本地过期时间戳(毫秒) */
export interface IccAuthResult {
accessToken: string;
expiresAt: number;
}
/** 获取公钥接口路径({version} 运行时替换) */
const PUBLIC_KEY_ENDPOINT = "/evo-apigw/evo-oauth/{version}/oauth/public-key";
/** 换取 token 接口路径({version} 运行时替换) */
const TOKEN_ENDPOINT = "/evo-apigw/evo-oauth/{version}/oauth/extend/token";
/** 默认 version(.env 未配置 ICC_VERSION 时使用) */
const DEFAULT_VERSION = "1.0.0";
/** RSA/ECB/PKCS1Padding 单块最大明文长度(字节) */
const RSA_MAX_CHUNK_BYTES = 117;
/** token 过期前多少毫秒视为失效并自动续签 */
const REFRESH_LEAD_TIME_MS = 60 * 1000;
/** 响应未返回 expires_in 时的默认有效期(毫秒) */
const DEFAULT_EXPIRES_IN_MS = 7200 * 1000;
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/** 换取 token 的请求体(对应 ICC extend/token 接口字段名) */
interface TokenRequestBody {
grant_type: string;
username: string;
password: string;
client_id: string;
client_secret: string;
public_key: string;
}
/** 进程内 token 缓存 */
let cachedAuth: IccAuthResult | null = null;
/** 读取必填配置,缺失时抛出明确提示(指明缺哪个配置键)。 */
function requireConfig(key: string): string {
const value = process.env[key]?.trim();
if (!value) {
throw new Error(`缺少 ICC 鉴权必需配置 ${key},请检查 server/.env`);
}
return value;
}
/** 拼接 baseUrl(去末尾斜杠)与接口路径,并替换 {version} 占位。 */
function buildUrl(pathTemplate: string): string {
const baseUrl = requireConfig("ICC_BASE_URL").replace(/\/$/, "");
const version = process.env.ICC_VERSION?.trim() || DEFAULT_VERSION;
return `${baseUrl}${pathTemplate.replace("{version}", version)}`;
}
/** 获取公钥:返回 base64 编码的 X509 SPKI DER 公钥原文。 */
async function fetchPublicKey(): Promise<string> {
const res = await iccFetch(buildUrl(PUBLIC_KEY_ENDPOINT), {
method: "GET",
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`获取 ICC 公钥失败:HTTP ${res.status} ${res.statusText}`);
}
const payload: any = await res.json();
if (String(payload?.code ?? "") !== "0" || !payload?.data) {
throw new Error(`获取 ICC 公钥失败:${JSON.stringify(payload)}`);
}
// ICC 返回结构为 { data: { publicKey: "<base64 编码的 X509 SPKI DER 公钥>" } }
return String(payload.data.publicKey ?? payload.data);
}
/**
* RSA 分块加密密码(对齐文档 Java 示例:Cipher.getInstance("RSA") 即
* RSA/ECB/PKCS1Padding,单块最大明文 117 字节),分块密文拼接后整体 Base64。
* 防御:明文为空时直接返回空字符串。
*/
function rsaEncryptPassword(password: string, publicKeyB64: string): string {
const input = Buffer.from(password, "utf8");
if (input.length === 0) return "";
// ICC 返回的 publicKey 为 base64 编码的 X509 SPKI DER 公钥,故 type 用 "spki"
const key = createPublicKey({
key: Buffer.from(publicKeyB64, "base64"),
format: "der",
type: "spki"
});
const chunks: Buffer[] = [];
for (let i = 0; i < input.length; i += RSA_MAX_CHUNK_BYTES) {
const chunk = input.subarray(i, i + RSA_MAX_CHUNK_BYTES);
chunks.push(publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, chunk));
}
return Buffer.concat(chunks).toString("base64");
}
/** 用 RSA 加密后的密码换取 access_token(data.expires_in 单位秒)。 */
async function fetchToken(body: TokenRequestBody): Promise<IccAuthResult> {
const res = await iccFetch(buildUrl(TOKEN_ENDPOINT), {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`换取 ICC token 失败:HTTP ${res.status} ${res.statusText}`);
}
const payload: any = await res.json();
if (String(payload?.code ?? "") !== "0" || !payload?.data?.access_token) {
throw new Error(`换取 ICC token 失败:${JSON.stringify(payload)}`);
}
const expiresIn = Number(payload.data.expires_in);
const expiresAt =
Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : DEFAULT_EXPIRES_IN_MS);
return { accessToken: String(payload.data.access_token), expiresAt };
}
/**
* 获取 ICC access_token(进程内缓存,过期前 60 秒自动重新鉴权)。
* 配置缺失或鉴权失败时抛 Error,由调用方(同步服务)决定如何处理。
*/
export async function getIccAccessToken(): Promise<string> {
// 一次性校验全部必填配置,缺失时明确提示
requireConfig("ICC_BASE_URL");
requireConfig("ICC_CLIENT_ID");
requireConfig("ICC_CLIENT_SECRET");
requireConfig("ICC_USERNAME");
requireConfig("ICC_PASSWORD");
if (cachedAuth && cachedAuth.expiresAt - Date.now() > REFRESH_LEAD_TIME_MS) {
return cachedAuth.accessToken;
}
const publicKey = await fetchPublicKey();
const auth = await fetchToken({
grant_type: "password",
username: requireConfig("ICC_USERNAME"),
password: rsaEncryptPassword(requireConfig("ICC_PASSWORD"), publicKey),
client_id: requireConfig("ICC_CLIENT_ID"),
client_secret: requireConfig("ICC_CLIENT_SECRET"),
public_key: publicKey
});
cachedAuth = auth;
console.log(`[ICC鉴权] access_token 获取成功,有效期至 ${new Date(auth.expiresAt).toISOString()}`);
return auth.accessToken;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* ICC 客户(组织)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 customer 字段。接入真实 ICC 平台时仅需
* 调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccOrgRawRecord {
[field: string]: any;
}
/** 组织分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_ORG_ENDPOINT = "/evo-apigw/evo-brm/{version}/organization/page";
/** 分页请求参数名约定:pageNum / pageSize / maxRangeId(调用方构造请求时使用) */
/** 组织类型过滤常量:仅同步 base 组织(对应客户) */
export const ICC_ORG_TYPE_BASE = "org_base";
/** 原始记录关键字段映射:ICC 返回字段名 → 本系统 customer 字段 */
export const ICC_ORG_FIELDS = {
orgCode: "iccCode", // ICC 客户编码(同步匹配键)
orgName: "customerName", // 组织名称 → 客户名称
province: "province", // 省
city: "city", // 市
county: "district" // 县/区 → 区县
} as const;
/** 将 ICC 原始组织记录映射为本系统 customer 落库字段(字符串 trim,空串转 undefined)。 */
export function mapIccOrgRecord(raw: IccOrgRawRecord): {
iccCode: string;
customerName: string;
province?: string;
city?: string;
district?: string;
orgType?: string;
orgNameRaw?: string;
} {
const result: Record<string, any> = {
orgType: toOptionalString(raw.orgType), // 组织类型,用于 org_base 过滤判断
orgNameRaw: toOptionalString(raw.orgName) // 原始组织名称,便于核对
};
// 遍历 ICC_ORG_FIELDS(ICC 返回字段名 → 本系统字段名),从 raw 按 ICC 字段名取值
for (const [iccField, targetField] of Object.entries(ICC_ORG_FIELDS)) {
const value = raw[iccField];
result[targetField] =
iccField === "orgCode" || iccField === "orgName"
? String(value ?? "").trim() // 匹配键/必填名保持空串语义,由调用方判跳
: toOptionalString(value);
}
return result as any;
}
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/**
* 解析组织分页响应:
* - payload 为数组 → 直接作为列表
* - payload.data 为 PageVO({ currentPage, totalPage, pageSize, totalRows, pageData })→ 取 pageData
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseOrgPage(payload: any): {
list: IccOrgRawRecord[];
currentPage: number;
totalPage: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload, currentPage: 1, totalPage: 1 };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
currentPage: Number(page.currentPage) || 1,
totalPage: Number(page.totalPage) || 1
};
}
return { list: [], currentPage: 1, totalPage: 1 };
} catch {
return { list: [], currentPage: 1, totalPage: 1 };
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { BuiltinJob } from "./job_registry";
/**
* 客户信息同步任务:从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,
* 写 icc_sync_log。默认每 10 分钟执行一次。
*/
export const iccCustomerSyncJob: BuiltinJob = {
jobKey: "icc_customer_sync",
jobName: "客户信息同步",
description: "从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(写 icc_sync_log)",
defaultCron: "*/10 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccCustomers();
}
};
+103
View File
@@ -0,0 +1,103 @@
/**
* ICC 网关设备数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 electric_gateway 字段。接入真实 ICC 平台时
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccGatewayRawRecord {
[field: string]: any;
}
/** 设备分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_GATEWAY_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/device/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_GATEWAY_FIELDS = {
imei: "imei", // 设备唯一标识码(imei)
deviceCode: "deviceCode", // 设备编码
deviceName: "deviceName", // 设备名称
orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户)
orgName: "orgName", // 所属组织名称
manufacturerCode: "manufacturerCode", // 厂商编码
manufacturerName: "manufacturerName", // 厂商名称
typeCode: "typeCode", // 设备类型编码
typeName: "typeName", // 设备类型名称
productModel: "productModel", // 产品型号
isOnline: "isOnline" // 在线状态
} as const;
/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
const OFFLINE_MARKERS = new Set(["0", "02", "false", "no", "off", "离线"]);
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/** 将 ICC 原始设备记录映射为本系统 electric_gateway 落库字段(不含联表外键)。 */
export function mapIccGatewayRecord(raw: IccGatewayRawRecord): {
imei: string;
deviceCode: string;
deviceName?: string;
orgCode?: string;
orgName?: string;
manufacturerCode?: string;
manufacturerName?: string;
typeCode?: string;
typeName?: string;
productModel?: string;
isOnline: number;
} {
const result: Record<string, any> = {
// 匹配键保持空串语义,由调用方判跳
imei: String(raw[ICC_GATEWAY_FIELDS.imei] ?? "").trim(),
deviceCode: String(raw[ICC_GATEWAY_FIELDS.deviceCode] ?? "").trim()
};
// 可选字符串字段
for (const key of ["deviceName", "orgCode", "orgName", "manufacturerCode", "manufacturerName", "typeCode", "typeName", "productModel"] as const) {
result[key] = toOptionalString(raw[ICC_GATEWAY_FIELDS[key]]);
}
// 在线状态归一化:离线标记集合 → 0,其余(含空)→ 1
const onlineRaw = String(raw[ICC_GATEWAY_FIELDS.isOnline] ?? "").trim();
result.isOnline = OFFLINE_MARKERS.has(onlineRaw) ? 0 : 1;
return result as any;
}
/**
* 解析设备分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseGatewayPage(payload: any): {
list: IccGatewayRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { BuiltinJob } from "./job_registry";
/**
* 智电网关同步任务:从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,
* 写 icc_sync_log。默认每 10 分钟执行一次。
*/
export const iccGatewaySyncJob: BuiltinJob = {
jobKey: "icc_gateway_sync",
jobName: "智电网关同步",
description: "从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert(写 icc_sync_log)",
defaultCron: "*/10 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccGateways();
}
};
+91 -33
View File
@@ -1,51 +1,109 @@
/** /**
* ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整) * ICC 智电改造线路(消防探测器)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
* *
* ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射, * ICC 返回的原始字段名 → 本系统 electric_line 字段。接入真实 ICC 平台时
* 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。 * 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/ */
export interface IccLineRawRecord { export interface IccLineRawRecord {
[field: string]: any; [field: string]: any;
} }
/** 原始记录关键字段名(ICC 平台约定,占位) */ /** 探测器分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_LINE_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/detector/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_LINE_FIELDS = { export const ICC_LINE_FIELDS = {
lineCode: "lineCode", // ICC 线路编码(唯一键) lineCode: "detectorCode", // 探测器编码(唯一幂等键)
iccDeviceCode: "deviceCode", // ICC 智电设备编号 lineName: "detectorName", // 探测器名称
equipCode: "equipCode", // 关联的客户设备编码(用于反查 equip_id) imei: "imei", // 探测器 IMEI
lineName: "lineName", // 线路名称 orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户,不落库)
customerNo: "customerNo", // 关联客户编号(用于反查 customer_id) parentCode: "parentCode", // 上级网关编码(→ electric_gateway.device_code 反查网关,不落库)
buildingName: "buildingName", // 建筑名称(占位,反查 building_id) typeCode: "typeCode", // 探测器类型编码
floorNo: "floorNo", // 所在楼层 typeName: "typeName", // 探测器类型名称
location: "location", // 所在位置 fireSystemCode: "fireSystemCode", // 消防系统编码
status: "status" // 1在线 0离线 fireSystemName: "fireSystemName", // 消防系统名称
partUnitLoopCode: "partUnitLoopCode", // 部件/单元/回路编码
channelNo: "channelNo", // 通道号
channelSn: "channelSn", // 通道序列号
produceDate: "produceDate", // 生产日期
detectorStatus: "detectorStatus" // 上报状态 01=在线 02=离线
} as const; } as const;
/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */ /** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
export const ICC_LINE_ENDPOINT = "/api/grid/lines"; export const OFFLINE_MARKERS = new Set(["02", "0", "false", "off", "离线"]);
/** 将 ICC 原始记录映射为本系统 electric_line 落库字段(不含联表外键)。 */ /** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
export function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/** 将 ICC 原始探测器记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
export function mapIccLineRecord(raw: IccLineRawRecord): { export function mapIccLineRecord(raw: IccLineRawRecord): {
lineCode: string; lineCode: string;
iccDeviceCode: string;
equipCode?: string;
lineName?: string; lineName?: string;
customerNo?: string; imei?: string;
buildingName?: string; orgCode?: string;
floorNo?: string; parentCode?: string;
location?: string; typeCode?: string;
typeName?: string;
fireSystemCode?: string;
fireSystemName?: string;
partUnitLoopCode?: string;
channelNo?: string;
channelSn?: string;
produceDate?: string;
status: number; status: number;
} { } {
const f = ICC_LINE_FIELDS; const f = ICC_LINE_FIELDS;
return { const result: Record<string, any> = {
lineCode: String(raw[f.lineCode] ?? "").trim(), // 匹配键保持空串语义,由调用方判跳
iccDeviceCode: String(raw[f.iccDeviceCode] ?? "").trim(), lineCode: String(raw[f.lineCode] ?? "").trim()
equipCode: raw[f.equipCode] ? String(raw[f.equipCode]).trim() : undefined,
lineName: raw[f.lineName] ? String(raw[f.lineName]).trim() : undefined,
customerNo: raw[f.customerNo] ? String(raw[f.customerNo]).trim() : undefined,
buildingName: raw[f.buildingName] ? String(raw[f.buildingName]).trim() : undefined,
floorNo: raw[f.floorNo] ? String(raw[f.floorNo]).trim() : undefined,
location: raw[f.location] ? String(raw[f.location]).trim() : undefined,
status: Number(raw[f.status]) === 0 ? 0 : 1
}; };
} // 可选字符串字段
for (const key of ["lineName", "imei", "orgCode", "parentCode", "typeCode", "typeName", "fireSystemCode", "fireSystemName", "partUnitLoopCode", "channelNo", "channelSn", "produceDate"] as const) {
result[key] = toOptionalString(raw[f[key]]);
}
// 上报状态归一化:离线标记集合 → 0,其余(含空)→ 1
const statusRaw = String(raw[f.detectorStatus] ?? "").trim();
result.status = OFFLINE_MARKERS.has(statusRaw) ? 0 : 1;
return result as any;
}
/**
* 解析探测器分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseLinePage(payload: any): {
list: IccLineRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+10 -1
View File
@@ -2,6 +2,9 @@ import { demoHeartbeatJob } from "./demo_heartbeat.task";
import { logCleanJob } from "./log_clean.task"; import { logCleanJob } from "./log_clean.task";
import { noticeScanJob } from "./notice_scan.task"; import { noticeScanJob } from "./notice_scan.task";
import { iccLineSyncJob } from "./icc_line_sync.task"; 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";
export type JobParamType = "number" | "string"; export type JobParamType = "number" | "string";
@@ -32,6 +35,12 @@ export interface JobRuntime {
runNoticeRules(): Promise<string>; runNoticeRules(): Promise<string>;
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */ /** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
syncIccLines(): Promise<string>; syncIccLines(): Promise<string>;
/** 从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,返回执行摘要 */
syncIccCustomers(): Promise<string>;
/** 从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,返回执行摘要 */
syncIccGateways(): Promise<string>;
/** 从 ICC 平台按 deviceCode 分批拉取探测器模拟量并按 (line_code,analog_type,collect_time) 幂等 upsert,返回执行摘要 */
syncIccAnalogs(): Promise<string>;
} }
export interface JobHandlerContext { export interface JobHandlerContext {
@@ -58,7 +67,7 @@ export interface BuiltinJob {
handler: JobHandler; handler: JobHandler;
} }
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob]; export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob, iccGatewaySyncJob, iccAnalogSyncJob];
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined { export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
return BUILTIN_JOBS.find(job => job.jobKey === jobKey); return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
+5
View File
@@ -4537,6 +4537,11 @@ undici-types@~6.21.0:
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz" resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
undici@^8.12.3:
version "8.12.3"
resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.3.tgz#e76a91feee9c275ad0f43bfc0eb632b6124d3bb6"
integrity sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==
universalify@^0.1.0: universalify@^0.1.0:
version "0.1.2" version "0.1.2"
resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz" resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz"
+90
View File
@@ -0,0 +1,90 @@
智电改造网关表:electric\_gatway(ICC同步)
| 字段 | 类型 | 键 | 说明 |
| :--------- | :---------- | :----- | :--------------- |
| id | BIGSERIAL | PK | 自增主键 |
| iccImei | VARCHAR(64) | UNIQUE | 设备唯一标识码,设备的imei号 |
| deviceCode | VARCHAR(64) | UNIQUE | 设备编码 |
| deviceName | VARCHAR(64) | <br /> | 设备名称 |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
智电改造线路表:electric_line(ICC探测器同步)
| 字段 | 类型 | 键 | 说明 |
| :------------------ | :----------- | :----- | :------------------------------------------------------- |
| id | BIGSERIAL | PK | 自增主键 |
| line_code | VARCHAR(64) | UNIQUE | 探测器编码(ICC detectorCode,唯一幂等键) |
| equip_id | BIGINT | FK | 客户设备 ID(→ customer_equipment,可空,同步不填充) |
| gateway_id | BIGINT | FK | 网关设备 ID(→ electric_gateway,由 parentCode↔device_code 匹配) |
| customer_id | BIGINT | FK | 客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键) |
| line_name | VARCHAR(128) | | 探测器名称(ICC detectorName) |
| imei | VARCHAR(64) | | 设备唯一标识码(ICC imei) |
| type_code | VARCHAR(32) | | 探测器类型编码 |
| type_name | VARCHAR(64) | | 探测器类型名称 |
| fire_system_code | VARCHAR(64) | | 消防系统编码 |
| fire_system_name | VARCHAR(128) | | 消防系统名称 |
| part_unit_loop_code | VARCHAR(64) | | 探测器回路号 |
| channel_no | VARCHAR(64) | | 探测器通道号 |
| channel_sn | VARCHAR(64) | | 通道唯一标识码 |
| produce_date | VARCHAR(16) | | 生产日期(yyyy-MM-dd) |
| status | SMALLINT | | 探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0) |
| synced_at | TIMESTAMPTZ | | ICC 同步时间 |
| created_at | TIMESTAMPTZ | | 创建时间 |
electricity_data 表结构重建(ICC 模拟量同步)
原 `electricity_data`(线路用电信息表)仅有一版占位定义(单列 `electricity`),与 ICC 平台「消防最新模拟量信息分页查询」接口(`POST /evo-apigw/evo-fdbu/{version}/analog/subsystem/page`,实际返回 `AnalogInfoVO{deviceCode, uniqueCode, analogType, analogValue, analogUnit, analogName}`,无 reportTime)不对应,本次按接口返回数据重建表结构,并实现真实同步服务与定时任务。
| 字段 | 类型 | 键 | 说明 |
| :---------- | :------------ | :----------------- | :---------------------------------------------- |
| id | BIGSERIAL | PK(复合) | 自增主键(与 collect_time 组成复合主键) |
| line_id | BIGINT NOT NULL | FK | 线路 ID(→ electric_line.id) |
| line_code | VARCHAR(64) NOT NULL | UNIQUE(复合) | 线路编码(= ICC deviceCode,冗余 + 幂等键) |
| imei | VARCHAR(64) | | 设备唯一标识码(= ICC uniqueCode) |
| line_name | VARCHAR(128) | | 线路名称(冗余展示) |
| customer_id | BIGINT NOT NULL | | 客户 ID(逻辑隔离键 → customer.id) |
| analog_type | VARCHAR(64) | | 模拟量类型(= ICC analogType) |
| analog_name | VARCHAR(128) | | 模拟量名称(= ICC analogName,如"剩余电流") |
| analog_unit | VARCHAR(32) | | 模拟量单位(= ICC analogUnit,如"mA") |
| analog_value | NUMERIC(14,4) | | 模拟量值(= ICC analogValue) |
| collect_time | TIMESTAMPTZ NOT NULL | PK(复合)/分区键 | 采集时间 = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作为分区键与采集时间) |
键与分区:
- 主键:`(id, collect_time)`(复合主键,兼容按月分区)
- 唯一键:`UNIQUE(line_code, analog_type, collect_time)`,作幂等键(同键重复上报覆盖更新)
- 分区:按 `collect_time` 按月 RANGE 分区,初始分区 `2026_10`~`2027_03`(`electricity_data_2026_10`~`electricity_data_2027_03`,共 6 个),后续按月自动/手动创建
- 索引:`idx_ed_customer(customer_id)`、`idx_ed_line(line_id)`、`idx_ed_time(collect_time)`、`idx_ed_customer_time(customer_id, collect_time)`(父表创建,自动继承到各分区)
关联关系:
- `line_id` → `electric_line.id`(再由 electric_line 关联 customer 与 customer_equipment)
- `customer_id` → `customer.id`(逻辑隔离键)
- **明确注明:按需求确认,`equip_id` 字段已删除,本表不再直接关联 customer_equipment。**
变更说明:
- 删除旧占位列 `electricity`(重建为通用模拟量行:analog_type / analog_value)与 `equip_id`
- 原 `icc_device_code` 更名 `imei`(映射 uniqueCode)
- 迁移文件:`database/migrations/20261024_icc_analog_data.sql`(建表/分区/索引/注释 + sys_job 种子,2026-10-24 已执行到生产库),`database/db_init.sql` 同步更新
同步服务说明:
- 默认每 5 分钟执行一次;复用 ICC OAuth2 鉴权(`getIccAccessToken`/`iccFetch`)
- 先读取已同步 `electric_line` 的 line_code(= detectorCode),按 deviceCodes 分批(每批 ≤50 个)POST 模拟量接口(pageSize=500,短页停止全量拉取)
- 字段映射:imei=uniqueCode、analog_type=analogType、analog_name=analogName、analog_unit=analogUnit、analog_value=analogValue;collect_time = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作为分区键与采集时间);预取 deviceCode→(line_id, customer_id) 映射避免 N+1
- 按 `(line_code, analog_type, collect_time)` 幂等 upsert(同一次同步内重复则覆盖更新);写 `icc_sync_log`(sync_type=electricity_data);拉取失败仅记日志不抛业务错
定时任务:
- jobKey=`icc_analog_sync`,cron `*/5 * * * *`(默认每 5 分钟,sys_job 种子幂等,注册进 job_registry/JobRuntime)
- 手动同步接口:`POST /api/electricity-data/sync`(JWT+RBAC `device:list`,返回 pulled/inserted/updated/skipped/synced)
- 不做前端页面/菜单
+350
View File
@@ -0,0 +1,350 @@
# 鑫潼ICC平台接口文档
## 接口环境变量
host:222.243.55.92,端口:1443
clientId:xtzdapp
clientSecret:1c7e440a-660c-4a5d-a7b8-82b58d9dca4e
username:XTZD接口
password:Xtzdkj\@20260801
version:1.0.0
## 接口鉴权认证
大华ICC平台采用OAuth2 鉴权认证。
OAuth2是目前最流行的授权机制,是一种安全机制保障的方案。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(Access\_token)和刷新该令牌的凭证(Refresh\_token),用来代替密码,供第三方应用使用。客户端认证模式常用于微服务间交互,该模式Access\_token有效期一般较长,两小时,且没有Refresh\_token,过期可以直接认证获取匿名令牌。
授权模式:密码认证模式,密码模式在客户端基础上,从用户方获取帐号密码,再访问授权服务器认证授权。
![图片 2](assets/image1.jpeg)
客户端从用户方获取帐号密码。2、客户端携带用户信息向认证服务器获取令牌。3、认证服务器确认无误后,向客户端提供访问令牌。4、客户端携带令牌访问资源端。
协议鉴权
![图片 4](assets/image2.png)
鉴权接口的access\_token有效期2h,权限与鉴权用户的组织、部门权限保持一致。
获取公钥:访问地址:https\://{{host}}/evo-apigw/evo-oauth/{{version}}/oauth/public-key。其中{{host}}为接口环境变量的host:端口。{{version}}为接口环境变量的version。
```http
GET /evo-apigw/evo-oauth/{{version}}/oauth/public-key HTTP/1.1
```
RSA加密密码。以下是java RSA加密示例:其中使用接口环境变量的username、password。
```java
import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
public class RSAUtil {
/**
* 基于原生RSA公钥加密
* @param password 用户密码
* @param publicKey 公钥
* @return
* @throws Exception
*/
public static String encrypt(String password, String publicKey) throws Exception {
//base64编码的公钥
byte[] decoded = java.util.Base64.getDecoder().decode(publicKey);
RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
//RSA加密
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, pubKey);
String outStr;
byte[] inputArray = password.getBytes("UTF-8");
int inputLength = inputArray.length;
// 最大加密字节数,超出最大字节数需要分组加密
int MAX_ENCRYPT_BLOCK = 117;
// 标识
int offSet = 0;
byte[] resultBytes = {};
byte[] cache = {};
while (inputLength - offSet > 0) {
if (inputLength - offSet > MAX_ENCRYPT_BLOCK) {
cache = cipher.doFinal(inputArray, offSet, MAX_ENCRYPT_BLOCK);
offSet += MAX_ENCRYPT_BLOCK;
} else {
cache = cipher.doFinal(inputArray, offSet, inputLength - offSet);
offSet = inputLength;
}
resultBytes = Arrays.copyOf(resultBytes, resultBytes.length + cache.length);
System.arraycopy(cache, 0, resultBytes, resultBytes.length - cache.length, cache.length);
}
outStr = java.util.Base64.getEncoder().encodeToString(resultBytes);
return outStr;
}
}
```
认证:访问凭证client\_id与client\_secret,使用接口环境变量的clientId、clientSecret。access\_token有效期2h。访问地址:https\://{{host}}/evo-apigw/evo-oauth/{{version}}/oauth/extend/token其中使用接口环境变量的username、password、clientId、clientSecret。
```http
POST /evo-apigw/evo-oauth/1.0.0/oauth/extend/token HTTP/1.1
Content-Type: application/json;charset=UTF-8
Host: 222.243.55.92:1443
{
"username":"用户账号",
"password":"RSA加密后的密码",
"client_id":"申请的clientId",
"client_secret":"申请的clientSecret",
"public_key":"获取的公钥"
}
```
返回参数示例:
```json
{
"success": true,
"data": {
"access_token": "10000:5cb47cd80-35d9-406d-a8d3-d87dd672d5d3",
"token_type": "bearer",
"refresh_token": "b83403e1-5ea1-41fd-81fc-e86fe5252c93",
"expires_in": 7199,
"scope": "*",
"userId": "10000",
"magicId": "1868a7db-adeb-42de-8064-43b3706ae407"
},
"code": "0",
"errMsg": ""
}
```
请求头设置鉴权码:调用业务接口需在请求头中设置鉴权码:
| **头部参数** | **解释** |
| ------------- | -------------------------------------------------------------------------------------------------------------- |
| Authorization | 格式:token\_type + 空格 + access\_token, token\_type一般为固定值bearer 例如:bearer 1:bb951116-d963-4528-80e1-91c2e94c3e75 |
## 本次接口说明
### 一、组织全量数据同步接口
前置条件:请求头设置Authorization字段
接口URL:https\://{{host}}/evo-apigw/evo-brm/{{version}}/organization/page
请求Method:GET
数据提交格式:application/json
请求参数:
| **参数名** | **必选** | **类型** | **说明** |
| ---------- | ------ | ------ | -------------------- |
| pageNum | false | int | 当前页, 默认1 |
| pageSize | false | int | 每页记录数, 默认10,最大支持1000 |
| orgPreCode | false | String | 组织父节点 |
| maxRangeId | true | long | 同步边界值 |
返回参数:
| **名称** | **类型** | **说明** |
| ------- | ------- | -------------------- |
| success | boolean | 接口返回是否成功 |
| code | string | 错误码:0-为无错误,非0-为具体错误码 |
| errMsg | string | 错误消息 |
| data | PageVO | 分页数据 |
PageVO
| **名称** | **类型** | **说明** |
| ----------- | --------------- | ------ |
| currentPage | int | 当前页码 |
| totalPage | int | 总页数 |
| pageSize | int | 每页记录数 |
| totalRows | int | 总数 |
| pageData | List<OrgInfoVO> | 数据列表 |
OrgInfoVO
| **名称** | **类型** | **说明** |
| ----------------- | ------- | ------------------------ |
| orgCode | string | 组织编码 |
| orgName | string | 组织名称 |
| orgSn | string | 唯一标识码 |
| orgType | string | 组织结构类型,具体参考组织类型 |
| province | string | 省 |
| city | string | 市 |
| county | string | 县 |
| domainId | Long | 域ID |
| stat | Integer | 状态 |
| sort | Integer | 排序码 |
| allSort | string | 所有组织排序 |
| service | string | 子系统服务编码,默认evo-thirdParty |
| forbidBindUser | Integer | 禁止绑定用户 :0-可绑定用户,1-不可绑定用户 |
| forbidBindService | String | 禁止绑定用户服务 |
组织类型
| **类型key** | **类型value** | **备注** |
| ------------- | ----------- | ------ |
| org\_base | 基础组织 | <br /> |
| org\_country | 国家 | <br /> |
| org\_province | 省 | <br /> |
| org\_city | 市 | <br /> |
| org\_county | 区县 | <br /> |
接口数据字段与customer(客户信息表)对应关系:
| **接口字段名称** | **字段说明** | **客户信息表字段名称** | **字段说明** |
| ---------- | -------- | -------------- | -------- |
| orgCode | 组织编码 | iccCode | ICC客户编码 |
| orgName | 组织名称 | customer\_name | 客户名称 |
| province | 省 | province | 省 |
| city | 市 | city | 市 |
| county | 县 | district | 区/县 |
**二、消防物联设备分页查询接口**
前置条件:请求头设置Authorization字段
接口URL:https\://{{host}}/evo-apigw/evo-fdbu/{{version}}/device/subsystem/page
请求Method:POST 数据提交格式:application/json
请求参数:
| **参数名** | **必选** | **类型** | **说明** |
| :----------- | :----- | :----- | :--------------- |
| pageNum | false | int | 当前页, 默认1 |
| pageSize | false | int | 每页记录数, 默认10,最多50 |
| orgCodes | false | array | 设备所属组织编码集合,最多10 |
| typeCode | false | string | 主机类型 |
| keyCondition | false | string | 设备名称/设备编号/imei |
**请求参数示例**
```JSON
{
"pageNum": 1,
"pageSize": 10,
"orgCodes": ["001001", "002001"],
"typeCode": 128,
"keyCondition": "测试"
}
```
**返回参数**
| **名称** | **类型** | **说明** |
| :------ | :--------- | :------------------- |
| success | boolean | 接口返回是否成功 |
| code | string | 错误码:0-为无错误,非0-为具体错误码 |
| errMsg | string | 错误消息 |
| data | **PageVO** | 分页数据 |
**PageVO**
| **名称** | **类型** | **说明** |
| :--------- | :--------------------- | :----- |
| nextPage | int | 当前页码 |
| totalCount | int | 总数 |
| pageData | List<**DeviceInfoVO**> | 数据列表 |
**DeviceInfoVO**
| **名称** | **类型** | **说明** |
| :--------------- | :----- | :--------------- |
| id | string | 唯一主键 |
| imei | String | 设备唯一标识码,设备的imei号 |
| deviceCode | String | 设备编码 |
| deviceName | String | 设备名称 |
| companyId | String | 公司编号 |
| companyName | String | 公司名称 |
| manufacturerCode | String | 厂商编码 |
| manufacturerName | String | 厂商名称 |
| typeCode | String | 主机类型 |
| typeName | String | 主机类型名称 |
| orgCode | String | 组织编码 |
| orgName | String | 组织名称 |
| dcpsId | string | 接入设备域 |
| softwareVersion | string | 软件版本号 |
| systemAddress | int | 系统地址 |
| address | string | 安装地址 |
| productModel | String | 设备型号 |
| isOnline | string | 在离线状态 |
| description | string | 描述 |
接口数据字段与electric\_gateway(智能改造设备表)对应关系:
<br />
三、**消防探测器分页查询**
前置条件:请求头设置Authorization字段
接口URL:https\://{{host}}/evo-apigw/evo-fdbu/{{version}}/detector/subsystem/page
请求Method:POST 数据提交格式:application/json
请求参数:
| **参数名** | **必选** | **类型** | **说明** |
| :----------- | :----- | :----- | :--------------- |
| pageNum | false | int | 当前页, 默认1 |
| pageSize | false | int | 每页记录数, 默认10,最多50 |
| orgCodes | false | array | 设备所属组织编码集合,最多10 |
| typeCode | false | string | 探测器类型 |
| keyCondition | false | string | 设备名称/设备编号/imei |
**请求参数示例**
```JSON
{
"pageNum": 1,
"pageSize": 10,
"orgCodes": ["001001", "002001"],
"typeCode": 128,
"keyCondition": "测试"
}
```
**返回参数**
| **名称** | **类型** | **说明** |
| :------ | :--------- | :------------------- |
| success | boolean | 接口返回是否成功 |
| code | string | 错误码:0-为无错误,非0-为具体错误码 |
| errMsg | string | 错误消息 |
| data | **PageVO** | 分页数据 |
**PageVO**
| **名称** | **类型** | **说明** |
| :--------- | :------------------- | :----- |
| nextPage | int | 当前页码 |
| totalCount | int | 总数 |
| pageData | List<**DetectorVO**> | 数据列表 |
**DetectorVO**
| **名称** | **类型** | **说明** |
| :--------------- | :----- | :----------------- |
| id | string | 唯一主键 |
| detectorCode | string | 探测器编码 |
| detectorName | string | 探测器名称 |
| imei | string | 设备唯一标识码,设备的imei号 |
| detectorStatus | int | 探测器状态,参考上报状态 |
| orgCode | String | 组织编码 |
| orgName | String | 组织名称 |
| companyId | String | 探测器所属单位编码 |
| companyName | String | 探测器所属单位名称 |
| parentCode | string | 父设备编码 |
| parentName | string | 父设备名称 |
| manufacturerCode | String | 厂商编码 |
| manufacturerName | String | 厂商名称 |
| typeCode | String | 探测器类型 |
| typeName | String | 探测器类型名称 |
| fireSystemCode | String | 消防系统编码 |
| fireSystemName | String | 消防系统名称 |
| partUnitLoopCode | string | 探测器回路号 |
| channelNo | string | 探测器通道号 |
| systemAddress | int | 系统地址 |
| channelSn | string | 通道唯一标识码 |
| mapType | String | 地图类型 |
| produceDate | string | 生产日期,格式:yyyy-MM-dd |
**上报状态**
| **状态编码** | **状态名称** | **备注** |
| :------- | :------- | :----- |
| 01 | 在线 | <br /> |
| 02 | 离线 | <br /> |
接口数据字段与electric\_**line**(智电改造线路表)对应关系: