feat: 新增可配置通知中心,支持扫描式/埋点式通知规则与手动群发
新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
This commit is contained in:
1 parent
91aa3c06f0
commit
6cf6a3d5b9
36 files changed
+3309
-246
No files matched your search
@@ -0,0 +1,480 @@
|
||||
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} 天到期`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user