This commit is contained in:
andyesfly committed 2026-10-17 10:08:54 +08:00
commit 184b8d03e2
21 files changed
+1363 -6

No files matched your search

@@ -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 };
}
}
+11
View File
@@ -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;
}
+64
View File
@@ -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;
}
+36
View File
@@ -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;
}
+1
View File
@@ -28,6 +28,7 @@ const PERMISSION_MAP: Record<string, string> = {
"/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",
+144
View File
@@ -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<ElectricLine>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ 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<any> {
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<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 [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;
});
}
}
+205
View File
@@ -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<ElectricLine>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
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 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<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
@@ -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";
/** 任务执行结果(一次尝试) */
@@ -445,6 +446,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}`;
}
};
}
+51
View File
@@ -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
};
}
+17
View File
@@ -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();
}
};
+4 -1
View File
@@ -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<number>;
/** 执行全部启用的扫描式通知规则,返回本次执行摘要 */
runNoticeRules(): Promise<string>;
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
syncIccLines(): Promise<string>;
}
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);