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)
This commit is contained in:
wk authored and trae-solo-agent committed 2026-10-24 13:50:32 +08:00
1 parent cf0a0d4d31
commit 160ad85051
13 files changed
+510 -200

No files matched your search

@@ -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 依赖全部
+13 -4
View File
@@ -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 {
+29 -11
View File
@@ -16,7 +16,7 @@
<span class="main-title">智电改造线路台账</span>
<span class="sub-title">ELECTRIC LINE LEDGER</span>
</div>
<p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、设备、建筑查看线路及在线状态。</p>
<p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、网关查看线路、探测器及在线状态。</p>
</div>
</div>
</div>
@@ -47,7 +47,7 @@
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="线路名称 / 线路编码"
placeholder="线路名称 / 编码 / IMEI"
allow-clear
@pressEnter="handleSearch"
>
@@ -103,16 +103,32 @@
<span class="line-name">{{ record.lineName || '-' }}</span>
</template>
<template v-else-if="column.key === 'imei'">
<span class="mono">{{ record.imei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template>
<template v-else-if="column.key === 'equipId'">
{{ record.equipName ? `${record.equipName}(${record.equipCode})` : '-' }}
<template v-else-if="column.key === 'gatewayId'">
{{ record.gatewayName ? `${record.gatewayName}(${record.gatewayCode})` : (record.gatewayCode || '-') }}
</template>
<template v-else-if="column.key === 'buildingId'">
{{ record.buildingName || '-' }}
<template v-else-if="column.key === 'typeName'">
{{ 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 v-else-if="column.key === 'status'">
@@ -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;
}
</style>
</style>
+38 -26
View File
@@ -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;
+8
View File
@@ -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)
@@ -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;
+43 -24
View File
@@ -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;
}
}
+17 -16
View File
@@ -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<CustomerEquipment>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@@ -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<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 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;
});
}
+92 -86
View File
@@ -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<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
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<string, string> = { 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);
}
}
}
}
+91 -33
View File
@@ -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<string, any> = {
// 匹配键保持空串语义,由调用方判跳
lineCode: String(raw[f.lineCode] ?? "").trim()
};
}
// 可选字符串字段
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: [] };
}
}
+23
View File
@@ -15,3 +15,26 @@
| <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 | | 创建时间 |