This commit is contained in:
wk committed 2026-10-07 08:59:03 +08:00
commit 737244fa9e
36 files changed
+3309 -246

No files matched your search

+4 -4
View File
@@ -12,23 +12,23 @@ export class NoticeController {
@Get("/")
async list(@Query() query: any) {
return await this.noticeService.listMy(this.currentUserId(), query);
return await this.noticeService.listMy("user", this.currentUserId(), query);
}
// 注意:静态路径必须注册在动态路径 /:id/read 之前
@Get("/unread-count")
async unreadCount() {
return await this.noticeService.unreadCount(this.currentUserId());
return await this.noticeService.unreadCount("user", this.currentUserId());
}
@Put("/read-all")
async markAllRead() {
return await this.noticeService.markAllRead(this.currentUserId());
return await this.noticeService.markAllRead("user", this.currentUserId());
}
@Put("/:id/read")
async markRead(@Param("id") id: string) {
return await this.noticeService.markRead(this.currentUserId(), Number(id));
return await this.noticeService.markRead("user", this.currentUserId(), Number(id));
}
private currentUserId(): number {
@@ -0,0 +1,27 @@
import { Controller, Get, Post, Inject, Query, Body } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { NoticeBatchService } from "../service/notice_batch.service";
import { NoticeBatchQueryDTO, NoticeBatchCreateDTO } from "../dto/notice_batch.dto";
@Controller("/api/notice-batches")
export class NoticeBatchController {
@Inject()
ctx!: Context;
@Inject()
noticeBatchService!: NoticeBatchService;
@Get("/")
async list(@Query() query: NoticeBatchQueryDTO) {
return this.noticeBatchService.list(query);
}
@Post("/")
async send(@Body() dto: NoticeBatchCreateDTO) {
return this.noticeBatchService.send(dto, this.currentUserId());
}
private currentUserId(): number {
return Number(this.ctx.state.user?.userId);
}
}
@@ -0,0 +1,55 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { NoticeRuleService } from "../service/notice_rule.service";
import {
NoticeRuleQueryDTO,
NoticeRuleCreateDTO,
NoticeRuleUpdateDTO,
NoticeRuleEnabledDTO
} from "../dto/notice_rule.dto";
@Controller("/api/notice-rules")
export class NoticeRuleController {
@Inject()
noticeRuleService!: NoticeRuleService;
@Get("/")
async list(@Query() query: NoticeRuleQueryDTO) {
return this.noticeRuleService.list(query);
}
// 静态路径必须声明在动态路径 /:id 之前
@Get("/events")
async events() {
return this.noticeRuleService.eventOptions();
}
@Post("/run-scan")
async runScan() {
return this.noticeRuleService.runScanNow();
}
@Get("/:id")
async detail(@Param("id") id: string) {
return this.noticeRuleService.detail(Number(id));
}
@Post("/")
async create(@Body() dto: NoticeRuleCreateDTO) {
return this.noticeRuleService.create(dto);
}
@Put("/:id")
async update(@Param("id") id: string, @Body() dto: NoticeRuleUpdateDTO) {
return this.noticeRuleService.update(Number(id), dto);
}
@Put("/:id/enabled")
async changeEnabled(@Param("id") id: string, @Body() dto: NoticeRuleEnabledDTO) {
return this.noticeRuleService.changeEnabled(Number(id), dto.enabled);
}
@Del("/:id")
async remove(@Param("id") id: string) {
return this.noticeRuleService.remove(Number(id));
}
}
+22 -3
View File
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { WorkerAppService } from "../service/worker_app.service";
import { NoticeService } from "../service/notice.service";
import {
WorkerRegisterDTO,
WorkerLoginDTO,
@@ -16,6 +17,9 @@ export class WorkerAppController {
@Inject()
workerAppService!: WorkerAppService;
@Inject()
noticeService!: NoticeService;
// 注册(匿名)
@Post("/auth/register")
async register(@Body() body: WorkerRegisterDTO) {
@@ -66,10 +70,25 @@ export class WorkerAppController {
return this.workerAppService.resubmit(this.currentWorkerId());
}
// 证照到期提醒
// 站内消息中心:静态路径必须声明在动态路径 /notices/:id/read 之前
@Get("/notices")
async listNotices() {
return this.workerAppService.listNotices(this.currentWorkerId());
async listNotices(@Query() query: any) {
return this.noticeService.listMy("worker", this.currentWorkerId(), query);
}
@Get("/notices/unread-count")
async noticeUnreadCount() {
return this.noticeService.unreadCount("worker", this.currentWorkerId());
}
@Put("/notices/read-all")
async markAllNoticesRead() {
return this.noticeService.markAllRead("worker", this.currentWorkerId());
}
@Put("/notices/:id/read")
async markNoticeRead(@Param("id") id: string) {
return this.noticeService.markRead("worker", this.currentWorkerId(), Number(id));
}
// 订单池:静态路径必须声明在动态路径 /orders/:source/:id 之前
+17
View File
@@ -0,0 +1,17 @@
import { IsOptional, IsString, IsInt, IsNotEmpty, ValidateNested, MaxLength, Min, Max } from "class-validator";
import { Type } from "class-transformer";
import { ReceiverConfigDTO } from "./notice_rule.dto";
/** 群发批次列表查询 */
export class NoticeBatchQueryDTO {
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
}
/** 手动群发消息(仅立即发送) */
export class NoticeBatchCreateDTO {
@IsNotEmpty({ message: "消息标题不能为空" }) @IsString() @MaxLength(128) title!: string;
@IsNotEmpty({ message: "消息正文不能为空" }) @IsString() @MaxLength(512) content!: string;
@ValidateNested() @Type(() => ReceiverConfigDTO) receiverConfig!: ReceiverConfigDTO;
}
+77
View File
@@ -0,0 +1,77 @@
import {
IsOptional,
IsString,
IsInt,
IsArray,
IsIn,
IsNotEmpty,
ValidateNested,
MaxLength,
Min,
Max
} from "class-validator";
import { Type } from "class-transformer";
/** 接收对象-管理端用户:按角色编码 + 按用户 id */
export class ReceiverUsersDTO {
@IsOptional() @IsArray() @IsString({ each: true }) roles?: string[];
@IsOptional() @IsArray() @IsInt({ each: true }) ids?: number[];
}
/** 接收对象-电工:none 不发 / event 事件关联电工 / all 全部在岗 / ids 指定 / status 按状态 */
export class ReceiverWorkersDTO {
@IsOptional() @IsString() @IsIn(["none", "event", "all", "ids", "status"]) mode?: string = "none";
@IsOptional() @IsArray() @IsInt({ each: true }) ids?: number[];
// 审核状态:0待审核 1已通过 2已驳回
@IsOptional() @IsInt() @IsIn([0, 1, 2]) auditStatus?: number;
// 在岗状态:1在岗 0离职
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
}
/** 接收对象配置 */
export class ReceiverConfigDTO {
@IsOptional() @ValidateNested() @Type(() => ReceiverUsersDTO) users?: ReceiverUsersDTO;
@IsOptional() @ValidateNested() @Type(() => ReceiverWorkersDTO) workers?: ReceiverWorkersDTO;
}
/** 通知规则列表查询 */
export class NoticeRuleQueryDTO {
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsString() @MaxLength(64) eventKey?: string;
@IsOptional() @IsInt() @IsIn([1, 2]) triggerMode?: number;
@IsOptional() @IsInt() @IsIn([0, 1]) enabled?: number;
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
}
/** 新增通知规则 */
export class NoticeRuleCreateDTO {
@IsNotEmpty({ message: "规则键不能为空" }) @IsString() @MaxLength(64) ruleKey!: string;
@IsNotEmpty({ message: "规则名称不能为空" }) @IsString() @MaxLength(64) ruleName!: string;
@IsNotEmpty({ message: "事件不能为空" }) @IsString() @MaxLength(64) eventKey!: string;
@IsInt() @IsIn([1, 2], { message: "触发方式非法" }) triggerMode!: number;
@IsOptional() @IsInt() @IsIn([0, 1]) enabled?: number = 1;
@IsOptional() @IsInt() @Min(0) @Max(3650) advanceDays?: number;
@IsNotEmpty({ message: "标题模板不能为空" }) @IsString() @MaxLength(128) titleTpl!: string;
@IsNotEmpty({ message: "正文模板不能为空" }) @IsString() @MaxLength(512) contentTpl!: string;
@ValidateNested() @Type(() => ReceiverConfigDTO) receiverConfig!: ReceiverConfigDTO;
@IsOptional() @IsString() @MaxLength(255) remark?: string;
}
/** 更新通知规则 */
export class NoticeRuleUpdateDTO {
@IsOptional() @IsString() @MaxLength(64) ruleName?: string;
@IsOptional() @IsString() @MaxLength(64) eventKey?: string;
@IsOptional() @IsInt() @IsIn([1, 2]) triggerMode?: number;
@IsOptional() @IsInt() @IsIn([0, 1]) enabled?: number;
@IsOptional() @IsInt() @Min(0) @Max(3650) advanceDays?: number;
@IsOptional() @IsString() @MaxLength(128) titleTpl?: string;
@IsOptional() @IsString() @MaxLength(512) contentTpl?: string;
@IsOptional() @ValidateNested() @Type(() => ReceiverConfigDTO) receiverConfig?: ReceiverConfigDTO;
@IsOptional() @IsString() @MaxLength(255) remark?: string;
}
/** 启停通知规则 */
export class NoticeRuleEnabledDTO {
@IsInt() @IsIn([0, 1], { message: "状态值非法" }) enabled!: number;
}
+12 -3
View File
@@ -7,8 +7,8 @@ import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "idx_sys_notice_receiver", fields: ["receiver_id", "is_read"] },
{ name: "uk_sys_notice_biz", unique: true, fields: ["notice_type", "biz_id", "receiver_id"] }
{ name: "idx_sys_notice_receiver_type", fields: ["receiver_type", "receiver_id", "is_read"] },
{ name: "uk_sys_notice_deliver", unique: true, fields: ["notice_type", "biz_type", "biz_id", "receiver_type", "receiver_id"] }
]
})
export default class SysNotice extends Model {
@@ -30,9 +30,18 @@ export default class SysNotice extends Model {
@Column({ type: DataType.BIGINT, field: "biz_id", comment: "业务主键(→ worker_cert.id)" })
bizId!: number;
@Column({ type: DataType.BIGINT, allowNull: false, field: "receiver_id", comment: "接收人(→ sys_user.id)" })
@Column({ type: DataType.BIGINT, allowNull: false, field: "receiver_id", comment: "接收人(→ sys_user.id / worker.id)" })
receiverId!: number;
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "user", field: "receiver_type", comment: "接收人类型:user-管理端用户 worker-电工" })
receiverType!: string;
@Column({ type: DataType.BIGINT, field: "rule_id", comment: "来源通知规则(→ sys_notice_rule.id)" })
ruleId!: number | null;
@Column({ type: DataType.BIGINT, field: "batch_id", comment: "来源群发批次(→ sys_notice_batch.id)" })
batchId!: number | null;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "is_read", comment: "是否已读:0未读 1已读" })
isRead!: number;
@@ -0,0 +1,29 @@
import { Column, Model, Table, DataType } from "sequelize-typescript";
@Table({
tableName: "sys_notice_batch",
comment: "手动群发批次表(发送记录)",
timestamps: false
})
export default class SysNoticeBatch extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(128), allowNull: false, field: "title", comment: "消息标题" })
title!: string;
@Column({ type: DataType.STRING(512), allowNull: false, field: "content", comment: "消息正文" })
content!: string;
@Column({ type: DataType.JSONB, allowNull: false, field: "receiver_config", comment: "接收对象快照" })
receiverConfig!: Record<string, any>;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "receiver_count", comment: "实际接收人数" })
receiverCount!: number;
@Column({ type: DataType.BIGINT, field: "sent_by", comment: "发送人(→ sys_user.id)" })
sentBy!: number | null;
@Column({ type: DataType.DATE, allowNull: false, field: "sent_at", comment: "发送时间" })
sentAt!: Date;
}
@@ -0,0 +1,53 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
@Table({
tableName: "sys_notice_rule",
comment: "通知规则表(模板 + 接收对象)",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "uk_sys_notice_rule_key", unique: true, fields: ["rule_key"] },
{ name: "idx_sys_notice_rule_event", fields: ["event_key", "trigger_mode", "enabled"] }
]
})
export default class SysNoticeRule extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, field: "rule_key", comment: "规则键(唯一)" })
ruleKey!: string;
@Column({ type: DataType.STRING(64), allowNull: false, field: "rule_name", comment: "规则名称" })
ruleName!: string;
@Column({ type: DataType.STRING(64), allowNull: false, field: "event_key", comment: "事件键(对应代码事件注册表)" })
eventKey!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "trigger_mode", comment: "触发方式:1扫描式 2埋点式" })
triggerMode!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "enabled", comment: "状态:1启用 0停用" })
enabled!: number;
@Column({ type: DataType.INTEGER, field: "advance_days", comment: "提前天数(扫描式)" })
advanceDays!: number | null;
@Column({ type: DataType.STRING(128), allowNull: false, field: "title_tpl", comment: "标题模板(支持 {变量})" })
titleTpl!: string;
@Column({ type: DataType.STRING(512), allowNull: false, field: "content_tpl", comment: "正文模板(支持 {变量})" })
contentTpl!: string;
@Column({ type: DataType.JSONB, allowNull: false, field: "receiver_config", comment: "接收对象配置" })
receiverConfig!: Record<string, any>;
@Column({ type: DataType.STRING(255), field: "remark", comment: "备注" })
remark!: string | null;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+2
View File
@@ -14,6 +14,8 @@ const PERMISSION_MAP: Record<string, string> = {
"/api/dicts": "system:dict",
"/api/logs": "system:log",
"/api/jobs": "system:job",
"/api/notice-rules": "notice:rule",
"/api/notice-batches": "notice:batch",
"/api/customers": "customer:list",
"/api/customer-departments": "customer:department",
"/api/customer-accounts": "customer:account",
+7 -7
View File
@@ -64,7 +64,7 @@ export class DispatchPriceService {
return {
code: 200,
message: "获取派单成本价目列表成功",
message: "获取勘察费用列表成功",
data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) }
};
}
@@ -72,7 +72,7 @@ export class DispatchPriceService {
async detail(id: number): Promise<any> {
const record = await this.dispatchPriceModel.findByPk(id);
if (!record) return { code: 404, message: "价目不存在", data: null };
return { code: 200, message: "获取派单成本价目详情成功", data: record.toJSON() };
return { code: 200, message: "获取勘察费用详情成功", data: record.toJSON() };
}
async create(data: any, ctx: Context): Promise<any> {
@@ -89,14 +89,14 @@ export class DispatchPriceService {
try {
payload.priceCode = await this.generatePriceCode();
const created = await this.dispatchPriceModel.create(payload);
return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() };
return { code: 200, message: "创建勘察费用成功", data: created.toJSON() };
} catch (err: any) {
// 唯一冲突:重新生成编码后重试 1 次
if (err instanceof UniqueConstraintError) {
try {
payload.priceCode = await this.generatePriceCode();
const created = await this.dispatchPriceModel.create(payload);
return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() };
return { code: 200, message: "创建勘察费用成功", data: created.toJSON() };
} catch (_e) {
return { code: 400, message: "价格编码已存在", data: null };
}
@@ -112,7 +112,7 @@ export class DispatchPriceService {
const payload = this.pickWritable(data);
await record.update(payload);
return { code: 200, message: "更新派单成本价目成功", data: record.toJSON() };
return { code: 200, message: "更新勘察费用成功", data: record.toJSON() };
}
async delete(id: number, ctx: Context): Promise<any> {
@@ -120,7 +120,7 @@ export class DispatchPriceService {
if (!record) return { code: 404, message: "价目不存在", data: null };
await record.destroy();
return { code: 200, message: "删除派单成本价目成功", data: null };
return { code: 200, message: "删除勘察费用成功", data: null };
}
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
@@ -130,6 +130,6 @@ export class DispatchPriceService {
if (!record) return { code: 404, message: "价目不存在", data: null };
await record.update({ status });
return { code: 200, message: "更新派单成本价目状态成功", data: null };
return { code: 200, message: "更新勘察费用状态成功", data: null };
}
}
@@ -10,6 +10,7 @@ import ProjectPlan from "../entity/project_plan.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
// 安装派单状态:0已派发 1已完成
const STATUS = { DISPATCHED: 0, COMPLETED: 1 };
@@ -37,6 +38,9 @@ export class InstallOrderService {
@Inject()
logService!: LogService;
@Inject()
noticeRuleService!: NoticeRuleService;
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");
@@ -222,6 +226,22 @@ export class InstallOrderService {
const vo: any = created.toJSON();
await this.decorate([vo]);
await this.logService.logAction(ctx, "安装派单", "创建", created.installNo, `创建安装派单 ${created.installNo}`);
// 派单站内通知(埋点式;通知失败不得阻断业务)
if (created.workerId) {
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "安装",
orderNo: created.installNo,
customerId: Number(created.customerId),
workerId: Number(created.workerId),
detailText: created.startDate ? `计划开始:${created.startDate}。` : "",
bizType: "install_order",
bizId: Number(created.id)
});
} catch (err) {
console.error("[notice] 安装派单通知失败:", err);
}
}
return { code: 200, message: "创建安装派单成功", data: vo };
}
@@ -245,11 +265,28 @@ export class InstallOrderService {
}
const payload = this.normalizePayload(data);
const oldWorkerId = record.workerId ? Number(record.workerId) : null;
await record.update(payload);
const fresh = await this.installModel.findByPk(id);
const vo: any = fresh!.toJSON();
await this.decorate([vo]);
await this.logService.logAction(ctx, "安装派单", "更新", record.installNo, `更新安装派单 ${record.installNo}`);
// 改派站内通知(派单对象变化时提醒新电工)
if (record.workerId && Number(record.workerId) !== oldWorkerId) {
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "安装",
orderNo: record.installNo,
customerId: Number(record.customerId),
workerId: Number(record.workerId),
detailText: record.startDate ? `计划开始:${record.startDate}。` : "",
bizType: "install_order",
bizId: Number(record.id)
});
} catch (err) {
console.error("[notice] 安装派单通知失败:", err);
}
}
return { code: 200, message: "更新安装派单成功", data: vo };
}
+8 -1
View File
@@ -7,6 +7,7 @@ import dayjs from "dayjs";
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 { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
/** 任务执行结果(一次尝试) */
@@ -423,7 +424,13 @@ export class JobSchedulerService {
const logService = (await ctx.requestContext.getAsync(LogService)) as LogService;
return logService.cleanExpiredLogs(keepDays);
},
cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays)
cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays),
// NoticeRuleService 同样是请求作用域,按需从匿名请求上下文获取
runNoticeRules: async () => {
const ctx = this.app.createAnonymousContext();
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
return noticeRuleService.executeScan();
}
};
}
}
+37
View File
@@ -10,6 +10,7 @@ import Worker from "../entity/worker.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
// 维保派单状态:0已派发 1已完成
const STATUS = { DISPATCHED: 0, COMPLETED: 1 };
@@ -37,6 +38,9 @@ export class MaintenanceService {
@Inject()
logService!: LogService;
@Inject()
noticeRuleService!: NoticeRuleService;
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");
@@ -199,6 +203,22 @@ export class MaintenanceService {
await this.decorate([vo]);
vo.details = detailRows.map((d) => d.toJSON());
await this.logService.logAction(ctx, "维保派单", "创建", created.maintenanceNo, `创建维保派单 ${created.maintenanceNo}`);
// 派单站内通知(埋点式;通知失败不得阻断业务)
if (created.workerId) {
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "维保",
orderNo: created.maintenanceNo,
customerId: Number(created.customerId),
workerId: Number(created.workerId),
detailText: created.maintenanceDate ? `维保日期:${created.maintenanceDate}。` : "",
bizType: "maintenance_order",
bizId: Number(created.id)
});
} catch (err) {
console.error("[notice] 维保派单通知失败:", err);
}
}
return { code: 200, message: "创建维保派单成功", data: vo };
}
@@ -228,6 +248,7 @@ export class MaintenanceService {
const t = await this.orderModel.sequelize!.transaction();
const detailRows: MaintenanceDetail[] = [];
const oldWorkerId = record.workerId ? Number(record.workerId) : null;
try {
await record.update(payload, { transaction: t });
if (details.length) {
@@ -247,6 +268,22 @@ export class MaintenanceService {
await this.decorate([vo]);
vo.details = detailRows.map((d) => d.toJSON());
await this.logService.logAction(ctx, "维保派单", "更新", record.maintenanceNo, `更新维保派单 ${record.maintenanceNo}`);
// 改派站内通知(派单对象变化时提醒新电工)
if (record.workerId && Number(record.workerId) !== oldWorkerId) {
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "维保",
orderNo: record.maintenanceNo,
customerId: Number(record.customerId),
workerId: Number(record.workerId),
detailText: record.maintenanceDate ? `维保日期:${record.maintenanceDate}。` : "",
bizType: "maintenance_order",
bizId: Number(record.id)
});
} catch (err) {
console.error("[notice] 维保派单通知失败:", err);
}
}
return { code: 200, message: "更新维保派单成功", data: vo };
}
+136 -141
View File
@@ -2,30 +2,34 @@ import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import dayjs from "dayjs";
import SysNotice from "../entity/sys_notice.entity";
import WorkerCert from "../entity/worker_cert.entity";
import Worker from "../entity/worker.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import Worker from "../entity/worker.entity";
// 通知类型:证照到期提醒
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
// 业务类型:电工资质证照
const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert";
// 到期提醒提前天数
const CERT_EXPIRE_WARN_DAYS = 30;
// 超级管理员角色编码(拥有全部权限,作为证照提醒接收人)
const ADMIN_ROLE_CODE = "ADMIN";
// 分页上限,防止单次拉取过多数据
const MAX_PAGE_SIZE = 200;
// 接收人类型:user-管理端用户 worker-电工
export type ReceiverType = "user" | "worker";
// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效)
function toDateOnly(value: any): string {
if (value === null || value === undefined || value === "") return "";
const str = String(value);
return str.length >= 10 ? str.slice(0, 10) : str;
/** 接收对象配置(与 sys_notice_rule.receiver_config 同构) */
export interface ReceiverConfig {
users?: { roles?: string[]; ids?: number[] };
workers?: { mode?: "none" | "event" | "all" | "ids" | "status"; ids?: number[]; auditStatus?: number; status?: number };
}
/** 统一投递入参 */
export interface DeliverInput {
noticeType: string;
title: string;
content: string;
bizType?: string | null;
bizId?: number | null;
receiverType: ReceiverType;
receiverId: number;
ruleId?: number | null;
batchId?: number | null;
}
@Provide()
@@ -33,12 +37,6 @@ export class NoticeService {
@InjectRepository(SysNotice)
noticeModel!: Repository<SysNotice>;
@InjectRepository(WorkerCert)
certModel!: Repository<WorkerCert>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@@ -48,13 +46,16 @@ export class NoticeService {
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
// 我的通知列表:未读优先,可选按已读状态筛选
async listMy(userId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise<any> {
async listMy(receiverType: ReceiverType, receiverId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise<any> {
const page = Math.max(Number(query.page) || 1, 1);
const rawPageSize = Number(query.pageSize) || 10;
const pageSize = Math.min(Math.max(rawPageSize, 1), MAX_PAGE_SIZE);
const where: any = { receiverId: userId };
const where: any = { receiverType, receiverId };
if (query.isRead !== undefined && query.isRead !== "") {
where.isRead = Number(query.isRead);
}
@@ -80,8 +81,8 @@ export class NoticeService {
}
// 未读数量(顶栏红点)
async unreadCount(userId: number): Promise<any> {
const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } });
async unreadCount(receiverType: ReceiverType, receiverId: number): Promise<any> {
const count = await this.noticeModel.count({ where: { receiverType, receiverId, isRead: 0 } as any });
return {
code: 200,
@@ -91,8 +92,8 @@ export class NoticeService {
}
// 单条标记已读(仅能操作自己的通知)
async markRead(userId: number, id: number): Promise<any> {
const notice = await this.noticeModel.findOne({ where: { id, receiverId: userId } });
async markRead(receiverType: ReceiverType, receiverId: number, id: number): Promise<any> {
const notice = await this.noticeModel.findOne({ where: { id, receiverType, receiverId } as any });
if (!notice) {
return {
@@ -114,8 +115,11 @@ export class NoticeService {
}
// 全部标记已读
async markAllRead(userId: number): Promise<any> {
const updated = await this.noticeModel.update({ isRead: 1 }, { where: { receiverId: userId, isRead: 0 } });
async markAllRead(receiverType: ReceiverType, receiverId: number): Promise<any> {
const updated = await this.noticeModel.update(
{ isRead: 1 },
{ where: { receiverType, receiverId, isRead: 0 } as any }
);
return {
code: 200,
@@ -124,128 +128,119 @@ export class NoticeService {
};
}
// 查询拥有 ADMIN 角色的所有用户ID(sys_user_role + sys_role 关联,并校验用户真实存在)
async getAdminUserIds(): Promise<number[]> {
const adminRole = await this.roleModel.findOne({
where: { roleCode: ADMIN_ROLE_CODE },
attributes: ["id"]
});
if (!adminRole) return [];
/**
* 统一投递(批量、幂等)。
* 依赖唯一索引 uk_sys_notice_deliver(notice_type,biz_type,biz_id,receiver_type,receiver_id),
* 以 ON CONFLICT DO NOTHING 去重,返回新增条数。
*/
async deliverMany(rows: DeliverInput[]): Promise<number> {
if (!rows || rows.length === 0) return 0;
const userRoles = await this.userRoleModel.findAll({
where: { roleId: Number(adminRole.id) },
attributes: ["userId"]
});
const userIds = Array.from(new Set(userRoles.map((ur) => Number(ur.userId))));
if (userIds.length === 0) return [];
const placeholders = rows.map(() => "(?,?,?,?,?,?,?,?,?,0,NOW())").join(",");
const replacements: any[] = [];
for (const r of rows) {
replacements.push(
r.noticeType,
r.title,
r.content,
r.bizType ?? null,
r.bizId ?? null,
r.receiverType,
r.receiverId,
r.ruleId ?? null,
r.batchId ?? null
);
}
const sql = `INSERT INTO sys_notice
(notice_type, title, content, biz_type, biz_id, receiver_type, receiver_id, rule_id, batch_id, is_read, created_at)
VALUES ${placeholders}
ON CONFLICT DO NOTHING`;
const [, affected] = await this.noticeModel.sequelize!.query(sql, { replacements });
return Number(affected) || 0;
}
// 单条投递
async deliver(row: DeliverInput): Promise<boolean> {
const created = await this.deliverMany([row]);
return created > 0;
}
/** 解析管理端接收人:角色编码 + 指定用户 id,并校验用户真实存在 */
async resolveUserIds(config?: ReceiverConfig["users"]): Promise<number[]> {
const ids = new Set<number>();
const roleCodes = (config?.roles || []).filter(Boolean);
if (roleCodes.length > 0) {
const roles = await this.roleModel.findAll({
where: { roleCode: { [Op.in]: roleCodes } },
attributes: ["id"]
});
const roleIds = roles.map((r) => Number(r.id));
if (roleIds.length > 0) {
const userRoles = await this.userRoleModel.findAll({
where: { roleId: { [Op.in]: roleIds } },
attributes: ["userId"]
});
for (const ur of userRoles) ids.add(Number(ur.userId));
}
}
for (const id of config?.ids || []) ids.add(Number(id));
if (ids.size === 0) return [];
const users = await this.userModel.findAll({
where: { id: { [Op.in]: userIds } },
where: { id: { [Op.in]: Array.from(ids) } },
attributes: ["id"]
});
return users.map((u) => Number(u.id));
}
// 扫描 30 天内到期或已过期的电工资质证照,向所有 ADMIN 推送站内通知(幂等)
async scanCertExpireAndNotify(): Promise<{ scanned: number; created: number }> {
let scanned = 0;
let created = 0;
/**
* 解析电工接收人。
* mode: none 不发 / event 事件关联电工 / all 全部在岗 / ids 指定 / status 按审核与在岗状态
*/
async resolveWorkerIds(config?: ReceiverConfig["workers"], eventWorkerId?: number): Promise<number[]> {
const mode = config?.mode || "none";
if (mode === "none") return [];
try {
// 使用本地日期字符串比较,避免时区偏差
const today = dayjs().format("YYYY-MM-DD");
const deadline = dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD");
// 1. 查出已设置有效期且在提醒窗口内的证书(含已过期)
const certs = await this.certModel.findAll({
where: {
expireDate: { [Op.not]: null, [Op.lte]: deadline }
},
order: [["expire_date", "ASC"]]
});
scanned = certs.length;
if (scanned === 0) return { scanned, created };
// 2. worker_cert 未声明 BelongsTo(Worker) 关联,单独查询电工姓名后按 Map 关联,避免 N+1
const workerIds = Array.from(new Set(certs.map((c) => Number(c.workerId))));
const workers = await this.workerModel.findAll({
where: { id: { [Op.in]: workerIds } },
attributes: ["id", "workerName"]
});
const workerNameMap = new Map<number, string>();
for (const w of workers) workerNameMap.set(Number(w.id), w.workerName);
// 3. 接收人:全部 ADMIN
const adminIds = await this.getAdminUserIds();
if (adminIds.length === 0) return { scanned, created };
// 4. 幂等:按 (noticeType, bizType, bizId, receiverId) 先批量查出已存在的通知,已存在则跳过
const certIds = certs.map((c) => Number(c.id));
const existing = await this.noticeModel.findAll({
where: {
noticeType: NOTICE_TYPE_CERT_EXPIRE,
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
bizId: { [Op.in]: certIds },
receiverId: { [Op.in]: adminIds }
},
attributes: ["bizId", "receiverId"]
});
const existsSet = new Set(existing.map((n) => `${Number(n.bizId)}_${Number(n.receiverId)}`));
// 5. 证书 × ADMIN 逐条生成通知
for (const cert of certs) {
const expire = toDateOnly(cert.expireDate);
if (!expire) continue;
const days = dayjs(expire).startOf("day").diff(dayjs(today).startOf("day"), "day");
const workerName = workerNameMap.get(Number(cert.workerId)) || "未知电工";
const certType = cert.certType || "资质证书";
const title =
days < 0
? `电工【${workerName}】的${certType}已于 ${expire} 过期`
: `电工【${workerName}】的${certType}将于 ${expire} 到期(剩余 ${days} 天)`;
const content = [
`证书编号:${cert.certNo || "—"}`,
`发证机关:${cert.issueAuthority || "—"}`,
`有效期至:${expire}`,
days < 0 ? `已过期 ${Math.abs(days)} 天,请及时提醒电工办理续期` : `剩余 ${days} 天,请及时提醒电工办理续期`
].join(";");
for (const adminId of adminIds) {
if (existsSet.has(`${Number(cert.id)}_${adminId}`)) continue;
await this.noticeModel.create({
noticeType: NOTICE_TYPE_CERT_EXPIRE,
title,
content,
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
bizId: Number(cert.id),
receiverId: adminId,
isRead: 0
} as any);
existsSet.add(`${Number(cert.id)}_${adminId}`);
created += 1;
}
}
return { scanned, created };
} catch (err: any) {
// 定时任务不允许抛出,失败仅记录日志
console.error("[notice] 证照到期扫描失败:", err?.message || err);
return { scanned, created };
const where: any = {};
if (mode === "event") {
if (!eventWorkerId) return [];
where.id = Number(eventWorkerId);
} else if (mode === "ids") {
const ids = (config?.ids || []).map(Number);
if (ids.length === 0) return [];
where.id = { [Op.in]: ids };
} else if (mode === "all") {
where.status = 1;
} else if (mode === "status") {
if (config?.status !== undefined && config?.status !== null) where.status = Number(config.status);
if (config?.auditStatus !== undefined && config?.auditStatus !== null) where.auditStatus = Number(config.auditStatus);
}
const workers = await this.workerModel.findAll({ where, attributes: ["id"] });
return workers.map((w) => Number(w.id));
}
// 清理指定证书的到期通知(证书续期/删除后调用,允许重新提醒)
async clearByCertIds(certIds: number[]): Promise<number> {
if (!certIds || certIds.length === 0) return 0;
/**
* 按接收对象配置投递:展开管理端用户 + 电工,逐人写入(幂等)。
* base 提供通知类型/标题/正文/业务锚点;eventWorkerId 供 workers.mode=event 使用。
*/
async deliverToReceivers(
base: Omit<DeliverInput, "receiverType" | "receiverId">,
config: ReceiverConfig | null | undefined,
options?: { eventWorkerId?: number }
): Promise<number> {
const userIds = await this.resolveUserIds(config?.users);
const workerIds = await this.resolveWorkerIds(config?.workers, options?.eventWorkerId);
return await this.noticeModel.destroy({
where: {
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
bizId: { [Op.in]: certIds }
}
});
const rows: DeliverInput[] = [
...userIds.map((id) => ({ ...base, receiverType: "user" as ReceiverType, receiverId: id })),
...workerIds.map((id) => ({ ...base, receiverType: "worker" as ReceiverType, receiverId: id }))
];
return this.deliverMany(rows);
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import SysNoticeBatch from "../entity/sys_notice_batch.entity";
import SysUser from "../entity/sysUser.entity";
import { NoticeService, ReceiverConfig } from "./notice.service";
import { NoticeBatchQueryDTO, NoticeBatchCreateDTO } from "../dto/notice_batch.dto";
const MAX_PAGE_SIZE = 200;
// 手动群发通知类型
const NOTICE_TYPE_MANUAL = "manual";
const BIZ_TYPE_MANUAL_BATCH = "manual_batch";
@Provide()
export class NoticeBatchService {
@InjectRepository(SysNoticeBatch)
batchModel!: Repository<SysNoticeBatch>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@Inject()
noticeService!: NoticeService;
/** 群发记录分页(附发送人姓名) */
async list(query: NoticeBatchQueryDTO): 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] = [
{ title: { [Op.like]: `%${query.keyword}%` } },
{ content: { [Op.like]: `%${query.keyword}%` } }
];
}
const { count, rows } = await this.batchModel.findAndCountAll({
where,
order: [["id", "DESC"]],
limit: pageSize,
offset: (page - 1) * pageSize
});
const senderIds = Array.from(new Set(rows.map(r => Number(r.sentBy)).filter(id => !!id)));
const senders = senderIds.length > 0
? await this.userModel.findAll({ where: { id: { [Op.in]: senderIds } } as any, attributes: ["id", "username", "realName"] })
: [];
const nameMap = new Map(senders.map(u => [Number(u.id), u.realName || u.username]));
const list = rows.map(row => ({
...row.toJSON(),
sentByName: row.sentBy ? nameMap.get(Number(row.sentBy)) || "—" : "—"
}));
return {
code: 200,
message: "获取发送记录成功",
data: { list, total: count, page, pageSize }
};
}
/** 手动群发(仅立即发送):解析接收人 → 写入 sys_notice → 记录批次 */
async send(dto: NoticeBatchCreateDTO, operatorUserId: number): Promise<any> {
const receiverConfig = (dto.receiverConfig ?? {}) as ReceiverConfig;
const batch = await this.batchModel.create({
title: dto.title,
content: dto.content,
receiverConfig: receiverConfig as any,
receiverCount: 0,
sentBy: operatorUserId || null,
sentAt: new Date()
} as any);
const created = await this.noticeService.deliverToReceivers(
{
noticeType: NOTICE_TYPE_MANUAL,
title: dto.title,
content: dto.content,
bizType: BIZ_TYPE_MANUAL_BATCH,
bizId: Number(batch.id),
batchId: Number(batch.id)
},
receiverConfig,
{}
);
if (created === 0) {
await batch.destroy();
return { code: 400, message: "未匹配到任何接收人,请检查接收对象配置", data: null };
}
await batch.update({ receiverCount: created } as any);
return {
code: 200,
message: `发送成功,已送达 ${created} 人`,
data: { batchId: Number(batch.id), receiverCount: created }
};
}
}
+480
View File
@@ -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} 天到期`;
}
}
@@ -10,6 +10,7 @@ import Worker from "../entity/worker.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
// 报修单状态:0待处理 1已派单 2已完成
const REQUEST_STATUS = { PENDING: 0, DISPATCHED: 1, COMPLETED: 2 };
@@ -42,6 +43,9 @@ export class RepairDispatchService {
@Inject()
logService!: LogService;
@Inject()
noticeRuleService!: NoticeRuleService;
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");
@@ -267,6 +271,20 @@ export class RepairDispatchService {
const vo: any = created.record.toJSON();
this.decorateDispatch([vo]);
await this.logService.logAction(ctx, "报修派单", "创建", created.record.dispatchNo, `创建报修派单 ${created.record.dispatchNo}`);
// 派单站内通知(埋点式;通知失败不得阻断业务)
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "报修",
orderNo: created.record.dispatchNo,
customerId: Number(created.record.customerId),
workerId: Number(created.record.workerId),
detailText: `报修内容:${String(created.record.repairContent || "").slice(0, 60)}。`,
bizType: "repair_dispatch",
bizId: Number(created.record.id)
});
} catch (err) {
console.error("[notice] 报修派单通知失败:", err);
}
return { code: 200, message: "创建报修派单成功", data: vo };
}
@@ -285,10 +303,27 @@ export class RepairDispatchService {
delete payload.repairId;
delete payload.customerId;
delete payload.status;
const oldWorkerId = record.workerId ? Number(record.workerId) : null;
await record.update(payload);
const vo: any = record.toJSON();
this.decorateDispatch([vo]);
await this.logService.logAction(ctx, "报修派单", "更新", record.dispatchNo, `更新报修派单 ${record.dispatchNo}`);
// 改派站内通知(派单对象变化时提醒新电工)
if (record.workerId && Number(record.workerId) !== oldWorkerId) {
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "报修",
orderNo: record.dispatchNo,
customerId: Number(record.customerId),
workerId: Number(record.workerId),
detailText: `报修内容:${String(record.repairContent || "").slice(0, 60)}。`,
bizType: "repair_dispatch",
bizId: Number(record.id)
});
} catch (err) {
console.error("[notice] 报修派单通知失败:", err);
}
}
return { code: 200, message: "更新报修派单成功", data: vo };
}
@@ -14,6 +14,7 @@ import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { UploadService } from "./upload.service";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
@@ -61,6 +62,9 @@ export class SurveyOrderService {
@Inject()
logService!: LogService;
@Inject()
noticeRuleService!: NoticeRuleService;
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");
@@ -526,6 +530,20 @@ export class SurveyOrderService {
};
if (estimatedFee !== undefined && estimatedFee !== null) patch.estimatedFee = estimatedFee;
await record.update(patch);
// 指派站内通知(埋点式;通知失败不得阻断业务)
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "勘察",
orderNo: record.surveyNo,
customerId: Number(record.customerId),
workerId: Number(worker.id),
detailText: record.taskTitle ? `任务:${record.taskTitle}。` : "",
bizType: "survey_order",
bizId: Number(record.id)
});
} catch (err) {
console.error("[notice] 勘察派单通知失败:", err);
}
const vo = this.toVO(record);
await this.decorate([vo]);
return { ok: true, data: vo };
+18 -1
View File
@@ -1,4 +1,4 @@
import { Provide } from "@midwayjs/core";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
@@ -11,6 +11,7 @@ import SurveyOrder from "../entity/survey_order.entity";
import InstallOrder from "../entity/install_order.entity";
import { WorkerCreateDTO, WorkerUpdateDTO, WorkerAuditDTO, WorkerCertDTO } from "../dto/worker.dto";
import { hashPassword } from "../util/crypto";
import { NoticeRuleService } from "./notice_rule.service";
// 电工状态:1在岗 0离职
const STATUS_ON_DUTY = 1;
@@ -92,6 +93,9 @@ export class WorkerService {
@InjectRepository(InstallOrder)
installOrderModel!: Repository<InstallOrder>;
@Inject()
noticeRuleService!: NoticeRuleService;
// 资质状态推导所需的日期边界
private certDateRange(): { today: string; warn: string } {
return {
@@ -375,6 +379,19 @@ export class WorkerService {
});
}
// 审核结果站内通知(埋点式;通知失败不得阻断审核业务)
try {
await this.noticeRuleService.runForEvent("worker_audit_result", {
vars: {
auditState: approved ? "已通过" : "已驳回",
auditRemarkText: approved ? "" : `驳回理由:${(dto.remark || "").trim()}。`
},
eventWorkerId: Number(id)
});
} catch (err) {
console.error("[notice] 电工审核结果通知失败:", err);
}
return {
code: 200,
message: approved ? "审核通过成功" : "审核驳回成功",
-25
View File
@@ -264,31 +264,6 @@ export class WorkerAppService {
return { code: 200, message: "已重新提交,请等待审核" };
}
// 到期提醒列表:基于 worker_cert 实时计算,包含临期与已过期(长期有效不计入)
async listNotices(workerId: number): Promise<any> {
const certs = await this.listCertRows(workerId);
const today = this.todayStr();
const list = certs
.filter(cert => !!cert.expireDate)
.map(cert => {
const expireDate = String(cert.expireDate).slice(0, 10);
const remainDays = this.diffDays(today, expireDate);
return {
certId: cert.id,
certType: cert.certType,
certNo: cert.certNo,
expireDate,
remainDays,
isExpired: expireDate < today
};
})
.filter(item => item.isExpired || item.remainDays <= EXPIRING_DAYS)
.sort((a, b) => a.remainDays - b.remainDays);
return { code: 200, message: "获取成功", data: list };
}
// ================= 订单池 / 我的订单 / 订单详情 =================
// 订单来源是否合法(控制器据此返回 400)
-19
View File
@@ -1,19 +0,0 @@
import { Inject } from "@midwayjs/core";
import { Job, IJob } from "@midwayjs/cron";
import { NoticeService } from "../service/notice.service";
/**
* 电工资质证照到期提醒定时任务
* 每天 08:00 扫描 worker_cert 中 30 天内到期或已过期的证照,
* 为每个 ADMIN 生成一条站内通知(幂等,已存在的提醒不会重复生成)。
*/
@Job({ cronTime: "0 0 8 * * *", start: true, timeZone: "Asia/Shanghai" })
export class CertExpireTask implements IJob {
@Inject()
noticeService!: NoticeService;
async onTick() {
const result = await this.noticeService.scanCertExpireAndNotify();
console.log(`[cert-expire] 证照到期扫描完成:扫描 ${result.scanned} 条,新增通知 ${result.created} 条`);
}
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { demoHeartbeatJob } from "./demo_heartbeat.task";
import { logCleanJob } from "./log_clean.task";
import { noticeScanJob } from "./notice_scan.task";
export type JobParamType = "number" | "string";
@@ -26,6 +27,8 @@ export interface JobRuntime {
cleanOperationLogs(keepDays: number): Promise<number>;
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
cleanJobLogs(keepDays: number): Promise<number>;
/** 执行全部启用的扫描式通知规则,返回本次执行摘要 */
runNoticeRules(): Promise<string>;
}
export interface JobHandlerContext {
@@ -52,7 +55,7 @@ export interface BuiltinJob {
handler: JobHandler;
}
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob];
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob];
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
+110
View File
@@ -0,0 +1,110 @@
/**
* 通知事件注册表(元数据)
*
* 事件分两类:
* - 扫描式(triggerMode=1):由内置任务 notice_scan 定时扫描,扫描实现位于 NoticeRuleService
* - 埋点式(triggerMode=2):业务代码在关键动作处调用 NoticeRuleService.runForEvent()
*
* 本文件只维护「事件元数据」(供 Web「通知管理」下拉与模板变量提示),
* 具体扫描逻辑与业务埋点实现分别位于 NoticeRuleService 与各业务 service。
*/
/** 模板可用变量({变量} 占位符) */
export interface NoticeEventVariable {
key: string;
label: string;
}
/** 事件定义 */
export interface NoticeEventDefinition {
eventKey: string;
eventName: string;
/** 1扫描式 2埋点式 */
triggerMode: 1 | 2;
variables: NoticeEventVariable[];
}
/** 扫描式事件单条命中结果 */
export interface NoticeScanItem {
bizType: string;
bizId: number;
/** workers.mode=event 时作为事件关联电工 */
workerId?: number | null;
vars: Record<string, any>;
}
export const NOTICE_EVENTS: NoticeEventDefinition[] = [
{
eventKey: "cert_expire",
eventName: "电工资质证照到期提醒",
triggerMode: 1,
variables: [
{ key: "workerName", label: "电工姓名" },
{ key: "certType", label: "证书类型" },
{ key: "certNo", label: "证书编号" },
{ key: "issueAuthority", label: "发证机关" },
{ key: "expireDate", label: "有效期至" },
{ key: "remainDays", label: "剩余天数" },
{ key: "expireState", label: "到期状态(即将到期/已过期)" },
{ key: "remainDaysText", label: "剩余天数文案" }
]
},
{
eventKey: "contract_expire",
eventName: "客户合同到期提醒",
triggerMode: 1,
variables: [
{ key: "customerName", label: "客户名称" },
{ key: "contractNo", label: "合同编号" },
{ key: "contractName", label: "合同名称" },
{ key: "endDate", label: "到期日" },
{ key: "expireState", label: "到期状态(即将到期/已过期)" },
{ key: "remainDaysText", label: "剩余天数文案" }
]
},
{
eventKey: "visit_plan",
eventName: "客户拜访计划提醒",
triggerMode: 1,
variables: [
{ key: "customerName", label: "客户名称" },
{ key: "planDate", label: "计划拜访日期" },
{ key: "remainDaysText", label: "到期文案" }
]
},
{
eventKey: "order_timeout",
eventName: "工单超时未接单提醒",
triggerMode: 1,
variables: [
{ key: "orderTypeName", label: "工单类型" },
{ key: "orderNo", label: "工单编号" },
{ key: "customerName", label: "客户名称" },
{ key: "staleDays", label: "滞留天数" }
]
},
{
eventKey: "worker_audit_result",
eventName: "电工入驻审核结果通知",
triggerMode: 2,
variables: [
{ key: "auditState", label: "审核结果(已通过/已驳回)" },
{ key: "auditRemarkText", label: "驳回理由文案" }
]
},
{
eventKey: "dispatch_assign",
eventName: "工单派单通知",
triggerMode: 2,
variables: [
{ key: "orderTypeName", label: "工单类型" },
{ key: "orderNo", label: "工单编号" },
{ key: "customerName", label: "客户名称" },
{ key: "detailText", label: "附加说明" }
]
}
];
export function getNoticeEvent(eventKey: string): NoticeEventDefinition | undefined {
return NOTICE_EVENTS.find(e => e.eventKey === eventKey);
}
+18
View File
@@ -0,0 +1,18 @@
import type { BuiltinJob } from "./job_registry";
/**
* 通知规则扫描任务:遍历启用的扫描式通知规则,
* 扫描业务数据并按模板 + 接收对象生成站内通知(幂等)。
* 默认每日 08:00 执行,可在「定时任务」页调整 cron / 启停 / 立即执行。
*/
export const noticeScanJob: BuiltinJob = {
jobKey: "notice_scan",
jobName: "通知规则扫描",
description: "遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等)",
defaultCron: "0 0 8 * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.runNoticeRules();
}
};