feat: 新增电工APP端与定时任务管理功能
- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
1 parent
1803ad679c
commit
f4454ad014
57 files changed
+9388
-200
No files matched your search
@@ -0,0 +1,135 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import DispatchPrice from "../entity/dispatch_price.entity";
|
||||
|
||||
// 创建/更新可写入字段白名单(priceCode 只读,服务端生成)
|
||||
const WRITABLE_FIELDS = ["dispatchType", "priceType", "priceMode", "unitPrice", "unit", "status", "remark"];
|
||||
// 计价方式 → 默认单位兜底
|
||||
const MODE_UNIT: Record<number, string> = { 1: "次", 2: "小时" };
|
||||
|
||||
@Provide()
|
||||
export class DispatchPriceService {
|
||||
@InjectRepository(DispatchPrice)
|
||||
dispatchPriceModel!: Repository<DispatchPrice>;
|
||||
|
||||
// 价格编码规则:DG + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次
|
||||
private async generatePriceCode(): Promise<string> {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
|
||||
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const prefix = `DG${yyyymmdd}`;
|
||||
|
||||
const count = await this.dispatchPriceModel.count({ where: { priceCode: { [Op.like]: `${prefix}%` } } });
|
||||
let seq = count + 1;
|
||||
|
||||
for (let i = 0; i <= 1; i++) {
|
||||
const code = `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
const exists = await this.dispatchPriceModel.findOne({ where: { priceCode: code } });
|
||||
if (!exists) return code;
|
||||
seq++;
|
||||
}
|
||||
throw new Error("价格编码生成冲突,请重试");
|
||||
}
|
||||
|
||||
// 仅保留白名单字段
|
||||
private pickWritable(data: any): any {
|
||||
const payload: any = {};
|
||||
for (const key of WRITABLE_FIELDS) {
|
||||
if (data[key] !== undefined) payload[key] = data[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Math.min(Number(query.pageSize) || 10, 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where.priceCode = { [Op.like]: `%${query.keyword}%` };
|
||||
}
|
||||
if (query.dispatchType !== undefined && query.dispatchType !== "") where.dispatchType = query.dispatchType;
|
||||
if (query.priceMode !== undefined && query.priceMode !== "") where.priceMode = Number(query.priceMode);
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
|
||||
const { count, rows } = await this.dispatchPriceModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["id", "DESC"]]
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取派单成本价目列表成功",
|
||||
data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) }
|
||||
};
|
||||
}
|
||||
|
||||
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() };
|
||||
}
|
||||
|
||||
async create(data: any, ctx: Context): Promise<any> {
|
||||
if (!data.dispatchType) return { code: 400, message: "派单类型不能为空", data: null };
|
||||
if (data.unitPrice === undefined || data.unitPrice === null || data.unitPrice === "") {
|
||||
return { code: 400, message: "单价不能为空", data: null };
|
||||
}
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
if (payload.priceMode === undefined || payload.priceMode === null) payload.priceMode = 1;
|
||||
if (payload.status === undefined || payload.status === null) payload.status = 1;
|
||||
if (!payload.unit) payload.unit = MODE_UNIT[Number(payload.priceMode)] || "次";
|
||||
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.dispatchPriceModel.create(payload);
|
||||
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() };
|
||||
} catch (_e) {
|
||||
return { code: 400, message: "价格编码已存在", data: null };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
await record.update(payload);
|
||||
|
||||
return { code: 200, message: "更新派单成本价目成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除派单成本价目成功", data: null };
|
||||
}
|
||||
|
||||
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
|
||||
if (status !== 0 && status !== 1) return { code: 400, message: "状态值非法", data: null };
|
||||
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.update({ status });
|
||||
return { code: 200, message: "更新派单成本价目状态成功", data: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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 SysJob from "../entity/sys_job.entity";
|
||||
import SysJobLog from "../entity/sys_job_log.entity";
|
||||
import { JobSchedulerService } from "./job_scheduler.service";
|
||||
import { getBuiltinJob } from "../task/job_registry";
|
||||
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
|
||||
|
||||
@Provide()
|
||||
export class JobService {
|
||||
@InjectRepository(SysJob)
|
||||
jobModel!: Repository<SysJob>;
|
||||
|
||||
@InjectRepository(SysJobLog)
|
||||
jobLogModel!: Repository<SysJobLog>;
|
||||
|
||||
@Inject()
|
||||
scheduler!: JobSchedulerService;
|
||||
|
||||
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
|
||||
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (query.jobName) {
|
||||
where.jobName = { [Op.like]: `%${query.jobName}%` };
|
||||
}
|
||||
|
||||
if (query.status !== undefined && query.status !== null && `${query.status}` !== "") {
|
||||
where.status = Number(query.status);
|
||||
}
|
||||
|
||||
const { count, rows } = await this.jobModel.findAndCountAll({
|
||||
where,
|
||||
order: [["id", "ASC"]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
const list = rows.map(job => {
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
const registered = !!builtin;
|
||||
|
||||
return {
|
||||
...job.toJSON(),
|
||||
description: builtin ? builtin.description : null,
|
||||
paramFields: builtin ? builtin.paramFields : [],
|
||||
registered,
|
||||
nextRunTime: registered && Number(job.status) === 1 ? this.scheduler.nextRunTime(job.cronExpression) : null
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取定时任务列表成功",
|
||||
data: { list, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
|
||||
async update(id: number, dto: UpdateJobDTO): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
return { code: 400, message: "任务实现不存在", data: null };
|
||||
}
|
||||
|
||||
if (!this.scheduler.validateCron(dto.cronExpression)) {
|
||||
return { code: 400, message: "cron 表达式不合法", data: null };
|
||||
}
|
||||
|
||||
const inputParams: Record<string, any> =
|
||||
dto.params && typeof dto.params === "object" ? dto.params : {};
|
||||
const baseParams: Record<string, any> =
|
||||
job.params && typeof job.params === "object" ? job.params : {};
|
||||
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
for (const field of builtin.paramFields) {
|
||||
const hasInput = Object.prototype.hasOwnProperty.call(inputParams, field.key);
|
||||
|
||||
// 未提供:保留原值,其次取注册表默认参数
|
||||
if (!hasInput) {
|
||||
if (Object.prototype.hasOwnProperty.call(baseParams, field.key)) {
|
||||
params[field.key] = baseParams[field.key];
|
||||
} else if (Object.prototype.hasOwnProperty.call(builtin.defaultParams, field.key)) {
|
||||
params[field.key] = builtin.defaultParams[field.key];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = inputParams[field.key];
|
||||
|
||||
if (field.type === "number") {
|
||||
const num = Number(value);
|
||||
if (value === null || value === "" || Number.isNaN(num)) {
|
||||
return { code: 400, message: `参数 ${field.label} 必须为数字`, data: null };
|
||||
}
|
||||
if (field.min !== undefined && num < field.min) {
|
||||
return { code: 400, message: `参数 ${field.label} 取值范围为 ${field.min}-${field.max}`, data: null };
|
||||
}
|
||||
if (field.max !== undefined && num > field.max) {
|
||||
return { code: 400, message: `参数 ${field.label} 取值范围为 ${field.min}-${field.max}`, data: null };
|
||||
}
|
||||
params[field.key] = num;
|
||||
} else {
|
||||
if (typeof value !== "string") {
|
||||
return { code: 400, message: `参数 ${field.label} 必须为字符串`, data: null };
|
||||
}
|
||||
params[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await job.update({
|
||||
cronExpression: dto.cronExpression,
|
||||
params,
|
||||
retryTimes: dto.retryTimes,
|
||||
retryInterval: dto.retryInterval,
|
||||
remark: dto.remark ?? null
|
||||
} as any);
|
||||
|
||||
await this.scheduler.reload(job.jobKey);
|
||||
|
||||
return { code: 200, message: "更新定时任务成功", data: null };
|
||||
}
|
||||
|
||||
/** 启停任务:更新状态后即时重载调度 */
|
||||
async changeStatus(id: number, status: number): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
return { code: 400, message: "任务实现不存在", data: null };
|
||||
}
|
||||
|
||||
await job.update({ status } as any);
|
||||
await this.scheduler.reload(job.jobKey);
|
||||
|
||||
return { code: 200, message: "更新任务状态成功", data: null };
|
||||
}
|
||||
|
||||
/** 立即执行一次(手动触发,trigger_type = 2) */
|
||||
async run(id: number): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
return { code: 400, message: "任务实现不存在", data: null };
|
||||
}
|
||||
|
||||
const result = await this.scheduler.runOnce(Number(id), 2);
|
||||
|
||||
return { code: 200, message: "任务执行完成", data: result };
|
||||
}
|
||||
|
||||
/** 任务执行日志分页查询 */
|
||||
async listLogs(id: number, query: JobLogQueryDTO): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
|
||||
const where: any = { jobId: Number(id) };
|
||||
|
||||
if (query.status !== undefined && query.status !== null && `${query.status}` !== "") {
|
||||
where.status = Number(query.status);
|
||||
}
|
||||
|
||||
if (query.triggerType !== undefined && query.triggerType !== null && `${query.triggerType}` !== "") {
|
||||
where.triggerType = Number(query.triggerType);
|
||||
}
|
||||
|
||||
if (query.startDate || query.endDate) {
|
||||
where.created_at = {};
|
||||
if (query.startDate) {
|
||||
where.created_at[Op.gte] = dayjs(query.startDate).startOf("day").toDate();
|
||||
}
|
||||
if (query.endDate) {
|
||||
where.created_at[Op.lte] = dayjs(query.endDate).endOf("day").toDate();
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows } = await this.jobLogModel.findAndCountAll({
|
||||
where,
|
||||
order: [["created_at", "DESC"]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取任务日志成功",
|
||||
data: { list: rows, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
/** 手动清理早于 days 天的任务日志 */
|
||||
async clearLogs(days: number = 90): Promise<any> {
|
||||
const cutoff = dayjs().subtract(days, "day").toDate();
|
||||
|
||||
const deleted = await this.jobLogModel.destroy({
|
||||
where: {
|
||||
created_at: { [Op.lt]: cutoff }
|
||||
}
|
||||
});
|
||||
|
||||
return { code: 200, message: "清理任务日志成功", data: { deleted } };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { Provide, App, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { Application } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { CronJob, CronTime } from "cron";
|
||||
import { Op } from "sequelize";
|
||||
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 { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
export interface JobRunResult {
|
||||
status: number; // 1成功 2失败 3跳过
|
||||
message: string; // 结果摘要
|
||||
costMs: number; // 本次尝试耗时
|
||||
attempt: number; // 本次是第几次尝试
|
||||
}
|
||||
|
||||
/** 调度时区(固定上海时区) */
|
||||
const TIMEZONE = "Asia/Shanghai";
|
||||
/** 异常堆栈最大存储长度 */
|
||||
const MAX_ERROR_STACK = 4000;
|
||||
|
||||
/**
|
||||
* 自研轻量调度器:任务清单来自代码注册表,调度配置持久化在 sys_job,
|
||||
* 页面修改 cron / 启停后通过 reload 即时生效(无需重启)。
|
||||
*/
|
||||
// 调度器必须是进程内单例:定时器注册表要能被「启动注册」和「接口重载」共享
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Singleton)
|
||||
export class JobSchedulerService {
|
||||
@App()
|
||||
app!: Application;
|
||||
|
||||
@InjectRepository(SysJob)
|
||||
jobModel!: typeof SysJob;
|
||||
|
||||
@InjectRepository(SysJobLog)
|
||||
jobLogModel!: typeof SysJobLog;
|
||||
|
||||
/** jobKey → 定时器 */
|
||||
private timers = new Map<string, CronJob>();
|
||||
/** jobKey → 待执行的重试定时器 */
|
||||
private retryTimers = new Map<string, NodeJS.Timeout>();
|
||||
/** jobKey 重入锁 */
|
||||
private running = new Set<string>();
|
||||
|
||||
/** 私有日志适配:注册表与任务经它输出日志,沿用仓库内 console 风格 */
|
||||
private readonly jobLogger = {
|
||||
info: (m: string, ...a: any[]) => console.log(`[定时任务] ${m}`, ...a),
|
||||
warn: (m: string, ...a: any[]) => console.warn(`[定时任务] ${m}`, ...a),
|
||||
error: (m: string, ...a: any[]) => console.error(`[定时任务] ${m}`, ...a)
|
||||
};
|
||||
|
||||
/** 应用启动时加载全部启用任务并注册调度;失败自动重试,最多 3 次,不阻塞启动 */
|
||||
async start(): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const jobs = await this.jobModel.findAll({ where: { status: 1 } });
|
||||
jobs.forEach(job => this.register(job));
|
||||
this.jobLogger.info(`调度器启动完成,已加载 ${jobs.length} 个任务`);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt < maxAttempts) {
|
||||
this.jobLogger.error(`调度器启动失败(第 ${attempt} 次尝试),5 秒后重试:`, err);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
} else {
|
||||
// 3 次仍失败仅记录,不抛出,避免阻塞应用启动
|
||||
console.error("[定时任务] 调度器启动失败,已达最大重试次数,应用继续启动:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止全部定时器(应用停止时调用) */
|
||||
async stop(): Promise<void> {
|
||||
for (const timer of this.timers.values()) {
|
||||
try {
|
||||
timer.stop();
|
||||
} catch (err) {
|
||||
console.warn("[定时任务] 停止定时器失败:", err);
|
||||
}
|
||||
}
|
||||
this.timers.clear();
|
||||
|
||||
for (const retryTimer of this.retryTimers.values()) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
this.retryTimers.clear();
|
||||
}
|
||||
|
||||
/** 单个任务重新加载:停旧 → 按最新配置重新注册(停用则不注册) */
|
||||
async reload(jobKey: string): Promise<void> {
|
||||
this.unregister(jobKey);
|
||||
|
||||
const job = await this.jobModel.findOne({ where: { jobKey } });
|
||||
if (!job || Number(job.status) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.register(job);
|
||||
}
|
||||
|
||||
/** 立即执行一次入口(手动触发同样按配置重试,但不阻塞调用方) */
|
||||
async runOnce(jobId: number, triggerType: number): Promise<JobRunResult> {
|
||||
return this.execute(jobId, triggerType);
|
||||
}
|
||||
|
||||
/** 校验 cron 表达式合法性 */
|
||||
validateCron(expression: string): boolean {
|
||||
if (!expression || typeof expression !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new CronTime(expression, TIMEZONE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算下次执行时间;表达式非法返回 null */
|
||||
nextRunTime(cronExpression: string): Date | null {
|
||||
if (!this.validateCron(cronExpression)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// luxon 3.x 中 toDate 已移除,使用 toJSDate
|
||||
return new CronTime(cronExpression, TIMEZONE).sendAt().toJSDate();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||
async cleanJobLogs(keepDays: number): Promise<number> {
|
||||
const cutoff = dayjs().subtract(keepDays, "day").toDate();
|
||||
|
||||
return this.jobLogModel.destroy({
|
||||
where: {
|
||||
created_at: { [Op.lt]: cutoff }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 按任务配置注册定时器 */
|
||||
private register(job: SysJob): void {
|
||||
this.unregister(job.jobKey);
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
this.jobLogger.warn(`任务 ${job.jobKey} 未在注册表中实现,跳过调度注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateCron(job.cronExpression)) {
|
||||
this.jobLogger.warn(`任务 ${job.jobKey} 的 cron 表达式不合法(${job.cronExpression}),跳过调度注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timer = new CronJob(
|
||||
job.cronExpression,
|
||||
() => {
|
||||
void this.execute(job.id, 1);
|
||||
},
|
||||
null,
|
||||
true,
|
||||
TIMEZONE
|
||||
);
|
||||
this.timers.set(job.jobKey, timer);
|
||||
this.jobLogger.info(`任务 ${job.jobKey} 已注册调度,cron=${job.cronExpression}`);
|
||||
} catch (err) {
|
||||
this.jobLogger.warn(`任务 ${job.jobKey} 定时器创建失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注销定时器与待执行的重试定时器(不清重入锁) */
|
||||
private unregister(jobKey: string): void {
|
||||
const timer = this.timers.get(jobKey);
|
||||
if (timer) {
|
||||
try {
|
||||
timer.stop();
|
||||
} catch (err) {
|
||||
console.warn(`[定时任务] 停止任务 ${jobKey} 定时器失败:`, err);
|
||||
}
|
||||
this.timers.delete(jobKey);
|
||||
}
|
||||
|
||||
const retryTimer = this.retryTimers.get(jobKey);
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
this.retryTimers.delete(jobKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行内核:重入保护 → 执行尝试 → 失败按配置进入重试链 */
|
||||
private async execute(jobId: number, triggerType: number): Promise<JobRunResult> {
|
||||
const job = await this.jobModel.findByPk(jobId);
|
||||
if (!job) {
|
||||
return { status: 2, message: "任务不存在", costMs: 0, attempt: 0 };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
const now = new Date();
|
||||
await this.writeJobLog({
|
||||
job,
|
||||
triggerType,
|
||||
status: 2,
|
||||
attempt: 1,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
costMs: 0,
|
||||
message: "任务实现不存在",
|
||||
errorStack: null
|
||||
});
|
||||
await this.writeLastRun(job, { status: 2, message: "任务实现不存在", costMs: 0, attempt: 1 });
|
||||
return { status: 2, message: "任务实现不存在", costMs: 0, attempt: 1 };
|
||||
}
|
||||
|
||||
// 并发保护:上一次尚未结束则本次跳过,且不改写 sys_job.last_*
|
||||
if (this.running.has(job.jobKey)) {
|
||||
const message = "上一次执行尚未结束,本次跳过";
|
||||
const now = new Date();
|
||||
await this.writeJobLog({
|
||||
job,
|
||||
triggerType,
|
||||
status: 3,
|
||||
attempt: 1,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
costMs: 0,
|
||||
message,
|
||||
errorStack: null
|
||||
});
|
||||
return { status: 3, message, costMs: 0, attempt: 1 };
|
||||
}
|
||||
|
||||
this.running.add(job.jobKey);
|
||||
// 失败且需重试时把锁移交给重试链,本次不释放
|
||||
let handedOverToRetry = false;
|
||||
|
||||
try {
|
||||
const result = await this.runAttempt(job, builtin, triggerType, 1);
|
||||
|
||||
if (result.status !== 1 && Number(job.retryTimes) > 0) {
|
||||
handedOverToRetry = true;
|
||||
this.scheduleRetry(job, 2);
|
||||
} else {
|
||||
await this.writeLastRun(job, result);
|
||||
}
|
||||
|
||||
return { status: result.status, message: result.message, costMs: result.costMs, attempt: result.attempt };
|
||||
} catch (err) {
|
||||
const e = err as any;
|
||||
this.jobLogger.error(`任务 ${job.jobKey} 执行流程异常:`, err);
|
||||
return { status: 2, message: e?.message ? String(e.message) : "执行失败", costMs: 0, attempt: 1 };
|
||||
} finally {
|
||||
if (!handedOverToRetry) {
|
||||
this.running.delete(job.jobKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重试链:失败重试(trigger_type = 3),重试期间保持重入锁 */
|
||||
private async continueChain(jobId: number, attempt: number, jobKeyHint?: string): Promise<void> {
|
||||
let jobKey = jobKeyHint;
|
||||
let handedOverToRetry = false;
|
||||
|
||||
try {
|
||||
const job = await this.jobModel.findByPk(jobId);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
jobKey = job.jobKey;
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
const now = new Date();
|
||||
await this.writeJobLog({
|
||||
job,
|
||||
triggerType: 3,
|
||||
status: 2,
|
||||
attempt,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
costMs: 0,
|
||||
message: "任务实现不存在",
|
||||
errorStack: null
|
||||
});
|
||||
await this.writeLastRun(job, { status: 2, message: "任务实现不存在", costMs: 0, attempt });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.runAttempt(job, builtin, 3, attempt);
|
||||
|
||||
if (result.status !== 1 && attempt <= Number(job.retryTimes)) {
|
||||
handedOverToRetry = true;
|
||||
this.scheduleRetry(job, attempt + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重试链结束(成功或超出重试次数)才回写任务的 last_run_*
|
||||
await this.writeLastRun(job, result);
|
||||
} catch (err) {
|
||||
this.jobLogger.error(`任务重试执行异常:`, err);
|
||||
} finally {
|
||||
if (!handedOverToRetry && jobKey) {
|
||||
this.running.delete(jobKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 单次尝试:执行 handler 并写入一条执行日志 */
|
||||
private async runAttempt(
|
||||
job: SysJob,
|
||||
builtin: BuiltinJob,
|
||||
triggerType: number,
|
||||
attempt: number
|
||||
): Promise<JobRunResult & { error?: any }> {
|
||||
const startedAt = new Date();
|
||||
const startMs = Date.now();
|
||||
let status = 1;
|
||||
let message = "执行成功";
|
||||
let errorStack: string | null = null;
|
||||
let error: any;
|
||||
|
||||
try {
|
||||
const ctx: JobHandlerContext = {
|
||||
jobKey: job.jobKey,
|
||||
jobName: job.jobName,
|
||||
triggerType,
|
||||
attempt,
|
||||
runtime: this.buildRuntime()
|
||||
};
|
||||
const ret = await builtin.handler(job.params ?? {}, ctx);
|
||||
message = typeof ret === "string" && ret ? ret : "执行成功";
|
||||
} catch (err) {
|
||||
const e = err as any;
|
||||
status = 2;
|
||||
message = e?.message ? String(e.message) : "执行失败";
|
||||
errorStack = (e?.stack ? String(e.stack) : String(e)).slice(0, MAX_ERROR_STACK);
|
||||
error = err;
|
||||
}
|
||||
|
||||
const finishedAt = new Date();
|
||||
const costMs = Date.now() - startMs;
|
||||
|
||||
await this.writeJobLog({ job, triggerType, status, attempt, startedAt, finishedAt, costMs, message, errorStack });
|
||||
|
||||
return { status, message, costMs, attempt, error };
|
||||
}
|
||||
|
||||
/** 写入一条执行日志(写入失败不影响任务执行) */
|
||||
private async writeJobLog(fields: {
|
||||
job: SysJob;
|
||||
triggerType: number;
|
||||
status: number;
|
||||
attempt: number;
|
||||
startedAt: Date;
|
||||
finishedAt: Date;
|
||||
costMs: number;
|
||||
message: string;
|
||||
errorStack: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.jobLogModel.create({
|
||||
jobId: fields.job.id,
|
||||
jobKey: fields.job.jobKey,
|
||||
jobName: fields.job.jobName,
|
||||
triggerType: fields.triggerType,
|
||||
status: fields.status,
|
||||
attempt: fields.attempt,
|
||||
startedAt: fields.startedAt,
|
||||
finishedAt: fields.finishedAt,
|
||||
costMs: fields.costMs,
|
||||
message: fields.message,
|
||||
errorStack: fields.errorStack
|
||||
} as any);
|
||||
} catch (err) {
|
||||
this.jobLogger.error(`任务 ${fields.job.jobKey} 执行日志写入失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** 回写任务上次执行信息 */
|
||||
private async writeLastRun(job: SysJob, result: JobRunResult): Promise<void> {
|
||||
await this.jobModel.update(
|
||||
{
|
||||
lastRunAt: new Date(),
|
||||
lastRunStatus: result.status,
|
||||
lastRunCostMs: result.costMs,
|
||||
lastError: result.status === 1 ? null : result.message
|
||||
} as any,
|
||||
{ where: { id: job.id } }
|
||||
);
|
||||
}
|
||||
|
||||
/** 排定一次重试(重试期间保持重入锁) */
|
||||
private scheduleRetry(job: SysJob, attempt: number): void {
|
||||
const intervalSec = Math.max(10, Number(job.retryInterval) || 60);
|
||||
|
||||
const retryTimer = setTimeout(() => {
|
||||
this.retryTimers.delete(job.jobKey);
|
||||
void this.continueChain(job.id, attempt, job.jobKey);
|
||||
}, intervalSec * 1000);
|
||||
|
||||
this.retryTimers.set(job.jobKey, retryTimer);
|
||||
}
|
||||
|
||||
/** 构造注入给任务的运行时能力 */
|
||||
private buildRuntime(): JobRuntime {
|
||||
return {
|
||||
logger: this.jobLogger,
|
||||
// LogService 是请求作用域,单例中改为按需从匿名请求上下文获取
|
||||
cleanOperationLogs: async (keepDays: number) => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const logService = (await ctx.requestContext.getAsync(LogService)) as LogService;
|
||||
return logService.cleanExpiredLogs(keepDays);
|
||||
},
|
||||
cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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";
|
||||
|
||||
// 通知类型:证照到期提醒
|
||||
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;
|
||||
|
||||
// 归一化 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;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class NoticeService {
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userModel!: Repository<SysUser>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
// 我的通知列表:未读优先,可选按已读状态筛选
|
||||
async listMy(userId: 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 };
|
||||
if (query.isRead !== undefined && query.isRead !== "") {
|
||||
where.isRead = Number(query.isRead);
|
||||
}
|
||||
|
||||
const { count, rows } = await this.noticeModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
// 未读在前,其次按时间倒序
|
||||
order: [["is_read", "ASC"], ["id", "DESC"]]
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取通知列表成功",
|
||||
data: {
|
||||
list: rows.map((r) => r.toJSON()),
|
||||
total: count,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 未读数量(顶栏红点)
|
||||
async unreadCount(userId: number): Promise<any> {
|
||||
const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取未读通知数成功",
|
||||
data: { count }
|
||||
};
|
||||
}
|
||||
|
||||
// 单条标记已读(仅能操作自己的通知)
|
||||
async markRead(userId: number, id: number): Promise<any> {
|
||||
const notice = await this.noticeModel.findOne({ where: { id, receiverId: userId } });
|
||||
|
||||
if (!notice) {
|
||||
return {
|
||||
code: 404,
|
||||
message: "通知不存在",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
if (Number(notice.isRead) !== 1) {
|
||||
await notice.update({ isRead: 1 });
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "标记已读成功",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// 全部标记已读
|
||||
async markAllRead(userId: number): Promise<any> {
|
||||
const updated = await this.noticeModel.update({ isRead: 1 }, { where: { receiverId: userId, isRead: 0 } });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "全部标记已读成功",
|
||||
data: { updated: Array.isArray(updated) ? updated[0] : 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// 查询拥有 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 [];
|
||||
|
||||
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 users = await this.userModel.findAll({
|
||||
where: { id: { [Op.in]: userIds } },
|
||||
attributes: ["id"]
|
||||
});
|
||||
|
||||
return users.map((u) => Number(u.id));
|
||||
}
|
||||
|
||||
// 扫描 30 天内到期或已过期的电工资质证照,向所有 ADMIN 推送站内通知(幂等)
|
||||
async scanCertExpireAndNotify(): Promise<{ scanned: number; created: number }> {
|
||||
let scanned = 0;
|
||||
let created = 0;
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// 清理指定证书的到期通知(证书续期/删除后调用,允许重新提醒)
|
||||
async clearByCertIds(certIds: number[]): Promise<number> {
|
||||
if (!certIds || certIds.length === 0) return 0;
|
||||
|
||||
return await this.noticeModel.destroy({
|
||||
where: {
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ServicePrice from "../entity/service_price.entity";
|
||||
|
||||
// 创建/更新可写入字段白名单(priceCode 只读,服务端生成)
|
||||
const WRITABLE_FIELDS = ["priceName", "serviceType", "priceMode", "unitPrice", "unit", "status", "remark"];
|
||||
// 计价方式 → 默认单位兜底
|
||||
const MODE_UNIT: Record<number, string> = { 1: "次", 2: "小时", 3: "个" };
|
||||
|
||||
@Provide()
|
||||
export class ServicePriceService {
|
||||
@InjectRepository(ServicePrice)
|
||||
servicePriceModel!: Repository<ServicePrice>;
|
||||
|
||||
// 价目编码规则:FW + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次
|
||||
private async generatePriceCode(): Promise<string> {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
|
||||
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const prefix = `FW${yyyymmdd}`;
|
||||
|
||||
const count = await this.servicePriceModel.count({ where: { priceCode: { [Op.like]: `${prefix}%` } } });
|
||||
let seq = count + 1;
|
||||
|
||||
for (let i = 0; i <= 1; i++) {
|
||||
const code = `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
const exists = await this.servicePriceModel.findOne({ where: { priceCode: code } });
|
||||
if (!exists) return code;
|
||||
seq++;
|
||||
}
|
||||
throw new Error("价目编码生成冲突,请重试");
|
||||
}
|
||||
|
||||
// 仅保留白名单字段
|
||||
private pickWritable(data: any): any {
|
||||
const payload: any = {};
|
||||
for (const key of WRITABLE_FIELDS) {
|
||||
if (data[key] !== undefined) payload[key] = data[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Math.min(Number(query.pageSize) || 10, 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ priceCode: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ priceName: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.priceMode !== undefined && query.priceMode !== "") where.priceMode = Number(query.priceMode);
|
||||
if (query.serviceType !== undefined && query.serviceType !== "") where.serviceType = query.serviceType;
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
|
||||
const { count, rows } = await this.servicePriceModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["id", "DESC"]]
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取服务价目列表成功",
|
||||
data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) }
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: number): Promise<any> {
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
return { code: 200, message: "获取服务价目详情成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async create(data: any, ctx: Context): Promise<any> {
|
||||
if (!data.priceName) return { code: 400, message: "价目名称不能为空", data: null };
|
||||
if (data.unitPrice === undefined || data.unitPrice === null || data.unitPrice === "") {
|
||||
return { code: 400, message: "单价不能为空", data: null };
|
||||
}
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
if (payload.priceMode === undefined || payload.priceMode === null) payload.priceMode = 1;
|
||||
if (payload.status === undefined || payload.status === null) payload.status = 1;
|
||||
if (!payload.unit) payload.unit = MODE_UNIT[Number(payload.priceMode)] || "次";
|
||||
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.servicePriceModel.create(payload);
|
||||
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.servicePriceModel.create(payload);
|
||||
return { code: 200, message: "创建服务价目成功", data: created.toJSON() };
|
||||
} catch (_e) {
|
||||
return { code: 400, message: "价目编码已存在", data: null };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
await record.update(payload);
|
||||
|
||||
return { code: 200, message: "更新服务价目成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除服务价目成功", data: null };
|
||||
}
|
||||
|
||||
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
|
||||
if (status !== 0 && status !== 1) return { code: 400, message: "状态值非法", data: null };
|
||||
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.update({ status });
|
||||
return { code: 200, message: "更新服务价目状态成功", data: null };
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@ import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import dayjs from "dayjs";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
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";
|
||||
|
||||
// 电工状态:1在岗 0离职
|
||||
const STATUS_ON_DUTY = 1;
|
||||
@@ -12,41 +19,421 @@ const RECOMMEND_LIMIT = 5;
|
||||
// 经验分基准场次(达到即满分)
|
||||
const EXP_BASE_COUNT = 50;
|
||||
|
||||
// 审核状态:0待审核 1已通过 2已驳回
|
||||
const AUDIT_PENDING = 0;
|
||||
const AUDIT_APPROVED = 1;
|
||||
const AUDIT_REJECTED = 2;
|
||||
|
||||
// 资质状态:none无证书 normal正常 expiring临期 expired已过期
|
||||
const CERT_STATUS_NONE = "none";
|
||||
const CERT_STATUS_NORMAL = "normal";
|
||||
const CERT_STATUS_EXPIRING = "expiring";
|
||||
const CERT_STATUS_EXPIRED = "expired";
|
||||
|
||||
// 临期预警天数(today <= expireDate <= today+30 视为临期)
|
||||
const CERT_EXPIRE_WARN_DAYS = 30;
|
||||
|
||||
// 到期提醒通知标识
|
||||
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
|
||||
const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert";
|
||||
|
||||
// 默认登录密码:手机号后 6 位,不足 6 位用 123456
|
||||
function getDefaultPassword(phone: string): string {
|
||||
return phone.length >= 6 ? phone.slice(-6) : "123456";
|
||||
}
|
||||
|
||||
// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效)
|
||||
function toDateOnly(value: any): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const str = String(value);
|
||||
return str.length >= 10 ? str.slice(0, 10) : str;
|
||||
}
|
||||
|
||||
// 按电工分组证书
|
||||
function groupCertsByWorker(certs: WorkerCert[]): Map<number, WorkerCert[]> {
|
||||
const map = new Map<number, WorkerCert[]>();
|
||||
for (const cert of certs) {
|
||||
const key = Number(cert.workerId);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(cert);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// 由证书的 expire_date 推导资质状态,优先级 expired > expiring > normal > none
|
||||
function computeCertStatus(certs: Array<{ expireDate?: any }>, today: string, warn: string): string {
|
||||
if (!certs || certs.length === 0) return CERT_STATUS_NONE;
|
||||
let hasExpiring = false;
|
||||
for (const cert of certs) {
|
||||
const expire = toDateOnly(cert.expireDate);
|
||||
// expire_date 为 NULL 视为长期有效,计为正常
|
||||
if (!expire) continue;
|
||||
// YYYY-MM-DD 字符串可直接字典序比较
|
||||
if (expire < today) return CERT_STATUS_EXPIRED;
|
||||
if (expire <= warn) hasExpiring = true;
|
||||
}
|
||||
return hasExpiring ? CERT_STATUS_EXPIRING : CERT_STATUS_NORMAL;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class WorkerService {
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
// 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
@InjectRepository(SurveyOrder)
|
||||
surveyOrderModel!: Repository<SurveyOrder>;
|
||||
|
||||
@InjectRepository(InstallOrder)
|
||||
installOrderModel!: Repository<InstallOrder>;
|
||||
|
||||
// 资质状态推导所需的日期边界
|
||||
private certDateRange(): { today: string; warn: string } {
|
||||
return {
|
||||
today: dayjs().format("YYYY-MM-DD"),
|
||||
warn: dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD")
|
||||
};
|
||||
}
|
||||
|
||||
// 剔除敏感字段
|
||||
private cleanWorker(worker: Worker): any {
|
||||
const data = worker.toJSON ? worker.toJSON() : { ...worker };
|
||||
delete data.passwordHash;
|
||||
return data;
|
||||
}
|
||||
|
||||
// 证书列表摘要(列表页使用)
|
||||
private toCertSummary(certs: WorkerCert[]): any[] {
|
||||
return certs.map((c) => ({ id: c.id, certType: c.certType, certNo: c.certNo, expireDate: c.expireDate }));
|
||||
}
|
||||
|
||||
// 一次性批量加载证书并按电工分组,避免 N+1
|
||||
private async loadCertMap(workerIds: number[]): Promise<Map<number, WorkerCert[]>> {
|
||||
if (!workerIds || workerIds.length === 0) return new Map();
|
||||
const certs = await this.certModel.findAll({ where: { workerId: { [Op.in]: workerIds } } });
|
||||
return groupCertsByWorker(certs);
|
||||
}
|
||||
|
||||
// 清理证照到期提醒(续期/删除后允许重新提醒)
|
||||
private async clearCertNotices(certIds: number[]): Promise<void> {
|
||||
if (!certIds || certIds.length === 0) return;
|
||||
await this.noticeModel.destroy({
|
||||
where: {
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 构建证书写入字段
|
||||
private buildCertPayload(workerId: number, dto: WorkerCertDTO): any {
|
||||
return {
|
||||
workerId,
|
||||
certType: dto.certType,
|
||||
certNo: dto.certNo,
|
||||
issueAuthority: dto.issueAuthority,
|
||||
issueDate: dto.issueDate,
|
||||
expireDate: dto.expireDate,
|
||||
certImageUrl: dto.certImageUrl
|
||||
};
|
||||
}
|
||||
|
||||
// 列表:姓名/手机号模糊检索 + 状态/审核状态/资质状态过滤 + 分页
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize || query.limit) || 10;
|
||||
const rawPageSize = Number(query.pageSize || query.limit) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ workerName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ phone: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ certInfo: { [Op.like]: `%${query.keyword}%` } }
|
||||
{ phone: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
if (query.auditStatus !== undefined && query.auditStatus !== "") where.auditStatus = Number(query.auditStatus);
|
||||
|
||||
const certStatus: string = query.certStatus || "";
|
||||
const { today, warn } = this.certDateRange();
|
||||
|
||||
// 资质状态筛选:先取全部命中基础条件的电工,内存推导状态后过滤再分页(保证 total 正确)
|
||||
if (certStatus) {
|
||||
const rows = await this.workerModel.findAll({
|
||||
where,
|
||||
attributes: { exclude: ["passwordHash"] },
|
||||
order: [["rating", "DESC"]]
|
||||
});
|
||||
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
|
||||
const filtered = rows.filter((r) => computeCertStatus(certMap.get(Number(r.id)) || [], today, warn) === certStatus);
|
||||
const pageRows = filtered.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize);
|
||||
const list = pageRows.map((r) => {
|
||||
const certs = certMap.get(Number(r.id)) || [];
|
||||
return {
|
||||
...this.cleanWorker(r),
|
||||
certStatus: computeCertStatus(certs, today, warn),
|
||||
certs: this.toCertSummary(certs)
|
||||
};
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取共享电工列表成功",
|
||||
data: { list, total: filtered.length, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
// 常规分页:仅对当前页电工批量加载证书
|
||||
const { count, rows } = await this.workerModel.findAndCountAll({
|
||||
where,
|
||||
attributes: { exclude: ["passwordHash"] },
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["rating", "DESC"]],
|
||||
distinct: true
|
||||
});
|
||||
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
|
||||
const list = rows.map((r) => {
|
||||
const certs = certMap.get(Number(r.id)) || [];
|
||||
return {
|
||||
...this.cleanWorker(r),
|
||||
certStatus: computeCertStatus(certs, today, warn),
|
||||
certs: this.toCertSummary(certs)
|
||||
};
|
||||
});
|
||||
|
||||
return { code: 200, message: "获取共享电工列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page, pageSize } };
|
||||
return { code: 200, message: "获取共享电工列表成功", data: { list, total: count, page, pageSize } };
|
||||
}
|
||||
|
||||
// 下拉选项:仅在岗电工
|
||||
// KPI 统计:总电工数 / 待审核 / 临期 / 已过期
|
||||
async stats(): Promise<any> {
|
||||
const total = await this.workerModel.count();
|
||||
const pendingAudit = await this.workerModel.count({ where: { auditStatus: AUDIT_PENDING } });
|
||||
|
||||
// 一次性取出全部证书,内存按电工分组统计(避免 N+1)
|
||||
const workers = await this.workerModel.findAll({ attributes: ["id"] });
|
||||
const validIds = new Set(workers.map((w) => Number(w.id)));
|
||||
const certs = await this.certModel.findAll({ attributes: ["workerId", "expireDate"] });
|
||||
const certMap = groupCertsByWorker(certs);
|
||||
const { today, warn } = this.certDateRange();
|
||||
|
||||
let expiring = 0;
|
||||
let expired = 0;
|
||||
for (const [workerId, list] of certMap) {
|
||||
if (!validIds.has(workerId)) continue;
|
||||
const status = computeCertStatus(list, today, warn);
|
||||
if (status === CERT_STATUS_EXPIRING) expiring += 1;
|
||||
else if (status === CERT_STATUS_EXPIRED) expired += 1;
|
||||
}
|
||||
|
||||
return { code: 200, message: "获取电工统计成功", data: { total, pendingAudit, expiring, expired } };
|
||||
}
|
||||
|
||||
// 详情:含完整证书数组
|
||||
async detail(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id, { attributes: { exclude: ["passwordHash"] } });
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
const certs = await this.certModel.findAll({ where: { workerId: id }, order: [["expireDate", "ASC"]] });
|
||||
const { today, warn } = this.certDateRange();
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取电工详情成功",
|
||||
data: {
|
||||
...this.cleanWorker(worker),
|
||||
certStatus: computeCertStatus(certs, today, warn),
|
||||
certs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 新增电工:手机号唯一,审核状态固定待审核、状态默认离职(0)
|
||||
async create(dto: WorkerCreateDTO): Promise<any> {
|
||||
const phone = dto.phone;
|
||||
const existing = await this.workerModel.findOne({ where: { phone }, attributes: ["id"] });
|
||||
if (existing) {
|
||||
return { code: 400, message: "手机号已存在", data: null };
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
workerName: dto.workerName,
|
||||
phone,
|
||||
idCard: dto.idCard,
|
||||
workerTitle: dto.workerTitle,
|
||||
status: 0,
|
||||
auditStatus: AUDIT_PENDING
|
||||
};
|
||||
if (dto.rating !== undefined) payload.rating = dto.rating;
|
||||
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
|
||||
// 开通电工APP账号:默认密码为手机号后 6 位
|
||||
if (dto.withAccount === true) payload.passwordHash = await hashPassword(getDefaultPassword(phone));
|
||||
|
||||
const worker = await this.workerModel.create(payload);
|
||||
|
||||
if (Array.isArray(dto.certs) && dto.certs.length > 0) {
|
||||
await this.certModel.bulkCreate(dto.certs.map((c) => this.buildCertPayload(Number(worker.id), c)) as any);
|
||||
}
|
||||
|
||||
return { code: 200, message: "创建电工成功", data: this.cleanWorker(worker) };
|
||||
}
|
||||
|
||||
// 更新电工:字段白名单;证书存在时整体覆盖
|
||||
async update(id: number, dto: WorkerUpdateDTO): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
if (dto.phone !== undefined && dto.phone !== worker.phone) {
|
||||
const conflict = await this.workerModel.findOne({ where: { phone: dto.phone }, attributes: ["id"] });
|
||||
if (conflict && Number(conflict.id) !== Number(id)) {
|
||||
return { code: 400, message: "手机号已存在", data: null };
|
||||
}
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
if (dto.workerName !== undefined) payload.workerName = dto.workerName;
|
||||
if (dto.phone !== undefined) payload.phone = dto.phone;
|
||||
if (dto.idCard !== undefined) payload.idCard = dto.idCard;
|
||||
if (dto.workerTitle !== undefined) payload.workerTitle = dto.workerTitle;
|
||||
if (dto.status !== undefined) payload.status = dto.status;
|
||||
if (dto.rating !== undefined) payload.rating = dto.rating;
|
||||
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
|
||||
|
||||
await worker.update(payload);
|
||||
|
||||
// 整体覆盖证书:先删后插,并清理被删证书的到期提醒
|
||||
if (Array.isArray(dto.certs)) {
|
||||
const oldCerts = await this.certModel.findAll({ where: { workerId: id }, attributes: ["id"] });
|
||||
await this.clearCertNotices(oldCerts.map((c) => Number(c.id)));
|
||||
await this.certModel.destroy({ where: { workerId: id } });
|
||||
if (dto.certs.length > 0) {
|
||||
await this.certModel.bulkCreate(dto.certs.map((c) => this.buildCertPayload(Number(id), c)) as any);
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) };
|
||||
}
|
||||
|
||||
// 删除电工:存在派单记录则拒绝;否则级联删除证书并清理通知
|
||||
async remove(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
const [surveyRef, installRef] = await Promise.all([
|
||||
this.surveyOrderModel.findOne({ where: { workerId: id }, attributes: ["id"] }),
|
||||
this.installOrderModel.findOne({ where: { workerId: id }, attributes: ["id"] })
|
||||
]);
|
||||
if (surveyRef || installRef) {
|
||||
return { code: 400, message: "该电工存在派单记录,无法删除", data: null };
|
||||
}
|
||||
|
||||
const certs = await this.certModel.findAll({ where: { workerId: id }, attributes: ["id"] });
|
||||
await this.clearCertNotices(certs.map((c) => Number(c.id)));
|
||||
await this.certModel.destroy({ where: { workerId: id } });
|
||||
await worker.destroy();
|
||||
|
||||
return { code: 200, message: "删除电工成功", data: null };
|
||||
}
|
||||
|
||||
// 资质审核
|
||||
async audit(id: number, dto: WorkerAuditDTO, operatorUserId: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
const approved = dto.approved === true;
|
||||
if (approved) {
|
||||
await worker.update({
|
||||
auditStatus: AUDIT_APPROVED,
|
||||
auditBy: operatorUserId,
|
||||
auditAt: new Date(),
|
||||
auditRemark: null,
|
||||
certVerified: true
|
||||
});
|
||||
} else {
|
||||
const remark = (dto.remark || "").trim();
|
||||
if (!remark) {
|
||||
return { code: 400, message: "请填写驳回理由", data: null };
|
||||
}
|
||||
await worker.update({
|
||||
auditStatus: AUDIT_REJECTED,
|
||||
auditRemark: remark,
|
||||
auditBy: operatorUserId,
|
||||
auditAt: new Date(),
|
||||
certVerified: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: approved ? "审核通过成功" : "审核驳回成功",
|
||||
data: this.cleanWorker(worker)
|
||||
};
|
||||
}
|
||||
|
||||
// 证书新增/更新(certId 缺省为新增)
|
||||
async saveCert(workerId: number, dto: WorkerCertDTO, certId?: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId, { attributes: ["id"] });
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
if (certId !== undefined) {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || Number(cert.workerId) !== Number(workerId)) {
|
||||
return { code: 403, message: "无权操作该证书", data: null };
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
if (dto.certType !== undefined) payload.certType = dto.certType;
|
||||
if (dto.certNo !== undefined) payload.certNo = dto.certNo;
|
||||
if (dto.issueAuthority !== undefined) payload.issueAuthority = dto.issueAuthority;
|
||||
if (dto.issueDate !== undefined) payload.issueDate = dto.issueDate;
|
||||
if (dto.certImageUrl !== undefined) payload.certImageUrl = dto.certImageUrl;
|
||||
// 有效期变更时清理旧到期提醒,便于续期后重新提醒
|
||||
if (dto.expireDate !== undefined) {
|
||||
payload.expireDate = dto.expireDate;
|
||||
if (toDateOnly(dto.expireDate) !== toDateOnly(cert.expireDate)) {
|
||||
await this.clearCertNotices([certId]);
|
||||
}
|
||||
}
|
||||
await cert.update(payload);
|
||||
|
||||
return { code: 200, message: "更新证书成功", data: cert };
|
||||
}
|
||||
|
||||
const created = await this.certModel.create(this.buildCertPayload(workerId, dto) as any);
|
||||
return { code: 200, message: "新增证书成功", data: created };
|
||||
}
|
||||
|
||||
// 删除证书(校验归属)
|
||||
async removeCert(workerId: number, certId: number): Promise<any> {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || Number(cert.workerId) !== Number(workerId)) {
|
||||
return { code: 403, message: "无权操作该证书", data: null };
|
||||
}
|
||||
|
||||
await this.clearCertNotices([certId]);
|
||||
await cert.destroy();
|
||||
|
||||
return { code: 200, message: "删除证书成功", data: null };
|
||||
}
|
||||
|
||||
// 下拉选项:仅在岗且审核通过的电工
|
||||
async options(ctx: Context): Promise<any> {
|
||||
const rows = await this.workerModel.findAll({
|
||||
where: { status: STATUS_ON_DUTY },
|
||||
where: { status: STATUS_ON_DUTY, auditStatus: AUDIT_APPROVED },
|
||||
attributes: ["id", "workerName", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"],
|
||||
order: [["rating", "DESC"]]
|
||||
});
|
||||
@@ -55,7 +442,7 @@ export class WorkerService {
|
||||
|
||||
// 智能推荐:评分 50% + 资质 30% + 经验 20% 加权,取前 5 名
|
||||
async recommend(query: any, ctx: Context): Promise<any> {
|
||||
const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY } });
|
||||
const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY, auditStatus: AUDIT_APPROVED } });
|
||||
if (rows.length === 0) return { code: 200, message: "获取推荐电工成功", data: [] };
|
||||
|
||||
const certKeyword: string = query.certKeyword || "";
|
||||
@@ -95,4 +482,4 @@ export class WorkerService {
|
||||
|
||||
return { code: 200, message: "获取推荐电工成功", data };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
import { TokenUtil } from "../util/token";
|
||||
import { hashPassword, comparePassword } from "../util/crypto";
|
||||
import {
|
||||
WorkerRegisterDTO,
|
||||
WorkerLoginDTO,
|
||||
WorkerProfileUpdateDTO,
|
||||
WorkerAppCertDTO
|
||||
} from "../dto/worker_app.dto";
|
||||
|
||||
// 资质临期提醒阈值(天)
|
||||
const EXPIRING_DAYS = 30;
|
||||
// 证照到期通知类型
|
||||
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
|
||||
// 证照业务类型
|
||||
const BIZ_TYPE_WORKER_CERT = "worker_cert";
|
||||
|
||||
@Provide()
|
||||
export class WorkerAppService {
|
||||
@Inject()
|
||||
tokenUtil!: TokenUtil;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
// 电工注册:手机号唯一校验,初始为待审核(0)且离职(0)
|
||||
async register(dto: WorkerRegisterDTO): Promise<any> {
|
||||
const exist = await this.workerModel.findOne({ where: { phone: dto.phone } });
|
||||
if (exist) {
|
||||
return { code: 400, message: "手机号已注册" };
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(dto.password);
|
||||
const worker = await this.workerModel.create({
|
||||
workerName: dto.workerName,
|
||||
phone: dto.phone,
|
||||
idCard: dto.idCard || null,
|
||||
workerTitle: dto.workerTitle || null,
|
||||
passwordHash,
|
||||
auditStatus: 0,
|
||||
status: 0
|
||||
} as any);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "注册成功,请等待审核",
|
||||
data: this.basicInfo(worker)
|
||||
};
|
||||
}
|
||||
|
||||
// 电工登录:不因审核状态拦截,允许未通过/离职电工登录查看审核与资质
|
||||
async login(dto: WorkerLoginDTO): Promise<any> {
|
||||
const worker = await this.workerModel.findOne({ where: { phone: dto.phone } });
|
||||
if (!worker || !worker.passwordHash) {
|
||||
return { code: 400, message: "手机号或密码错误" };
|
||||
}
|
||||
|
||||
const isPasswordValid = await comparePassword(dto.password, worker.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
return { code: 400, message: "手机号或密码错误" };
|
||||
}
|
||||
|
||||
const token = this.tokenUtil.generateWorkerToken({
|
||||
workerId: worker.id,
|
||||
phone: worker.phone
|
||||
});
|
||||
|
||||
await worker.update({ lastLoginAt: new Date() });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "登录成功",
|
||||
data: {
|
||||
token,
|
||||
workerInfo: this.basicInfo(worker)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 个人资料:含审核信息与资质状态汇总
|
||||
async profile(workerId: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在" };
|
||||
}
|
||||
|
||||
const certs = await this.listCertRows(workerId);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取成功",
|
||||
data: {
|
||||
...this.basicInfo(worker),
|
||||
idCard: worker.idCard,
|
||||
auditStatus: worker.auditStatus,
|
||||
auditRemark: worker.auditRemark,
|
||||
certStatus: this.calcCertStatus(certs),
|
||||
certs: certs.map(cert => cert.toJSON())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 更新个人资料:可改姓名/身份证/职衔,传 password 则重置密码
|
||||
async updateProfile(workerId: number, dto: WorkerProfileUpdateDTO): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在" };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.workerName !== undefined) updateData.workerName = dto.workerName;
|
||||
if (dto.idCard !== undefined) updateData.idCard = dto.idCard;
|
||||
if (dto.workerTitle !== undefined) updateData.workerTitle = dto.workerTitle;
|
||||
if (dto.password) updateData.passwordHash = await hashPassword(dto.password);
|
||||
|
||||
await worker.update(updateData);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "更新成功",
|
||||
data: this.basicInfo(worker)
|
||||
};
|
||||
}
|
||||
|
||||
// 资质证照列表
|
||||
async listCerts(workerId: number): Promise<any> {
|
||||
const certs = await this.listCertRows(workerId);
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取成功",
|
||||
data: certs.map(cert => cert.toJSON())
|
||||
};
|
||||
}
|
||||
|
||||
// 新增资质证照
|
||||
async createCert(workerId: number, dto: WorkerAppCertDTO): Promise<any> {
|
||||
const cert = await this.certModel.create({
|
||||
workerId,
|
||||
certType: dto.certType,
|
||||
certNo: dto.certNo || null,
|
||||
issueAuthority: dto.issueAuthority || null,
|
||||
issueDate: dto.issueDate || null,
|
||||
expireDate: dto.expireDate || null,
|
||||
certImageUrl: dto.certImageUrl || null
|
||||
} as any);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "新增成功",
|
||||
data: cert.toJSON()
|
||||
};
|
||||
}
|
||||
|
||||
// 修改资质证照:变更有效期时清除该证照的到期通知,以便重新提醒
|
||||
async updateCert(workerId: number, certId: number, dto: WorkerAppCertDTO): Promise<any> {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || cert.workerId !== workerId) {
|
||||
return { code: 403, message: "无权操作该证书" };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.certType !== undefined) updateData.certType = dto.certType;
|
||||
if (dto.certNo !== undefined) updateData.certNo = dto.certNo;
|
||||
if (dto.issueAuthority !== undefined) updateData.issueAuthority = dto.issueAuthority;
|
||||
if (dto.issueDate !== undefined) updateData.issueDate = dto.issueDate;
|
||||
if (dto.expireDate !== undefined) updateData.expireDate = dto.expireDate;
|
||||
if (dto.certImageUrl !== undefined) updateData.certImageUrl = dto.certImageUrl;
|
||||
|
||||
await cert.update(updateData);
|
||||
|
||||
// 有效期变化后删除历史到期通知,续期后由后续任务重新生成提醒
|
||||
if (dto.expireDate !== undefined) {
|
||||
await this.clearCertNotices(certId);
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "更新成功",
|
||||
data: cert.toJSON()
|
||||
};
|
||||
}
|
||||
|
||||
// 删除资质证照:归属校验通过后连带清除到期通知
|
||||
async removeCert(workerId: number, certId: number): Promise<any> {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || cert.workerId !== workerId) {
|
||||
return { code: 403, message: "无权操作该证书" };
|
||||
}
|
||||
|
||||
await cert.destroy();
|
||||
await this.clearCertNotices(certId);
|
||||
|
||||
return { code: 200, message: "删除成功" };
|
||||
}
|
||||
|
||||
// 重新提交审核:仅驳回(2)状态可退回待审核(0)
|
||||
async resubmit(workerId: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在" };
|
||||
}
|
||||
|
||||
if (worker.auditStatus !== 2) {
|
||||
return { code: 400, message: "当前状态无需重新提交" };
|
||||
}
|
||||
|
||||
await worker.update({
|
||||
auditStatus: 0,
|
||||
auditRemark: null,
|
||||
auditBy: null,
|
||||
auditAt: null
|
||||
} as any);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// 查询某电工的全部证照
|
||||
private async listCertRows(workerId: number): Promise<WorkerCert[]> {
|
||||
return await this.certModel.findAll({
|
||||
where: { workerId },
|
||||
order: [["id", "DESC"]]
|
||||
});
|
||||
}
|
||||
|
||||
// 资质状态汇总:none 无证书 / expired 存在已过期 / expiring 存在临期 / normal 其余
|
||||
private calcCertStatus(certs: WorkerCert[]): string {
|
||||
if (!certs || certs.length === 0) return "none";
|
||||
|
||||
const today = this.todayStr();
|
||||
const soon = this.addDays(today, EXPIRING_DAYS);
|
||||
let hasExpiring = false;
|
||||
|
||||
for (const cert of certs) {
|
||||
// expire_date 为 NULL 视为长期有效
|
||||
if (!cert.expireDate) continue;
|
||||
const expireDate = String(cert.expireDate).slice(0, 10);
|
||||
if (expireDate < today) return "expired";
|
||||
if (expireDate <= soon) hasExpiring = true;
|
||||
}
|
||||
|
||||
return hasExpiring ? "expiring" : "normal";
|
||||
}
|
||||
|
||||
// 删除某证照在 sys_notice 的到期通知
|
||||
private async clearCertNotices(certId: number): Promise<void> {
|
||||
await this.noticeModel.destroy({
|
||||
where: {
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
bizType: BIZ_TYPE_WORKER_CERT,
|
||||
bizId: certId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 电工基础信息(不含 passwordHash)
|
||||
private basicInfo(worker: Worker): any {
|
||||
return {
|
||||
id: worker.id,
|
||||
workerName: worker.workerName,
|
||||
phone: worker.phone,
|
||||
workerTitle: worker.workerTitle,
|
||||
auditStatus: worker.auditStatus,
|
||||
status: worker.status
|
||||
};
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
private todayStr(): string {
|
||||
return this.formatDate(new Date());
|
||||
}
|
||||
|
||||
private addDays(base: string, days: number): string {
|
||||
const date = new Date(`${base}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return this.formatDate(date);
|
||||
}
|
||||
|
||||
// 返回 to - from 的天数差(正数为未来)
|
||||
private diffDays(from: string, to: string): number {
|
||||
const fromTime = new Date(`${from}T00:00:00`).getTime();
|
||||
const toTime = new Date(`${to}T00:00:00`).getTime();
|
||||
return Math.round((toTime - fromTime) / 86400000);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user