Files
smart_grid_iot/server/src/service/notice_rule.service.ts
T
andyesfly 6cf6a3d5b9 feat: 新增可配置通知中心,支持扫描式/埋点式通知规则与手动群发
新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
2026-10-06 11:53:02 +08:00

481 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import dayjs from "dayjs";
import SysNoticeRule from "../entity/sys_notice_rule.entity";
import Worker from "../entity/worker.entity";
import WorkerCert from "../entity/worker_cert.entity";
import Contract from "../entity/contract.entity";
import Customer from "../entity/customer.entity";
import CustomerVisit from "../entity/customer_visit.entity";
import RepairDispatch from "../entity/repair_dispatch.entity";
import SurveyOrder from "../entity/survey_order.entity";
import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import { NoticeService, ReceiverConfig } from "./notice.service";
import {
NoticeRuleQueryDTO,
NoticeRuleCreateDTO,
NoticeRuleUpdateDTO
} from "../dto/notice_rule.dto";
import { NOTICE_EVENTS, NoticeScanItem, getNoticeEvent } from "../task/notice_event_registry";
const MAX_PAGE_SIZE = 200;
/** 埋点式事件入参 */
export interface RunEventParams {
bizType?: string | null;
bizId?: number | null;
vars: Record<string, any>;
/** workers.mode=event 时的目标电工 */
eventWorkerId?: number | null;
}
@Provide()
export class NoticeRuleService {
@InjectRepository(SysNoticeRule)
ruleModel!: Repository<SysNoticeRule>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(WorkerCert)
certModel!: Repository<WorkerCert>;
@InjectRepository(Contract)
contractModel!: Repository<Contract>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerVisit)
visitModel!: Repository<CustomerVisit>;
@InjectRepository(RepairDispatch)
repairDispatchModel!: Repository<RepairDispatch>;
@InjectRepository(SurveyOrder)
surveyOrderModel!: Repository<SurveyOrder>;
@InjectRepository(InstallOrder)
installOrderModel!: Repository<InstallOrder>;
@InjectRepository(MaintenanceOrder)
maintenanceModel!: Repository<MaintenanceOrder>;
@Inject()
noticeService!: NoticeService;
/** 扫描式事件 → 扫描实现(按 advanceDays 扫描,返回命中行) */
private scanProviders: Record<string, (advanceDays: number) => Promise<NoticeScanItem[]>> = {
cert_expire: (d) => this.scanCertExpire(d),
contract_expire: (d) => this.scanContractExpire(d),
visit_plan: (d) => this.scanVisitPlan(d),
order_timeout: (d) => this.scanOrderTimeout(d)
};
// ==================== 规则 CRUD ====================
async list(query: NoticeRuleQueryDTO): Promise<any> {
const page = Math.max(Number(query.page) || 1, 1);
const pageSize = Math.min(Math.max(Number(query.pageSize) || 10, 1), MAX_PAGE_SIZE);
const where: any = {};
if (query.keyword) {
where[Op.or as any] = [
{ ruleKey: { [Op.like]: `%${query.keyword}%` } },
{ ruleName: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.eventKey) where.eventKey = query.eventKey;
if (query.triggerMode !== undefined && `${query.triggerMode}` !== "") where.triggerMode = Number(query.triggerMode);
if (query.enabled !== undefined && `${query.enabled}` !== "") where.enabled = Number(query.enabled);
const { count, rows } = await this.ruleModel.findAndCountAll({
where,
order: [["id", "ASC"]],
limit: pageSize,
offset: (page - 1) * pageSize
});
return {
code: 200,
message: "获取通知规则列表成功",
data: { list: rows.map(r => this.toRuleView(r)), total: count, page, pageSize }
};
}
async detail(id: number): Promise<any> {
const rule = await this.ruleModel.findByPk(id);
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
return { code: 200, message: "获取通知规则成功", data: this.toRuleView(rule) };
}
async create(dto: NoticeRuleCreateDTO): Promise<any> {
const event = getNoticeEvent(dto.eventKey);
if (!event) return { code: 400, message: "事件不存在", data: null };
const exists = await this.ruleModel.findOne({ where: { ruleKey: dto.ruleKey } as any });
if (exists) return { code: 400, message: "规则键已存在", data: null };
const created = await this.ruleModel.create({
ruleKey: dto.ruleKey,
ruleName: dto.ruleName,
eventKey: dto.eventKey,
triggerMode: dto.triggerMode,
enabled: dto.enabled ?? 1,
advanceDays: dto.advanceDays ?? null,
titleTpl: dto.titleTpl,
contentTpl: dto.contentTpl,
receiverConfig: (dto.receiverConfig ?? {}) as any,
remark: dto.remark ?? null
} as any);
return { code: 200, message: "新增通知规则成功", data: this.toRuleView(created) };
}
async update(id: number, dto: NoticeRuleUpdateDTO): Promise<any> {
const rule = await this.ruleModel.findByPk(id);
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
if (dto.eventKey !== undefined && !getNoticeEvent(dto.eventKey)) {
return { code: 400, message: "事件不存在", data: null };
}
const payload: any = {};
if (dto.ruleName !== undefined) payload.ruleName = dto.ruleName;
if (dto.eventKey !== undefined) payload.eventKey = dto.eventKey;
if (dto.triggerMode !== undefined) payload.triggerMode = dto.triggerMode;
if (dto.enabled !== undefined) payload.enabled = dto.enabled;
if (dto.advanceDays !== undefined) payload.advanceDays = dto.advanceDays;
if (dto.titleTpl !== undefined) payload.titleTpl = dto.titleTpl;
if (dto.contentTpl !== undefined) payload.contentTpl = dto.contentTpl;
if (dto.receiverConfig !== undefined) payload.receiverConfig = dto.receiverConfig;
if (dto.remark !== undefined) payload.remark = dto.remark;
await rule.update(payload);
return { code: 200, message: "更新通知规则成功", data: this.toRuleView(rule) };
}
async changeEnabled(id: number, enabled: number): Promise<any> {
const rule = await this.ruleModel.findByPk(id);
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
await rule.update({ enabled });
return { code: 200, message: enabled === 1 ? "规则已启用" : "规则已停用", data: null };
}
async remove(id: number): Promise<any> {
const rule = await this.ruleModel.findByPk(id);
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
await rule.destroy();
return { code: 200, message: "删除通知规则成功", data: null };
}
/** 事件字典(供 Web 下拉与模板变量提示) */
async eventOptions(): Promise<any> {
return {
code: 200,
message: "获取事件字典成功",
data: NOTICE_EVENTS.map(e => ({
...e,
triggerModeText: e.triggerMode === 1 ? "扫描式" : "埋点式"
}))
};
}
/** 手动触发一次扫描(Web「立即执行」) */
async runScanNow(): Promise<any> {
const summary = await this.executeScan();
return { code: 200, message: "扫描执行完成", data: { summary } };
}
// ==================== 触发执行 ====================
/** 扫描式统一入口:遍历启用的扫描式规则并生成通知(供 notice_scan 任务调用) */
async executeScan(): Promise<string> {
const rules = await this.ruleModel.findAll({ where: { enabled: 1, triggerMode: 1 } as any });
let totalCreated = 0;
const details: string[] = [];
for (const rule of rules) {
const provider = this.scanProviders[rule.eventKey];
if (!provider) {
details.push(`${rule.ruleKey}:无扫描实现`);
continue;
}
try {
const items = await provider.call(this, Number(rule.advanceDays) || 0);
let created = 0;
for (const item of items) {
created += await this.dispatchRule(
rule,
item.vars,
{ noticeType: rule.eventKey, bizType: item.bizType, bizId: item.bizId },
item.workerId ?? undefined
);
}
totalCreated += created;
details.push(`${rule.ruleKey}:命中 ${items.length} 条/新增 ${created} 条`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
details.push(`${rule.ruleKey}:失败(${msg})`);
}
}
return `扫描 ${rules.length} 条规则,新增通知 ${totalCreated} 条(${details.join(";") || "无"})`;
}
/**
* 埋点式统一入口:业务代码在关键动作处调用。
* 返回新增通知条数;调用方须 try/catch,通知失败不得阻断业务。
*/
async runForEvent(eventKey: string, params: RunEventParams): Promise<number> {
const rules = await this.ruleModel.findAll({
where: { eventKey, enabled: 1, triggerMode: 2 } as any
});
let created = 0;
for (const rule of rules) {
created += await this.dispatchRule(
rule,
params.vars,
{
noticeType: eventKey,
bizType: params.bizType ?? null,
bizId: params.bizId ?? null
},
params.eventWorkerId ?? undefined
);
}
return created;
}
/** 按规则模板渲染 + 接收人解析 + 幂等投递 */
private async dispatchRule(
rule: SysNoticeRule,
vars: Record<string, any>,
base: { noticeType: string; bizType?: string | null; bizId?: number | null },
eventWorkerId?: number
): Promise<number> {
const title = this.renderTemplate(rule.titleTpl, vars);
const content = this.renderTemplate(rule.contentTpl, vars);
return this.noticeService.deliverToReceivers(
{
noticeType: base.noticeType,
title,
content,
bizType: base.bizType ?? null,
bizId: base.bizId ?? null,
ruleId: Number(rule.id)
},
(rule.receiverConfig || null) as ReceiverConfig | null,
{ eventWorkerId }
);
}
/**
* 派单通知(埋点式统一入口)。
* 业务方只需传订单信息与电工,客户名称由本方法补齐;通知失败由调用方 try/catch 兜底。
*/
async notifyDispatchAssign(params: {
orderTypeName: string;
orderNo: string;
customerId: number;
workerId: number;
detailText?: string;
bizType: string;
bizId: number;
}): Promise<number> {
if (!params.workerId) return 0;
let customerName = "未知客户";
const customer = await this.customerModel.findByPk(params.customerId, { attributes: ["id", "customerName"] });
if (customer) customerName = customer.customerName;
return this.runForEvent("dispatch_assign", {
vars: {
orderTypeName: params.orderTypeName,
orderNo: params.orderNo,
customerName,
detailText: params.detailText || ""
},
bizType: params.bizType,
bizId: params.bizId,
eventWorkerId: Number(params.workerId)
});
}
/** 模板渲染:{变量} 占位符替换,未提供变量替换为空串 */
private renderTemplate(tpl: string, vars: Record<string, any>): string {
if (!tpl) return "";
return tpl.replace(/\{(\w+)\}/g, (_m, key) => {
const val = vars[key];
return val === undefined || val === null ? "" : String(val);
});
}
private toRuleView(rule: SysNoticeRule): any {
return rule.toJSON();
}
// ==================== 扫描实现 ====================
/** 电工资质证照到期:expire_date <= today + advanceDays(含已过期) */
private async scanCertExpire(advanceDays: number): Promise<NoticeScanItem[]> {
const today = dayjs().startOf("day");
const limit = today.add(advanceDays, "day").format("YYYY-MM-DD");
const certs = await this.certModel.findAll({
where: { expireDate: { [Op.ne]: null, [Op.lte]: limit } } as any
});
if (certs.length === 0) return [];
const workerIds = Array.from(new Set(certs.map(c => Number(c.workerId))));
const workers = await this.workerModel.findAll({
where: { id: { [Op.in]: workerIds } } as any,
attributes: ["id", "workerName"]
});
const nameMap = new Map(workers.map(w => [Number(w.id), w.workerName]));
return certs.map(cert => {
const expireDate = String(cert.expireDate).slice(0, 10);
const remainDays = dayjs(expireDate).diff(today, "day");
const expired = remainDays < 0;
return {
bizType: "worker_cert",
bizId: Number(cert.id),
workerId: Number(cert.workerId),
vars: {
workerName: nameMap.get(Number(cert.workerId)) || "未知电工",
certType: cert.certType || "证照",
certNo: cert.certNo || "—",
issueAuthority: cert.issueAuthority || "—",
expireDate,
remainDays,
expireState: expired ? "已过期" : "即将到期",
remainDaysText: this.remainDaysText(remainDays)
}
};
});
}
/** 客户合同到期:end_date <= today + advanceDays(仅有效合同) */
private async scanContractExpire(advanceDays: number): Promise<NoticeScanItem[]> {
const today = dayjs().startOf("day");
const limit = today.add(advanceDays, "day").format("YYYY-MM-DD");
const contracts = await this.contractModel.findAll({
where: { status: 1, endDate: { [Op.ne]: null, [Op.lte]: limit } } as any
});
if (contracts.length === 0) return [];
const customerIds = Array.from(new Set(contracts.map(c => Number(c.customerId))));
const customers = await this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } } as any,
attributes: ["id", "customerName"]
});
const nameMap = new Map(customers.map(c => [Number(c.id), c.customerName]));
return contracts.map(contract => {
const endDate = String(contract.endDate).slice(0, 10);
const remainDays = dayjs(endDate).diff(today, "day");
const expired = remainDays < 0;
return {
bizType: "contract",
bizId: Number(contract.id),
vars: {
customerName: nameMap.get(Number(contract.customerId)) || "未知客户",
contractNo: contract.contractNo || "—",
contractName: contract.xtContractNo || contract.contractNo || "—",
endDate,
expireState: expired ? "已到期" : "即将到期",
remainDaysText: this.remainDaysText(remainDays)
}
};
});
}
/** 客户拜访计划:status=0(待执行)且计划时间已到/超期 */
private async scanVisitPlan(advanceDays: number): Promise<NoticeScanItem[]> {
const today = dayjs().startOf("day");
const limit = today.add(advanceDays, "day").endOf("day").toDate();
const visits = await this.visitModel.findAll({
where: { status: 0, planTime: { [Op.ne]: null, [Op.lte]: limit } } as any
});
if (visits.length === 0) return [];
const customerIds = Array.from(new Set(visits.map(v => Number(v.customerId))));
const customers = await this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } } as any,
attributes: ["id", "customerName"]
});
const nameMap = new Map(customers.map(c => [Number(c.id), c.customerName]));
return visits.map(visit => {
const planDate = dayjs(visit.planTime).format("YYYY-MM-DD");
const remainDays = dayjs(planDate).startOf("day").diff(today, "day");
return {
bizType: "customer_visit",
bizId: Number(visit.id),
vars: {
customerName: nameMap.get(Number(visit.customerId)) || "未知客户",
planDate,
remainDaysText: remainDays < 0 ? `已超期 ${Math.abs(remainDays)} 天` : "计划日期已到"
}
};
});
}
/** 工单超时未接单:派单后超过 advanceDays 天仍未接单(status=0) */
private async scanOrderTimeout(advanceDays: number): Promise<NoticeScanItem[]> {
const cutoff = dayjs().subtract(advanceDays, "day").toDate();
const createdCond = { [Op.ne]: null, [Op.lte]: cutoff };
const [repairs, surveys, installs, maintenances] = await Promise.all([
this.repairDispatchModel.findAll({ where: { status: 0, created_at: createdCond } as any }),
this.surveyOrderModel.findAll({ where: { status: 0, workerId: { [Op.ne]: null }, created_at: createdCond } as any }),
this.installOrderModel.findAll({ where: { status: 0, created_at: createdCond } as any }),
this.maintenanceModel.findAll({ where: { status: 0, created_at: createdCond } as any })
]);
const rows: { row: any; source: string; orderNo: string; typeName: string }[] = [
...repairs.map(r => ({ row: r, source: "repair_dispatch", orderNo: r.dispatchNo, typeName: "报修" })),
...surveys.map(r => ({ row: r, source: "survey_order", orderNo: r.surveyNo, typeName: "勘察" })),
...installs.map(r => ({ row: r, source: "install_order", orderNo: r.installNo, typeName: "安装" })),
...maintenances.map(r => ({ row: r, source: "maintenance_order", orderNo: r.maintenanceNo, typeName: "维保" }))
];
if (rows.length === 0) return [];
const customerIds = Array.from(new Set(rows.map(r => Number(r.row.customerId))));
const customers = await this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } } as any,
attributes: ["id", "customerName"]
});
const nameMap = new Map(customers.map(c => [Number(c.id), c.customerName]));
return rows.map(item => {
const staleDays = Math.max(dayjs().diff(dayjs(item.row.created_at), "day"), 0);
return {
bizType: item.source,
bizId: Number(item.row.id),
vars: {
orderTypeName: item.typeName,
orderNo: item.orderNo || "—",
customerName: nameMap.get(Number(item.row.customerId)) || "未知客户",
staleDays
}
};
});
}
private remainDaysText(remainDays: number): string {
if (remainDays < 0) return `已过期 ${Math.abs(remainDays)} 天`;
if (remainDays === 0) return "今天到期";
return `剩余 ${remainDays} 天到期`;
}
}