From 41765cdf44ed26d6b72ac7feee29b8ee28a34231 Mon Sep 17 00:00:00 2001 From: wangkai <10903508@qq.com> Date: Mon, 12 Oct 2026 15:57:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=99=BA=E7=94=B5?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E7=BA=BF=E8=B7=AF=E5=8F=B0=E8=B4=A6=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88ICC=E6=8B=89=E5=8F=96=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库:新增迁移脚本20261011_icc_line_ledger.sql(electric_line/line_room/icc_sync_log三表、菜单61、role_menu(1,61)、定时任务icc_line_sync),同步db_seed.sql - 后端:新增ElectricLine/IccSyncLog实体、查询DTO、线路台账服务与ICC拉取同步服务、/api/electric-lines控制器,接入RBAC(device:list)与定时任务 - 前端:新增智电改造线路台账页面及路由,API封装electricLineApi - ICC同步采用本系统主动拉取模式,失败仅记日志不抛错,接口统一code/message/data格式兼容APP --- .trae/specs/add-icc-line-ledger/checklist.md | 14 + .trae/specs/add-icc-line-ledger/spec.md | 45 ++ .trae/specs/add-icc-line-ledger/tasks.md | 26 + client/src/api/device.ts | 59 ++ client/src/router/index.ts | 6 + client/src/views/device/line/index.vue | 502 ++++++++++++++++++ client/src/views/dispatch/survey/form.vue | 2 +- database/db_seed.sql | 37 +- .../migrations/20261011_icc_line_ledger.sql | 115 ++++ server/.env | 4 + .../controller/electric_line.controller.ts | 18 + server/src/dto/electric_line.dto.ts | 11 + server/src/entity/electric_line.entity.ts | 64 +++ server/src/entity/icc_sync_log.entity.ts | 36 ++ server/src/middleware/rbac.middleware.ts | 1 + server/src/service/electric_line.service.ts | 144 +++++ server/src/service/icc_line_sync.service.ts | 205 +++++++ server/src/service/job_scheduler.service.ts | 7 + server/src/task/icc_line_mapper.ts | 51 ++ server/src/task/icc_line_sync.task.ts | 17 + server/src/task/job_registry.ts | 5 +- 21 files changed, 1363 insertions(+), 6 deletions(-) create mode 100644 .trae/specs/add-icc-line-ledger/checklist.md create mode 100644 .trae/specs/add-icc-line-ledger/spec.md create mode 100644 .trae/specs/add-icc-line-ledger/tasks.md create mode 100644 client/src/views/device/line/index.vue create mode 100644 database/migrations/20261011_icc_line_ledger.sql create mode 100644 server/src/controller/electric_line.controller.ts create mode 100644 server/src/dto/electric_line.dto.ts create mode 100644 server/src/entity/electric_line.entity.ts create mode 100644 server/src/entity/icc_sync_log.entity.ts create mode 100644 server/src/service/electric_line.service.ts create mode 100644 server/src/service/icc_line_sync.service.ts create mode 100644 server/src/task/icc_line_mapper.ts create mode 100644 server/src/task/icc_line_sync.task.ts diff --git a/.trae/specs/add-icc-line-ledger/checklist.md b/.trae/specs/add-icc-line-ledger/checklist.md new file mode 100644 index 0000000..7f5a1f8 --- /dev/null +++ b/.trae/specs/add-icc-line-ledger/checklist.md @@ -0,0 +1,14 @@ +# Checklist + +- [x] electric_line / line_room / icc_sync_log 表存在(生产库 + db_seed.sql 一致,幂等) +- [x] ElectricLine / IccSyncLog 实体与 DTO 实现,ElectricLine 仅 created_at(timestamps:false) +- [x] 列表查询使用参数绑定(Op.like/Op.in),无字符串拼接 SQL +- [x] GET /api/electric-lines:无 token 401、有 device:list 权限 200 返回 code/message/data 结构 +- [x] POST /api/electric-lines/sync:JWT+RBAC 鉴权;定时任务服务内直调 +- [x] IccLineSyncService 读 ICC_BASE_URL/ICC_TOKEN 占位配置,按 line_code upsert + 写 icc_sync_log;ICC 拉取失败记录 error 不抛业务错 +- [x] icc_line_sync 定时任务已注册(@midwayjs/cron) +- [x] RBAC 映射含 /api/electric-lines → device:list +- [x] 前端 /device/line 页面渲染(搜索/同步按钮/表格/分页/状态 tag),无 v-html +- [x] 菜单 id=61 挂设备监控(4) 且 role_menu 绑定,db_seed.sql 与生产库一致 +- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过 +- [x] 浏览器端登录后菜单可见、页面可用、同步按钮可用 diff --git a/.trae/specs/add-icc-line-ledger/spec.md b/.trae/specs/add-icc-line-ledger/spec.md new file mode 100644 index 0000000..4f3ab67 --- /dev/null +++ b/.trae/specs/add-icc-line-ledger/spec.md @@ -0,0 +1,45 @@ +# 智电改造线路台帐(设备监控)Spec + +## Why +设备监控模块缺少"智电改造线路"台账。线路数据由 ICC 平台同步到本系统,运营侧需按客户/设备/建筑查看线路及状态。需在既有设备模块最小套路基础上新增后端接口(含鉴权与防注入)与前端页面,并兼容 APP。 + +## What Changes +- **新增** `electric_line` 表后端实体/DTO/Service/Controller(查询接口 `GET /api/electric-lines` 列表 + 详情,手动同步 `POST /api/electric-lines/sync`) +- **新增** ICC 拉取同步:`IccLineSyncService` 从 `.env`(`ICC_BASE_URL`/`ICC_TOKEN`)读取占位配置,请求 ICC 线路列表接口(路径与字段映射集中定义),幂等 upsert 写入 `electric_line`,记录 `icc_sync_log` +- **新增** 定时任务:注册 `icc_line_sync.task`(@midwayjs/cron,周期默认 10 分钟,可配置),服务内直调拉取 +- **新增** RBAC 映射:`/api/electric-lines → device:list` +- **新增** 前端"智电改造线路台账"页面(`views/device/line/index.vue`),路由 `/device/line`,含"同步线路"手动按钮;菜单挂设备监控目录(parent=4,新 id=61) +- **同步** db_seed.sql 与迁移文件(建表 + 菜单 + role_menu),保持幂等一致 +- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`(兼容 APP) + +## Impact +- Affected specs: 设备监控(设备台账、智电设备型号)、ICC 数据同步、RBAC 权限 +- Affected code: server/src(entity/dto/service/controller/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/) + +## ADDED Requirements +### Requirement: 线路台账查询(JWT+RBAC) +系统 SHALL 提供线路台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/building_id/status/关键词(line_name/line_code)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。 +#### Scenario: 成功查询 +- **WHEN** 已登录用户 GET `/api/electric-lines?page=1&pageSize=10&customerId=1` +- **THEN** 返回 `code=0,data{list,total}`,含线路编码/名称/所属设备/客户/建筑/楼层/位置/状态/同步时间;无 token 返回 401,无权限返回 403 + +### Requirement: ICC 拉取同步(占位配置 + 定时 + 手动) +系统 SHALL 提供 ICC 线路拉取同步能力:`IccLineSyncService` 读取 `.env` 的 `ICC_BASE_URL`/`ICC_TOKEN`(占位,字段映射集中在 `icc_line_mapper`),向 ICC 拉取线路列表,按 `line_code` upsert(幂等)写入 `electric_line`,更新 `synced_at`,写 `icc_sync_log`(status/record_count/error_message)。同步入口:① 定时任务(@midwayjs/cron,默认每 10 分钟,服务内直调);② 手动 `POST /api/electric-lines/sync`(JWT+RBAC `device:list`)。 +#### Scenario: 定时/手动拉取成功 +- **WHEN** 定时任务触发或运营点击"同步线路" +- **THEN** 拉取 ICC 数据并 upsert 对应线路行,`icc_sync_log` 记录成功;返回 `code=0,data{syncedCount,updatedCount}` +#### Scenario: 手动同步鉴权失败 +- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-lines/sync` +- **THEN** 返回 401/403,不触发拉取 +#### Scenario: ICC 拉取失败 +- **WHEN** ICC 地址不可达或返回异常 +- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错 + +### Requirement: 前端线路台账页 +系统 SHALL 提供 `/device/line` 页面:搜索栏(线路名称/编码、客户、状态)、"同步线路"按钮(调 `/api/electric-lines/sync`,成功后刷新)、表格(编码/名称/客户/设备/建筑/楼层/位置/状态 tag/同步时间)、分页;样式与设备台账一致,全部文本经 Vue 插值转义。 +#### Scenario: 打开页面 +- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造线路台账 +- **THEN** 渲染列表并展示分页与筛选 + +## MODIFIED Requirements +(无既有功能被修改) diff --git a/.trae/specs/add-icc-line-ledger/tasks.md b/.trae/specs/add-icc-line-ledger/tasks.md new file mode 100644 index 0000000..e47de8d --- /dev/null +++ b/.trae/specs/add-icc-line-ledger/tasks.md @@ -0,0 +1,26 @@ +# Tasks + +- [ ] Task 1: 数据库层:新建 migration 建 `electric_line`(按 db_design.html:575-589 字段)、`line_room`、`icc_sync_log`(若不存在),并把建表语句同步进 db_seed.sql;幂等(IF NOT EXISTS / 增量) + - [ ] SubTask 1.1: 核对生产库是否已有 electric_line/line_room/icc_sync_log,避免重复 + - [ ] SubTask 1.2: 写 migration(migrations/20261011_icc_line_ledger.sql)并执行到远程生产库 +- [ ] Task 2: 后端实体+DTO:新增 `ElectricLine` entity(timestamps:false,仅 created_at)、`IccSyncLog` entity;`electric_line.dto.ts` 定义查询/同步入参(line_code 必填、字段校验、长度限制) +- [ ] Task 3: 后端 Service: + - [ ] SubTask 3.1: `ElectricLineService.list(page, filter)`(Op.like/Op.in 参数绑定,非 ADMIN 限定 customer_id) + - [ ] SubTask 3.2: `IccLineSyncService.syncFromIcc()`:读 `.env` 的 ICC_BASE_URL/ICC_TOKEN 占位配置,请求 ICC 线路列表(路径与字段映射集中在 `icc_line_mapper`),按 line_code 批量 upsert + 写 icc_sync_log + 失败捕获不抛业务错误 +- [ ] Task 4: 后端 Controller + RBAC: + - [ ] SubTask 4.1: `electric_line.controller.ts`:GET /api/electric-lines(列表/详情)、POST /api/electric-lines/sync(调 IccLineSyncService) + - [ ] SubTask 4.2: rbac.middleware.ts 追加 `/api/electric-lines → device:list` + - [ ] SubTask 4.3: `.env` 增加 ICC_BASE_URL、ICC_TOKEN(占位);定时任务注册 `icc_line_sync.task`(@midwayjs/cron,默认每 10 分钟) +- [ ] Task 5: 前端 API:`client/src/api/device.ts`(或新 `line.ts`)新增 `electricLineApi.list/get/sync` +- [ ] Task 6: 前端页面:`views/device/line/index.vue`(搜索+同步按钮+表格+分页+状态 tag),路由 `/device/line`(permission device:list),菜单 id=61 挂设备监控(4) + role_menu(1,61),同步 db_seed.sql +- [ ] Task 7: 验证: + - [ ] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查 + - [ ] SubTask 7.2: 启动后端/前端,接口实测(无 token 401、有权限 200、无权限 403、sync 接口鉴权、ICC 拉取失败不报错) + - [ ] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用 + +# Task Dependencies +- Task 2/5 依赖 Task 1(表存在) +- Task 3 依赖 Task 2 +- Task 4 依赖 Task 3 +- Task 6 依赖 Task 5 +- Task 7 依赖全部 diff --git a/client/src/api/device.ts b/client/src/api/device.ts index 98f1a1c..085e9eb 100644 --- a/client/src/api/device.ts +++ b/client/src/api/device.ts @@ -143,4 +143,63 @@ export const smartDeviceApi = { delete: (id: number) => request.delete(`/smart-devices/${id}`), +}; + +// ==================== 智电改造线路台账 ==================== + +export interface ElectricLine { + id: number; + lineCode: string; + equipId: number; + iccDeviceCode?: string; + lineName?: string; + customerId: number; + buildingId?: number; + floorNo?: string; + location?: string; + status: number; + syncedAt?: string; + createdAt: string; + // 联表补全字段 + customerNo?: string; + customerName?: string; + equipCode?: string; + equipName?: string; + buildingName?: string; +} + +export interface ElectricLineQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + equipId?: number; + buildingId?: number; + status?: number; +} + +export interface ElectricLineListResult { + list: ElectricLine[]; + total: number; + page: number; + pageSize: number; +} + +export interface IccLineSyncResult { + pulled: number; + synced: number; + inserted: number; + updated: number; + skipped: number; +} + +export const electricLineApi = { + getList: (params: ElectricLineQuery) => + request.get('/electric-lines', { params }), + + getDetail: (id: number) => + request.get(`/electric-lines/${id}`), + + sync: () => + request.post('/electric-lines/sync'), }; \ No newline at end of file diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 15fccd0..f1897b5 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -186,6 +186,12 @@ const routes: RouteRecordRaw[] = [ name: "DeviceEquipment", component: () => import("@/views/device/equipment/index.vue"), meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" } + }, + { + path: "device/line", + name: "DeviceLine", + component: () => import("@/views/device/line/index.vue"), + meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" } }, { path: "base", diff --git a/client/src/views/device/line/index.vue b/client/src/views/device/line/index.vue new file mode 100644 index 0000000..25b285c --- /dev/null +++ b/client/src/views/device/line/index.vue @@ -0,0 +1,502 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/dispatch/survey/form.vue b/client/src/views/dispatch/survey/form.vue index 59e0901..5fe5f87 100644 --- a/client/src/views/dispatch/survey/form.vue +++ b/client/src/views/dispatch/survey/form.vue @@ -911,7 +911,7 @@ async function handleSubmit(draft: boolean) { } onMounted(async () => { - // 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey/create?customerId=xx + // 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx const preCustomerId = Number(route.query.customerId || 0); if (preCustomerId && !isEdit.value) { formState.customerId = preCustomerId; diff --git a/database/db_seed.sql b/database/db_seed.sql index a5f83ce..7d026ed 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -322,13 +322,34 @@ ON CONFLICT (id) DO UPDATE SET permission = EXCLUDED.permission, visible = EXCLUDED.visible; --- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-60 +-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-61 INSERT INTO sys_role_menu (role_id, menu_id) VALUES -(1, 58), (1, 59), (1, 60) +(1, 58), (1, 59), (1, 60), (1, 61) ON CONFLICT (role_id, menu_id) DO NOTHING; --- 重置 sys_menu 自增序列(覆盖到 60) -SELECT setval('sys_menu_id_seq', 60, true); +-- 重置 sys_menu 自增序列(覆盖到 61) +SELECT setval('sys_menu_id_seq', 61, true); + +-- ------------------------------------------------------------ +-- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4) +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 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)拥有 61 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 61) +ON CONFLICT (role_id, menu_id) DO NOTHING; -- ------------------------------------------------------------ -- 9. 定时任务种子(sys_job,按 job_key 幂等) @@ -375,6 +396,14 @@ INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, re '遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)') ON CONFLICT (job_key) DO NOTHING; +-- ------------------------------------------------------------ +-- 10a. 智电线路同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟) +-- ------------------------------------------------------------ +INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES +('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1, + '从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)') +ON CONFLICT (job_key) DO NOTHING; + -- ------------------------------------------------------------ -- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整) -- ------------------------------------------------------------ diff --git a/database/migrations/20261011_icc_line_ledger.sql b/database/migrations/20261011_icc_line_ledger.sql new file mode 100644 index 0000000..e3123ec --- /dev/null +++ b/database/migrations/20261011_icc_line_ledger.sql @@ -0,0 +1,115 @@ +-- 智电改造线路台账(设备监控): +-- 1) 幂等建表 electric_line / line_room / icc_sync_log(若 db_init.sql 已建则跳过) +-- 2) 设备监控(id=4)下新增菜单 61「智电改造线路台账」 +-- 3) ADMIN 角色绑定新菜单 +-- 4) 注册 icc_line_sync 定时任务(默认每 10 分钟) +BEGIN; + +-- ------------------------------------------------------------ +-- 1) 幂等建表(与 db_init.sql 定义保持一致) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS 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() +); +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 '创建时间'; +CREATE INDEX IF NOT EXISTS idx_line_customer ON electric_line (customer_id); +CREATE INDEX IF NOT EXISTS idx_line_equip ON electric_line (equip_id); +CREATE INDEX IF NOT EXISTS idx_line_icc ON electric_line (icc_device_code); + +CREATE TABLE IF NOT EXISTS line_room ( + id BIGSERIAL PRIMARY KEY, + line_id BIGINT NOT NULL REFERENCES electric_line(id) ON DELETE CASCADE, + room_id BIGINT NOT NULL REFERENCES customer_room(id), + customer_id BIGINT NOT NULL REFERENCES customer(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE line_room IS '线路所属房间关联表'; +COMMENT ON COLUMN line_room.id IS '自增主键'; +COMMENT ON COLUMN line_room.line_id IS '线路 ID(→ electric_line)'; +COMMENT ON COLUMN line_room.room_id IS '房间 ID(→ customer_room)'; +COMMENT ON COLUMN line_room.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN line_room.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_lr_line ON line_room (line_id); +CREATE INDEX IF NOT EXISTS idx_lr_room ON line_room (room_id); +CREATE INDEX IF NOT EXISTS idx_lr_customer ON line_room (customer_id); + +CREATE TABLE IF NOT EXISTS icc_sync_log ( + id BIGSERIAL PRIMARY KEY, + sync_type VARCHAR(32) NOT NULL, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + record_count INT, + status SMALLINT NOT NULL DEFAULT 1, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE icc_sync_log IS 'ICC 数据同步日志表'; +COMMENT ON COLUMN icc_sync_log.id IS '自增主键'; +COMMENT ON COLUMN icc_sync_log.sync_type IS '同步类型:客户/设备/用电/报警'; +COMMENT ON COLUMN icc_sync_log.start_time IS '开始时间'; +COMMENT ON COLUMN icc_sync_log.end_time IS '结束时间'; +COMMENT ON COLUMN icc_sync_log.record_count IS '同步记录数'; +COMMENT ON COLUMN icc_sync_log.status IS '状态:1成功 0失败'; +COMMENT ON COLUMN icc_sync_log.error_message IS '错误信息'; +COMMENT ON COLUMN icc_sync_log.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_icc_sync_type ON icc_sync_log (sync_type); +CREATE INDEX IF NOT EXISTS idx_icc_sync_time ON icc_sync_log (created_at); + +-- ------------------------------------------------------------ +-- 2) 设备监控(id=4)下新增菜单 61「智电改造线路台账」(sort 2,排在设备管理台账之后) +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 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) 拥有 61 +-- ------------------------------------------------------------ +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 61) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- ------------------------------------------------------------ +-- 4) 定时任务种子:icc_line_sync(默认每 10 分钟,可按 job_key 幂等) +-- ------------------------------------------------------------ +INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES +('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1, + '从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)') +ON CONFLICT (job_key) DO NOTHING; + +-- 重置序列(覆盖到 61) +SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 61), true); + +COMMIT; diff --git a/server/.env b/server/.env index 8257922..dcd47ed 100644 --- a/server/.env +++ b/server/.env @@ -22,3 +22,7 @@ RUSTFS_BUCKET=xintong REDIS_HOST=222.243.55.42 REDIS_PORT=26678 REDIS_PASSWORD= + +# ICC 平台配置(结算所述智电改造线路数据由 ICC 同步,占位;接入真实平台时填写) +ICC_BASE_URL= +ICC_TOKEN= diff --git a/server/src/controller/electric_line.controller.ts b/server/src/controller/electric_line.controller.ts new file mode 100644 index 0000000..274ff1c --- /dev/null +++ b/server/src/controller/electric_line.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Post, Inject, Query, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { ElectricLineService } from "../service/electric_line.service"; +import { IccLineSyncService } from "../service/icc_line_sync.service"; + +@Controller("/api/electric-lines") +export class ElectricLineController { + @Inject() ctx!: Context; + @Inject() electricLineService!: ElectricLineService; + @Inject() iccLineSyncService!: IccLineSyncService; + + @Get("/") async list(@Query() query: any) { return this.electricLineService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.electricLineService.detail(Number(id), this.ctx); } + @Post("/sync") async sync() { + const result = await this.iccLineSyncService.syncFromIcc(this.ctx); + return { code: 200, message: "线路同步完成", data: result }; + } +} \ No newline at end of file diff --git a/server/src/dto/electric_line.dto.ts b/server/src/dto/electric_line.dto.ts new file mode 100644 index 0000000..3e9f21f --- /dev/null +++ b/server/src/dto/electric_line.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator"; + +export class ElectricLineQueryDTO { + @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() buildingId?: number; + @IsOptional() @IsInt() @Min(0) @Max(1) status?: number; +} \ No newline at end of file diff --git a/server/src/entity/electric_line.entity.ts b/server/src/entity/electric_line.entity.ts new file mode 100644 index 0000000..49279a9 --- /dev/null +++ b/server/src/entity/electric_line.entity.ts @@ -0,0 +1,64 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import CustomerEquipment from "./customer_equipment.entity"; +import Customer from "./customer.entity"; +import CustomerBuilding from "./customer_building.entity"; + +@Table({ + tableName: "electric_line", + comment: "智电改造线路信息表(ICC 同步)", + timestamps: false, + indexes: [ + { name: "idx_line_customer", fields: ["customer_id"] }, + { name: "idx_line_equip", fields: ["equip_id"] }, + { name: "idx_line_icc", fields: ["icc_device_code"] } + ] +}) +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: "线路编码(唯一)" }) + 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 同步)" }) + lineName!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" }) + customerId!: number; + + @ForeignKey(() => CustomerBuilding) + @Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" }) + buildingId!: number; + + @Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" }) + floorNo!: string; + + @Column({ type: DataType.STRING(256), comment: "所在位置" }) + location!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在线 0离线" }) + status!: number; + + @Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" }) + syncedAt!: Date; + + @BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" }) + equipment!: CustomerEquipment; + + @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/entity/icc_sync_log.entity.ts b/server/src/entity/icc_sync_log.entity.ts new file mode 100644 index 0000000..a0cb6d7 --- /dev/null +++ b/server/src/entity/icc_sync_log.entity.ts @@ -0,0 +1,36 @@ +import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"; + +@Table({ + tableName: "icc_sync_log", + comment: "ICC 数据同步日志表", + timestamps: false, + indexes: [ + { name: "idx_icc_sync_type", fields: ["sync_type"] }, + { name: "idx_icc_sync_time", fields: ["created_at"] } + ] +}) +export default class IccSyncLog extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(32), allowNull: false, field: "sync_type", comment: "同步类型:客户/设备/用电/报警" }) + syncType!: string; + + @Column({ type: DataType.DATE, field: "start_time", comment: "开始时间" }) + startTime!: Date; + + @Column({ type: DataType.DATE, field: "end_time", comment: "结束时间" }) + endTime!: Date; + + @Column({ type: DataType.INTEGER, field: "record_count", comment: "同步记录数" }) + recordCount!: number; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1成功 0失败" }) + status!: number; + + @Column({ type: DataType.TEXT, field: "error_message", comment: "错误信息" }) + errorMessage!: string; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index c436617..448cfb7 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -28,6 +28,7 @@ const PERMISSION_MAP: Record = { "/api/customer-circuits": "customer:topology", "/api/customer-equipments": "device:list", "/api/smart-devices": "device:model", + "/api/electric-lines": "device:list", "/api/survey-orders": "project:survey", "/api/project-plans": "project:plan", "/api/contracts": "project:contract", diff --git a/server/src/service/electric_line.service.ts b/server/src/service/electric_line.service.ts new file mode 100644 index 0000000..9296e14 --- /dev/null +++ b/server/src/service/electric_line.service.ts @@ -0,0 +1,144 @@ +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 ElectricLine from "../entity/electric_line.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"; + +@Provide() +export class ElectricLineService { + @InjectRepository(ElectricLine) + lineModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(CustomerEquipment) + equipmentModel!: Repository; + + @InjectRepository(CustomerBuilding) + buildingModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + 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 { + 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] = [ + { lineName: { [Op.like]: `%${query.keyword}%` } }, + { lineCode: { [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); + + // 非管理员按客户归属自动限定数据范围 + 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.lineModel.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 { + const record = await this.lineModel.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: 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 [customers, equips, buildings] = 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([]), + buildingIds.length + ? this.buildingModel.findAll({ + where: { id: { [Op.in]: buildingIds } }, + attributes: ["id", "buildingName"] + }) + : 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])); + + 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; + json.customerNo = customer?.customerNo || ""; + json.customerName = customer?.customerName || ""; + json.equipCode = equip?.equipCode || ""; + json.equipName = equip?.equipName || ""; + json.buildingName = building?.buildingName || ""; + return json; + }); + } +} \ No newline at end of file diff --git a/server/src/service/icc_line_sync.service.ts b/server/src/service/icc_line_sync.service.ts new file mode 100644 index 0000000..39f827d --- /dev/null +++ b/server/src/service/icc_line_sync.service.ts @@ -0,0 +1,205 @@ +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 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"; + +export interface IccLineSyncResult { + /** ICC 拉取到的原始条数 */ + pulled: number; + /** 解析落库的线路条数(成功 upsert,含新增/更新) */ + synced: number; + /** 新增的线路条数 */ + inserted: number; + /** 更新的线路条数 */ + updated: number; + /** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */ + skipped: number; +} + +const SYNC_TYPE = "electric_line"; + +/** + * 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,不抛业务错误 + */ +@Provide() +export class IccLineSyncService { + @InjectRepository(ElectricLine) + lineModel!: Repository; + + @InjectRepository(IccSyncLog) + syncLogModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(CustomerEquipment) + equipmentModel!: Repository; + + @InjectRepository(CustomerBuilding) + buildingModel!: 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 res = await fetch(url, { + method: "GET", + headers, + signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s + }); + + if (!res.ok) { + throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`); + } + + 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); + 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?.code === "ERR_MODULE_NOT_FOUND" || err?.message + ? String(err?.message || err) + : String(err); + await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message); + return result; + } + } + + /** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */ + private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> { + let inserted = 0; + let updated = 0; + let skipped = 0; + + 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))]; + + 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])); + + const now = new Date(); + + for (const raw of records) { + const mapped = mapIccLineRecord(raw); + if (!mapped.lineCode) { + skipped++; + continue; + } + + const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined; + // equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行 + if (!equip) { + 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; + } + + 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, + status: mapped.status, + syncedAt: now + } as any; + + const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } }); + if (existing) { + await existing.update(payload); + updated++; + } else { + await this.lineModel.create(payload); + inserted++; + } + } + + return { inserted, updated, skipped }; + } + + private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise { + try { + await this.syncLogModel.create({ + syncType, + startTime, + endTime: new Date(), + recordCount, + status, + errorMessage + } as any); + } catch (err) { + console.error("[ICC同步] 同步日志写入失败:", err); + } + } +} \ No newline at end of file diff --git a/server/src/service/job_scheduler.service.ts b/server/src/service/job_scheduler.service.ts index fb27396..6b51757 100644 --- a/server/src/service/job_scheduler.service.ts +++ b/server/src/service/job_scheduler.service.ts @@ -8,6 +8,7 @@ import SysJob from "../entity/sys_job.entity"; import SysJobLog from "../entity/sys_job_log.entity"; import { LogService } from "./log.service"; import { NoticeRuleService } from "./notice_rule.service"; +import { IccLineSyncService } from "./icc_line_sync.service"; import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry"; /** 任务执行结果(一次尝试) */ @@ -430,6 +431,12 @@ export class JobSchedulerService { const ctx = this.app.createAnonymousContext(); const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService; return noticeRuleService.executeScan(); + }, + syncIccLines: async () => { + const ctx = this.app.createAnonymousContext(); + const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService; + const result = await iccService.syncFromIcc(); + return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`; } }; } diff --git a/server/src/task/icc_line_mapper.ts b/server/src/task/icc_line_mapper.ts new file mode 100644 index 0000000..2ede236 --- /dev/null +++ b/server/src/task/icc_line_mapper.ts @@ -0,0 +1,51 @@ +/** + * ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整) + * + * ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射, + * 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。 + */ +export interface IccLineRawRecord { + [field: string]: any; +} + +/** 原始记录关键字段名(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离线 +} as const; + +/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */ +export const ICC_LINE_ENDPOINT = "/api/grid/lines"; + +/** 将 ICC 原始记录映射为本系统 electric_line 落库字段(不含联表外键)。 */ +export function mapIccLineRecord(raw: IccLineRawRecord): { + lineCode: string; + iccDeviceCode: string; + equipCode?: string; + lineName?: string; + customerNo?: string; + buildingName?: string; + floorNo?: string; + location?: 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 + }; +} \ No newline at end of file diff --git a/server/src/task/icc_line_sync.task.ts b/server/src/task/icc_line_sync.task.ts new file mode 100644 index 0000000..342e449 --- /dev/null +++ b/server/src/task/icc_line_sync.task.ts @@ -0,0 +1,17 @@ +import type { BuiltinJob } from "./job_registry"; + +/** + * 智电线路同步任务:从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert, + * 写 icc_sync_log。默认每 10 分钟执行一次。 + */ +export const iccLineSyncJob: BuiltinJob = { + jobKey: "icc_line_sync", + jobName: "智电线路同步", + description: "从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(写 icc_sync_log)", + defaultCron: "*/10 * * * *", + defaultParams: {}, + paramFields: [], + handler: async (_params, ctx) => { + return ctx.runtime.syncIccLines(); + } +}; \ No newline at end of file diff --git a/server/src/task/job_registry.ts b/server/src/task/job_registry.ts index a7320bf..91b76c0 100644 --- a/server/src/task/job_registry.ts +++ b/server/src/task/job_registry.ts @@ -1,6 +1,7 @@ import { demoHeartbeatJob } from "./demo_heartbeat.task"; import { logCleanJob } from "./log_clean.task"; import { noticeScanJob } from "./notice_scan.task"; +import { iccLineSyncJob } from "./icc_line_sync.task"; export type JobParamType = "number" | "string"; @@ -29,6 +30,8 @@ export interface JobRuntime { cleanJobLogs(keepDays: number): Promise; /** 执行全部启用的扫描式通知规则,返回本次执行摘要 */ runNoticeRules(): Promise; + /** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */ + syncIccLines(): Promise; } export interface JobHandlerContext { @@ -55,7 +58,7 @@ export interface BuiltinJob { handler: JobHandler; } -export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob]; +export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob]; export function getBuiltinJob(jobKey: string): BuiltinJob | undefined { return BUILTIN_JOBS.find(job => job.jobKey === jobKey);