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; /** workers.mode=event 时的目标电工 */ eventWorkerId?: number | null; } @Provide() export class NoticeRuleService { @InjectRepository(SysNoticeRule) ruleModel!: Repository; @InjectRepository(Worker) workerModel!: Repository; @InjectRepository(WorkerCert) certModel!: Repository; @InjectRepository(Contract) contractModel!: Repository; @InjectRepository(Customer) customerModel!: Repository; @InjectRepository(CustomerVisit) visitModel!: Repository; @InjectRepository(RepairDispatch) repairDispatchModel!: Repository; @InjectRepository(SurveyOrder) surveyOrderModel!: Repository; @InjectRepository(InstallOrder) installOrderModel!: Repository; @InjectRepository(MaintenanceOrder) maintenanceModel!: Repository; @Inject() noticeService!: NoticeService; /** 扫描式事件 → 扫描实现(按 advanceDays 扫描,返回命中行) */ private scanProviders: Record Promise> = { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return { code: 200, message: "获取事件字典成功", data: NOTICE_EVENTS.map(e => ({ ...e, triggerModeText: e.triggerMode === 1 ? "扫描式" : "埋点式" })) }; } /** 手动触发一次扫描(Web「立即执行」) */ async runScanNow(): Promise { const summary = await this.executeScan(); return { code: 200, message: "扫描执行完成", data: { summary } }; } // ==================== 触发执行 ==================== /** 扫描式统一入口:遍历启用的扫描式规则并生成通知(供 notice_scan 任务调用) */ async executeScan(): Promise { 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 { 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, base: { noticeType: string; bizType?: string | null; bizId?: number | null }, eventWorkerId?: number ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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} 天到期`; } }