feat: 新增智电改造线路台账功能(ICC拉取同步)
- 数据库:新增迁移脚本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
This commit is contained in:
1 parent
f72644c24f
commit
41765cdf44
21 files changed
+1363
-6
No files matched your search
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
@@ -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}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user