From 160ad85051681af77ee9b315dd5810bad8754bbf Mon Sep 17 00:00:00 2001
From: wangkai <10903508@qq.com>
Date: Sat, 24 Oct 2026 13:50:32 +0800
Subject: [PATCH] =?UTF-8?q?feat(line):=20=E6=99=BA=E7=94=B5=E6=94=B9?=
=?UTF-8?q?=E9=80=A0=E7=BA=BF=E8=B7=AF=E5=8F=B0=E8=B4=A6=E6=8E=A5=E5=85=A5?=
=?UTF-8?q?=20ICC=20=E6=B6=88=E9=98=B2=E6=8E=A2=E6=B5=8B=E5=99=A8=E7=9C=9F?=
=?UTF-8?q?=E5=AE=9E=E5=90=8C=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 重建 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)
---
.trae/specs/add-icc-line-sync/checklist.md | 12 ++
.trae/specs/add-icc-line-sync/spec.md | 71 +++++++
.trae/specs/add-icc-line-sync/tasks.md | 25 +++
client/src/api/device.ts | 17 +-
client/src/views/device/line/index.vue | 40 ++--
database/db_init.sql | 64 ++++---
database/db_seed.sql | 8 +
.../migrations/20261024_icc_line_rebuild.sql | 48 +++++
server/src/entity/electric_line.entity.ts | 67 ++++---
server/src/service/electric_line.service.ts | 33 ++--
server/src/service/icc_line_sync.service.ts | 178 +++++++++---------
server/src/task/icc_line_mapper.ts | 124 ++++++++----
系统设计/表结构新增及变更.md | 23 +++
13 files changed, 510 insertions(+), 200 deletions(-)
create mode 100644 .trae/specs/add-icc-line-sync/checklist.md
create mode 100644 .trae/specs/add-icc-line-sync/spec.md
create mode 100644 .trae/specs/add-icc-line-sync/tasks.md
create mode 100644 database/migrations/20261024_icc_line_rebuild.sql
diff --git a/.trae/specs/add-icc-line-sync/checklist.md b/.trae/specs/add-icc-line-sync/checklist.md
new file mode 100644
index 0000000..95457fb
--- /dev/null
+++ b/.trae/specs/add-icc-line-sync/checklist.md
@@ -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;浏览器页面新列渲染正常
diff --git a/.trae/specs/add-icc-line-sync/spec.md b/.trae/specs/add-icc-line-sync/spec.md
new file mode 100644
index 0000000..43048ef
--- /dev/null
+++ b/.trae/specs/add-icc-line-sync/spec.md
@@ -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 重写。
diff --git a/.trae/specs/add-icc-line-sync/tasks.md b/.trae/specs/add-icc-line-sync/tasks.md
new file mode 100644
index 0000000..cb4a859
--- /dev/null
+++ b/.trae/specs/add-icc-line-sync/tasks.md
@@ -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 依赖全部
diff --git a/client/src/api/device.ts b/client/src/api/device.ts
index 356ceb8..21a2c0f 100644
--- a/client/src/api/device.ts
+++ b/client/src/api/device.ts
@@ -154,9 +154,6 @@ export interface ElectricLine {
iccDeviceCode?: string;
lineName?: string;
customerId: number;
- buildingId?: number;
- floorNo?: string;
- location?: string;
status: number;
syncedAt?: string;
createdAt: string;
@@ -165,7 +162,19 @@ export interface ElectricLine {
customerName?: string;
equipCode?: 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 {
diff --git a/client/src/views/device/line/index.vue b/client/src/views/device/line/index.vue
index 25b285c..4ed5b50 100644
--- a/client/src/views/device/line/index.vue
+++ b/client/src/views/device/line/index.vue
@@ -16,7 +16,7 @@
智电改造线路台账
ELECTRIC LINE LEDGER
-
由 ICC 平台同步的智电改造线路台账,按客户、设备、建筑查看线路及在线状态。
+ 由 ICC 平台同步的智电改造线路台账,按客户、网关查看线路、探测器及在线状态。
@@ -47,7 +47,7 @@
@@ -103,16 +103,32 @@
{{ record.lineName || '-' }}
+
+ {{ record.imei || '-' }}
+
+
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
-
- {{ record.equipName ? `${record.equipName}(${record.equipCode})` : '-' }}
+
+ {{ record.gatewayName ? `${record.gatewayName}(${record.gatewayCode})` : (record.gatewayCode || '-') }}
-
- {{ record.buildingName || '-' }}
+
+ {{ record.typeName || '-' }}
+
+
+
+ {{ [record.partUnitLoopCode, record.channelNo].filter(Boolean).join('/') || '-' }}
+
+
+
+ {{ record.fireSystemName || '-' }}
+
+
+
+ {{ record.produceDate || '-' }}
@@ -172,11 +188,13 @@ const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 160 },
{ 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: 'equipId', key: 'equipId', ellipsis: true, width: 180 },
- { title: '所属建筑', dataIndex: 'buildingId', key: 'buildingId', ellipsis: true, width: 140 },
- { title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
- { title: '所在位置', dataIndex: 'location', key: 'location', ellipsis: true },
+ { title: '所属网关', dataIndex: 'gatewayId', key: 'gatewayId', ellipsis: true, width: 180 },
+ { title: '探测器类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
+ { title: '回路/通道', dataIndex: 'loopChannel', key: 'loopChannel', ellipsis: true, width: 160 },
+ { 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: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
];
@@ -499,4 +517,4 @@ function formatTime(val?: string) {
background: #136bfb;
border-color: #136bfb;
}
-
\ No newline at end of file
+
diff --git a/database/db_init.sql b/database/db_init.sql
index c9135ef..fc7cfee 100644
--- a/database/db_init.sql
+++ b/database/db_init.sql
@@ -614,35 +614,47 @@ CREATE INDEX idx_contract_xt ON contract (xt_contract_no);
-- 19. 智电改造线路表(ICC 同步 + 业务分配)
DROP TABLE IF EXISTS electric_line CASCADE;
CREATE TABLE electric_line (
- id BIGSERIAL PRIMARY KEY,
- line_code VARCHAR(64) NOT NULL UNIQUE,
- equip_id BIGINT NOT NULL REFERENCES customer_equipment(id),
- icc_device_code VARCHAR(128),
- line_name VARCHAR(128),
- customer_id BIGINT NOT NULL REFERENCES customer(id),
- building_id BIGINT REFERENCES customer_building(id),
- floor_no VARCHAR(16),
- location VARCHAR(256),
- status SMALLINT NOT NULL DEFAULT 1,
- synced_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ id BIGSERIAL PRIMARY KEY,
+ line_code VARCHAR(64) NOT NULL UNIQUE,
+ equip_id BIGINT,
+ gateway_id BIGINT,
+ customer_id BIGINT NOT NULL REFERENCES customer(id),
+ line_name VARCHAR(128),
+ imei VARCHAR(64),
+ 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),
+ 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 COLUMN electric_line.id IS '自增主键';
-COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)';
-COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)';
-COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号';
-COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)';
-COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)';
-COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)';
-COMMENT ON COLUMN electric_line.floor_no IS '所在楼层';
-COMMENT ON COLUMN electric_line.location IS '所在位置';
-COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线';
-COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
-COMMENT ON COLUMN electric_line.created_at IS '创建时间';
+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,外键由迁移/db_seed 建立)';
+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 '创建时间';
CREATE INDEX idx_line_customer ON electric_line (customer_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. 线路所属房间关联表
DROP TABLE IF EXISTS line_room CASCADE;
diff --git a/database/db_seed.sql b/database/db_seed.sql
index 4d26fe1..17de238 100644
--- a/database/db_seed.sql
+++ b/database/db_seed.sql
@@ -493,6 +493,14 @@ 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)
diff --git a/database/migrations/20261024_icc_line_rebuild.sql b/database/migrations/20261024_icc_line_rebuild.sql
new file mode 100644
index 0000000..8f927b2
--- /dev/null
+++ b/database/migrations/20261024_icc_line_rebuild.sql
@@ -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;
diff --git a/server/src/entity/electric_line.entity.ts b/server/src/entity/electric_line.entity.ts
index 49279a9..56131fb 100644
--- a/server/src/entity/electric_line.entity.ts
+++ b/server/src/entity/electric_line.entity.ts
@@ -1,7 +1,7 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import CustomerEquipment from "./customer_equipment.entity";
+import ElectricGateway from "./electric_gateway.entity";
import Customer from "./customer.entity";
-import CustomerBuilding from "./customer_building.entity";
@Table({
tableName: "electric_line",
@@ -10,41 +10,60 @@ import CustomerBuilding from "./customer_building.entity";
indexes: [
{ name: "idx_line_customer", fields: ["customer_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 {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
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;
- @ForeignKey(() => CustomerEquipment)
- @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 同步)" })
+ @Column({ type: DataType.STRING(128), field: "line_name", comment: "探测器名称(detectorName)" })
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)
- @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;
- @ForeignKey(() => CustomerBuilding)
- @Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" })
- buildingId!: number;
+ @Column({ type: DataType.STRING(32), field: "type_code", comment: "探测器类型编码(typeCode)" })
+ typeCode!: string;
- @Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
- floorNo!: string;
+ @Column({ type: DataType.STRING(64), field: "type_name", comment: "探测器类型名称(typeName)" })
+ typeName!: string;
- @Column({ type: DataType.STRING(256), comment: "所在位置" })
- location!: string;
+ @Column({ type: DataType.STRING(64), field: "fire_system_code", comment: "消防系统编码(fireSystemCode)" })
+ 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;
@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" })
equipment!: CustomerEquipment;
+ @BelongsTo(() => ElectricGateway, { foreignKey: "gatewayId", as: "gateway" })
+ gateway!: ElectricGateway;
+
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
- @BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" })
- building!: CustomerBuilding;
-
@CreatedAt
created_at!: Date;
-}
\ No newline at end of file
+}
diff --git a/server/src/service/electric_line.service.ts b/server/src/service/electric_line.service.ts
index 9296e14..952ff47 100644
--- a/server/src/service/electric_line.service.ts
+++ b/server/src/service/electric_line.service.ts
@@ -4,9 +4,9 @@ import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity";
+import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
-import CustomerBuilding from "../entity/customer_building.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@@ -21,8 +21,8 @@ export class ElectricLineService {
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository;
- @InjectRepository(CustomerBuilding)
- buildingModel!: Repository;
+ @InjectRepository(ElectricGateway)
+ gatewayModel!: Repository;
@InjectRepository(SysUserRole)
userRoleModel!: Repository;
@@ -44,12 +44,12 @@ export class ElectricLineService {
if (query.keyword) {
where[Op.or] = [
{ 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.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);
// 非管理员按客户归属自动限定数据范围
@@ -95,15 +95,15 @@ export class ElectricLineService {
return { code: 200, message: "获取线路台账详情成功", data: decorated[0] };
}
- /** 补全客户/设备/建筑展示信息,避免 N+1。 */
+ /** 补全客户/设备/网关展示信息,避免 N+1。 */
private async decorate(rows: ElectricLine[]): Promise {
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 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
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
@@ -116,28 +116,29 @@ export class ElectricLineService {
attributes: ["id", "equipCode", "equipName"]
})
: Promise.resolve([]),
- buildingIds.length
- ? this.buildingModel.findAll({
- where: { id: { [Op.in]: buildingIds } },
- attributes: ["id", "buildingName"]
+ gatewayIds.length
+ ? this.gatewayModel.findAll({
+ where: { id: { [Op.in]: gatewayIds } },
+ attributes: ["id", "deviceCode", "deviceName"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
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) => {
const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId);
- const equip: any = equipMap.get(row.equipId);
- const building: any = row.buildingId ? buildingMap.get(row.buildingId) : undefined;
+ const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
+ const gateway: any = row.gatewayId ? gatewayMap.get(row.gatewayId) : undefined;
json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || "";
- json.buildingName = building?.buildingName || "";
+ json.gatewayCode = gateway?.deviceCode || "";
+ json.gatewayName = gateway?.deviceName || "";
return json;
});
}
diff --git a/server/src/service/icc_line_sync.service.ts b/server/src/service/icc_line_sync.service.ts
index 39f827d..9a8fe52 100644
--- a/server/src/service/icc_line_sync.service.ts
+++ b/server/src/service/icc_line_sync.service.ts
@@ -6,31 +6,41 @@ import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity";
-import CustomerEquipment from "../entity/customer_equipment.entity";
-import CustomerBuilding from "../entity/customer_building.entity";
-import { ICC_LINE_ENDPOINT, mapIccLineRecord, IccLineRawRecord } from "../task/icc_line_mapper";
+import ElectricGateway from "../entity/electric_gateway.entity";
+import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
+import { ICC_LINE_ENDPOINT, mapIccLineRecord, parseLinePage, IccLineRawRecord } from "../task/icc_line_mapper";
export interface IccLineSyncResult {
- /** ICC 拉取到的原始条数 */
+ /** ICC 拉取到的原始探测器条数 */
pulled: number;
- /** 解析落库的线路条数(成功 upsert,含新增/更新) */
+ /** 成功落库的线路条数(新增+更新) */
synced: number;
/** 新增的线路条数 */
inserted: number;
/** 更新的线路条数 */
updated: number;
- /** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */
+ /** 跳过的条数(缺 lineCode / orgCode 匹配不到客户) */
skipped: number;
}
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 智电改造线路拉取同步:
- * 1) 从 .env 读取 ICC_BASE_URL / ICC_TOKEN 占位配置
- * 2) 请求 ICC 线路列表(路径与字段映射集中在 icc_line_mapper)
- * 3) 按 line_code 幂等 upsert 写入 electric_line,更新 synced_at
- * 4) 每次同步写一条 icc_sync_log;ICC 拉取失败仅记录 error,不抛业务错误
+ * ICC 智电改造线路(消防探测器)拉取同步:
+ * 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
+ * 从 .env 读取 ICC_BASE_URL / ICC_VERSION
+ * 2) POST 分页请求 ICC 探测器列表(/detector/subsystem/page,pageNum/pageSize=50,全量拉取)
+ * 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()
export class IccLineSyncService {
@@ -43,52 +53,55 @@ export class IccLineSyncService {
@InjectRepository(Customer)
customerModel!: Repository;
- @InjectRepository(CustomerEquipment)
- equipmentModel!: Repository;
-
- @InjectRepository(CustomerBuilding)
- buildingModel!: Repository;
+ @InjectRepository(ElectricGateway)
+ gatewayModel!: Repository;
async syncFromIcc(ctx?: Context): Promise {
const startTime = new Date();
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 };
- // 占位配置缺失视为拉取失败:记录日志,不抛业务错误
+ // 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
- const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT}`;
- const headers: Record = { Accept: "application/json" };
- if (token) headers.Authorization = `Bearer ${token}`;
+ const token = await getIccAccessToken();
+ const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
- const res = await fetch(url, {
- method: "GET",
- headers,
- signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
- });
+ const allRaw: IccLineRawRecord[] = [];
+ let pageNum = 1;
- if (!res.ok) {
- throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`);
+ 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 }), // 不传额外筛选条件,全量拉取
+ 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();
- // 兼容缓存工具包裹(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);
+ const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
@@ -97,15 +110,13 @@ export class IccLineSyncService {
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
- const message = err?.code === "ERR_MODULE_NOT_FOUND" || err?.message
- ? String(err?.message || err)
- : String(err);
+ const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
- /** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */
+ /** 按 line_code(detectorCode)幂等 upsert,customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
@@ -113,71 +124,66 @@ export class IccLineSyncService {
if (records.length === 0) return { inserted, updated, skipped };
- // 预取外键映射,避免 N+1
- const equipCodes = [...new Set(records.map((r) => mapIccLineRecord(r).equipCode).filter(Boolean))];
- const customerNos = [...new Set(records.map((r) => mapIccLineRecord(r).customerNo).filter(Boolean))];
- const buildingNames = [...new Set(records.map((r) => mapIccLineRecord(r).buildingName).filter(Boolean))];
+ // 预取 orgCode → customer.id 映射,避免 N+1 查询客户
+ const orgCodes = [...new Set(records.map((r) => mapIccLineRecord(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 [equips, customers, buildings] = await Promise.all([
- equipCodes.length
- ? this.equipmentModel.findAll({ where: { equipCode: { [Op.in]: equipCodes } }, attributes: ["id", "equipCode", "customerId", "buildingId"] })
- : Promise.resolve([]),
- customerNos.length
- ? this.customerModel.findAll({ where: { customerNo: { [Op.in]: customerNos } }, attributes: ["id", "customerNo"] })
- : 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]));
+ // 预取 parentCode → gateway.id 映射(electric_gateway.device_code),避免 N+1 查询网关
+ const parentCodes = [...new Set(records.map((r) => mapIccLineRecord(r).parentCode).filter(Boolean))];
+ const gateways = parentCodes.length
+ ? await this.gatewayModel.findAll({ where: { deviceCode: { [Op.in]: parentCodes } }, attributes: ["id", "deviceCode"] })
+ : [];
+ const gatewayIdByParentCode = new Map(gateways.map((g) => [g.deviceCode, g.id]));
const now = new Date();
for (const raw of records) {
const mapped = mapIccLineRecord(raw);
+ // line_code(detectorCode)为空则无同步匹配键,跳过
if (!mapped.lineCode) {
skipped++;
continue;
}
-
- const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined;
- // equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行
- if (!equip) {
+ // customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
+ if (!mapped.orgCode) {
+ skipped++;
+ continue;
+ }
+ const customerId = customerIdByOrgCode.get(mapped.orgCode);
+ if (!customerId) {
skipped++;
continue;
}
- let customerId: number = equip.customerId;
- if (mapped.customerNo) {
- 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;
- }
+ // gateway_id 可空:parentCode 匹配不到 electric_gateway.device_code 时置空
+ const gatewayId = mapped.parentCode ? (gatewayIdByParentCode.get(mapped.parentCode) ?? null) : null;
const payload = {
lineCode: mapped.lineCode,
- equipId: equip.id,
- customerId,
- buildingId,
- iccDeviceCode: mapped.iccDeviceCode || null,
lineName: mapped.lineName || null,
- floorNo: mapped.floorNo || null,
- location: mapped.location || null,
+ imei: mapped.imei || 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,
syncedAt: now
} as any;
+ // 幂等 upsert:按 line_code 唯一键匹配
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
if (existing) {
- await existing.update(payload);
+ await existing.update({ ...payload, syncedAt: now });
updated++;
} else {
await this.lineModel.create(payload);
@@ -202,4 +208,4 @@ export class IccLineSyncService {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
-}
\ No newline at end of file
+}
diff --git a/server/src/task/icc_line_mapper.ts b/server/src/task/icc_line_mapper.ts
index 2ede236..ca55bf9 100644
--- a/server/src/task/icc_line_mapper.ts
+++ b/server/src/task/icc_line_mapper.ts
@@ -1,51 +1,109 @@
/**
- * ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)
+ * ICC 智电改造线路(消防探测器)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
- * ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射,
- * 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。
+ * ICC 返回的原始字段名 → 本系统 electric_line 字段。接入真实 ICC 平台时
+ * 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccLineRawRecord {
[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 = {
- lineCode: "lineCode", // ICC 线路编码(唯一键)
- iccDeviceCode: "deviceCode", // ICC 智电设备编号
- equipCode: "equipCode", // 关联的客户设备编码(用于反查 equip_id)
- lineName: "lineName", // 线路名称
- customerNo: "customerNo", // 关联客户编号(用于反查 customer_id)
- buildingName: "buildingName", // 建筑名称(占位,反查 building_id)
- floorNo: "floorNo", // 所在楼层
- location: "location", // 所在位置
- status: "status" // 1在线 0离线
+ lineCode: "detectorCode", // 探测器编码(唯一幂等键)
+ lineName: "detectorName", // 探测器名称
+ imei: "imei", // 探测器 IMEI
+ orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户,不落库)
+ parentCode: "parentCode", // 上级网关编码(→ electric_gateway.device_code 反查网关,不落库)
+ typeCode: "typeCode", // 探测器类型编码
+ typeName: "typeName", // 探测器类型名称
+ fireSystemCode: "fireSystemCode", // 消防系统编码
+ fireSystemName: "fireSystemName", // 消防系统名称
+ partUnitLoopCode: "partUnitLoopCode", // 部件/单元/回路编码
+ channelNo: "channelNo", // 通道号
+ channelSn: "channelSn", // 通道序列号
+ produceDate: "produceDate", // 生产日期
+ detectorStatus: "detectorStatus" // 上报状态 01=在线 02=离线
} as const;
-/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */
-export const ICC_LINE_ENDPOINT = "/api/grid/lines";
+/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
+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): {
lineCode: string;
- iccDeviceCode: string;
- equipCode?: string;
lineName?: string;
- customerNo?: string;
- buildingName?: string;
- floorNo?: string;
- location?: string;
+ imei?: string;
+ orgCode?: string;
+ parentCode?: string;
+ typeCode?: string;
+ typeName?: string;
+ fireSystemCode?: string;
+ fireSystemName?: string;
+ partUnitLoopCode?: string;
+ channelNo?: string;
+ channelSn?: string;
+ produceDate?: string;
status: number;
} {
const f = ICC_LINE_FIELDS;
- return {
- lineCode: String(raw[f.lineCode] ?? "").trim(),
- iccDeviceCode: String(raw[f.iccDeviceCode] ?? "").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
+ const result: Record = {
+ // 匹配键保持空串语义,由调用方判跳
+ lineCode: String(raw[f.lineCode] ?? "").trim()
};
-}
\ No newline at end of file
+ // 可选字符串字段
+ 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: [] };
+ }
+}
diff --git a/系统设计/表结构新增及变更.md b/系统设计/表结构新增及变更.md
index 03b3768..47dabbc 100644
--- a/系统设计/表结构新增及变更.md
+++ b/系统设计/表结构新增及变更.md
@@ -15,3 +15,26 @@
|
|
|
|
|
|
|
|
|
|
+智电改造线路表: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 | | 创建时间 |
+