feat: 重构勘察业务为明细模式并新增定时任务人性化配置

- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理
- 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式
- 新增频率预览接口,支持配置转换为cron表达式与执行时间预览
- 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
This commit is contained in:
andyesfly committed 2026-10-17 10:08:42 +08:00
1 parent f72644c24f
commit 22d27a940d
29 files changed
+1908 -576

No files matched your search

+74 -3
View File
@@ -8,6 +8,10 @@ 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";
import { FreqConfig, FreqType, FREQ_TYPES, buildSchedule, describeCron } from "../util/cron_schedule";
/** 频率预览返回的未来执行次数 */
const PREVIEW_RUN_COUNT = 5;
@Provide()
export class JobService {
@@ -20,6 +24,29 @@ export class JobService {
@Inject()
scheduler!: JobSchedulerService;
/**
* 解析任务的频率配置:非法的频率类型或残缺的配置统一降级为「自定义」,
* 保证配置界面每次都能拿到可原样回填、可再次保存的配置。
*/
private resolveFreq(job: SysJob): { freqType: FreqType; freqConfig: FreqConfig; description: string } {
const storedType = FREQ_TYPES.includes(job.freqType as FreqType) ? (job.freqType as FreqType) : "custom";
const schedule = buildSchedule(storedType, job.freqConfig);
if (schedule.valid) {
return {
freqType: storedType,
freqConfig: schedule.freqConfig ?? {},
description: schedule.description ?? describeCron(job.cronExpression)
};
}
return {
freqType: "custom",
freqConfig: { expression: job.cronExpression },
description: describeCron(job.cronExpression)
};
}
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
const page = Number(query.page) || 1;
@@ -45,9 +72,13 @@ export class JobService {
const list = rows.map(job => {
const builtin = getBuiltinJob(job.jobKey);
const registered = !!builtin;
const freq = this.resolveFreq(job);
return {
...job.toJSON(),
freqType: freq.freqType,
freqConfig: freq.freqConfig,
cronDescription: freq.description,
description: builtin ? builtin.description : null,
paramFields: builtin ? builtin.paramFields : [],
registered,
@@ -62,7 +93,7 @@ export class JobService {
};
}
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
/** 更新任务配置:频率配置翻译 cron 并校验合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
async update(id: number, dto: UpdateJobDTO): Promise<any> {
const job = await this.jobModel.findByPk(id);
@@ -75,7 +106,14 @@ export class JobService {
return { code: 400, message: "任务实现不存在", data: null };
}
if (!this.scheduler.validateCron(dto.cronExpression)) {
// 频率配置 → cron:非法返回具体错误,合法则同时拿到规范化配置用于落库
const schedule = buildSchedule(dto.freqType, dto.freqConfig);
if (!schedule.valid) {
return { code: 400, message: schedule.error ?? "频率配置不合法", data: null };
}
const cronExpression = schedule.cronExpression ?? "";
if (!this.scheduler.validateCron(cronExpression)) {
return { code: 400, message: "cron 表达式不合法", data: null };
}
@@ -122,7 +160,9 @@ export class JobService {
}
await job.update({
cronExpression: dto.cronExpression,
cronExpression,
freqType: dto.freqType,
freqConfig: schedule.freqConfig ?? {},
params,
retryTimes: dto.retryTimes,
retryInterval: dto.retryInterval,
@@ -134,6 +174,37 @@ export class JobService {
return { code: 200, message: "更新定时任务成功", data: null };
}
/**
* 频率预览:由「人性化频率配置」计算 cron 表达式、中文描述与未来若干次执行时间。
* 配置不完整/表达式非法时仍返回 200,通过 valid/error 让前端就地提示,避免弹窗报错打断填写。
*/
async previewCron(freqType: string, freqConfig?: Record<string, any>): Promise<any> {
const schedule = buildSchedule(freqType, freqConfig);
if (!schedule.valid) {
return {
code: 200,
message: "频率配置不完整",
data: { valid: false, error: schedule.error, cronExpression: null, description: null, nextRuns: [] }
};
}
const cronExpression = schedule.cronExpression ?? "";
const valid = this.scheduler.validateCron(cronExpression);
return {
code: 200,
message: "预览成功",
data: {
valid,
error: valid ? null : "cron 表达式不合法",
cronExpression,
description: schedule.description ?? null,
nextRuns: valid ? this.scheduler.nextRunTimes(cronExpression, PREVIEW_RUN_COUNT) : []
}
};
}
/** 启停任务:更新状态后即时重载调度 */
async changeStatus(id: number, status: number): Promise<any> {
const job = await this.jobModel.findByPk(id);
@@ -138,6 +138,21 @@ export class JobSchedulerService {
}
}
/** 计算未来 count 次执行时间(配置界面预览用);表达式非法返回空数组 */
nextRunTimes(cronExpression: string, count: number): Date[] {
if (!this.validateCron(cronExpression)) {
return [];
}
try {
return new CronTime(cronExpression, TIMEZONE)
.sendAt(count)
.map(date => date.toJSDate());
} catch {
return [];
}
}
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
async cleanJobLogs(keepDays: number): Promise<number> {
const cutoff = dayjs().subtract(keepDays, "day").toDate();
+69 -103
View File
@@ -6,8 +6,7 @@ import { Context } from "@midwayjs/koa";
import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SurveySopItem from "../entity/survey_sop_item.entity";
import SurveySopResult from "../entity/survey_sop_result.entity";
import SurveyDetail from "../entity/survey_detail.entity";
import DispatchPrice from "../entity/dispatch_price.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
@@ -18,8 +17,6 @@ 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 };
// 默认 SOP 模板编码
const DEFAULT_TEMPLATE = "standard_dist";
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
const DEFAULT_EXPECT_MINUTES = 45;
const DEFAULT_BASE_FEE = 350;
@@ -38,11 +35,8 @@ export class SurveyOrderService {
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SurveySopItem)
sopItemModel!: Repository<SurveySopItem>;
@InjectRepository(SurveySopResult)
sopResultModel!: Repository<SurveySopResult>;
@InjectRepository(SurveyDetail)
detailModel!: Repository<SurveyDetail>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@@ -257,13 +251,50 @@ export class SurveyOrderService {
return { baseFee, loopFee, loopCount: count, total };
}
// 按模板批量生成回执记录(checked=false)
private async createSopResults(orderId: number, templateCode: string): Promise<void> {
const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
if (items.length === 0) return;
await this.sopResultModel.bulkCreate(
items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
);
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
private normalizeDetails(input: any): any[] {
if (!Array.isArray(input)) return [];
return input
.map((row) => {
const photos = this.parsePhotos(row?.photos);
const buildingId = Number(row?.buildingId) || null;
const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
const roomId = Number(row?.roomId) || null;
return {
buildingId,
buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
floorNo: floorNo.slice(0, 16),
roomId,
roomName: row?.roomName ? String(row.roomName).slice(0, 128) : null,
photos: photos.length ? JSON.stringify(photos) : null,
remark: row?.remark ? String(row.remark).slice(0, 255) : null
};
})
// 建筑与楼层是明细的定位要素,缺失的行直接丢弃
.filter((row) => row.buildingId && row.floorNo);
}
// 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
async replaceDetails(orderId: number, details: any): Promise<void> {
const rows = this.normalizeDetails(details);
await this.detailModel.destroy({ where: { surveyOrderId: orderId } });
if (rows.length === 0) return;
await this.detailModel.bulkCreate(rows.map((row) => ({ ...row, surveyOrderId: orderId })) as any);
}
// 工单明细 + 照片预签名访问 URL(管理端详情与电工端订单详情共用)
async getDetails(orderId: number): Promise<any[]> {
const results = await this.detailModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const rows = results.map((r) => {
const json: any = r.toJSON();
return { ...json, photos: this.parsePhotos(json.photos) };
});
const keys = [...new Set<string>(rows.flatMap((r) => r.photos))];
const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
return rows.map((row) => ({ ...row, photoUrls: row.photos.map((k: string) => urlMap[k] || "") }));
}
async list(query: any, ctx: Context): Promise<any> {
@@ -342,13 +373,6 @@ export class SurveyOrderService {
};
}
// SOP 模板检查项
async listSopItems(templateCode?: string): Promise<any> {
const code = templateCode || DEFAULT_TEMPLATE;
const rows = await this.sopItemModel.findAll({ where: { templateCode: code }, order: [["sort_order", "ASC"]] });
return { code: 200, message: "获取勘察SOP检查项成功", data: rows.map((r) => r.toJSON()) };
}
// 报酬试算
async quote(loopCount?: string): Promise<any> {
const data = await this.calcFee(Number(loopCount) || 0);
@@ -362,7 +386,7 @@ export class SurveyOrderService {
const vo = this.toVO(record);
await this.decorate([vo]);
vo.sopResults = await this.getSopResults(id);
vo.details = await this.getDetails(id);
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
const photoKeys: string[] = vo.photos || [];
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
@@ -370,84 +394,17 @@ export class SurveyOrderService {
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
// 工单回执 + 检查项信息(手动 join,避免额外关联);receiptValue 以 key 数组出参,并补预签名访问 URL
private async getSopResults(orderId: number): Promise<any[]> {
const results = await this.sopResultModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const itemIds = [...new Set(results.map((r) => r.sopItemId).filter(Boolean))];
const items = await this.sopItemModel.findAll({ where: { id: { [Op.in]: itemIds } } });
const itemMap = new Map(items.map((i) => [i.id, i]));
const rows = results.map((r) => {
const json: any = r.toJSON();
const item: any = itemMap.get(json.sopItemId);
return {
...json,
receiptValue: this.parsePhotos(json.receiptValue),
itemName: item?.itemName || "",
itemDesc: item?.itemDesc || "",
receiptType: item?.receiptType || "",
receiptRequire: item?.receiptRequire || "",
required: item?.required ?? false
};
});
// photo/meter 类回执是照片 key,批量换取预签名 URL 供前端直接渲染;form 类是文本值,不参与换取
const keys = [...new Set<string>(rows.filter((r) => r.receiptType !== "form").flatMap((r) => r.receiptValue))];
const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
return rows.map((row) => ({
...row,
receiptUrls: row.receiptType === "form" ? [] : row.receiptValue.map((k: string) => urlMap[k] || "")
}));
}
// 保存 SOP 工单回执(增量:只更新传入的检查项,未传入项保持原值)
async saveSopResults(id: number, dto: any, ctx: Context): Promise<any> {
// 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
async saveDetails(id: number, dto: any, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该工单", data: null };
if (record.status !== STATUS.SURVEYING) return { code: 400, message: "仅现场勘察中的工单可录入回执", data: null };
const incoming: any[] = Array.isArray(dto?.results) ? dto.results : [];
if (incoming.length === 0) return { code: 400, message: "回执内容不能为空", data: null };
// 仅允许更新本工单已生成的回执记录,防止跨工单/跨检查项写入
const existing = await this.sopResultModel.findAll({ where: { surveyOrderId: id } });
const resultMap = new Map(existing.map((r) => [Number(r.sopItemId), r]));
let updated = 0;
const skipped: number[] = [];
for (const item of incoming) {
const sopItemId = Number(item?.sopItemId);
const target = resultMap.get(sopItemId);
if (!target) {
skipped.push(sopItemId);
continue;
}
const patch: any = {};
if (item.checked !== undefined) {
const checked = !!item.checked;
patch.checked = checked;
// 首次标记完成时记录回执时间,回退未完成则清空
patch.checkedAt = checked ? target.checkedAt || new Date() : null;
}
if (item.receiptValue !== undefined) {
const keys = this.parsePhotos(item.receiptValue);
patch.receiptValue = keys.length ? JSON.stringify(keys) : null;
}
if (item.remark !== undefined) patch.remark = item.remark || null;
if (Object.keys(patch).length === 0) continue;
await target.update(patch);
updated++;
if (record.status !== STATUS.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
}
return {
code: 200,
message: "保存勘察回执成功",
data: { updated, skipped, sopResults: await this.getSopResults(id) }
};
await this.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
}
async create(data: any, ctx: Context): Promise<any> {
@@ -456,9 +413,8 @@ export class SurveyOrderService {
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
if (!isAdmin) data.salesmanId = userId;
const templateCode = data.templateCode || DEFAULT_TEMPLATE;
const payload = this.normalizePayload(data);
delete payload.templateCode;
delete payload.details;
payload.surveyNo = await this.generateSurveyNo();
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
@@ -484,9 +440,13 @@ export class SurveyOrderService {
}
}
await this.createSopResults(created.id, templateCode);
// 创建时若携带勘察明细则一并写入
if (Array.isArray(data.details) && data.details.length) {
await this.replaceDetails(created.id, data.details);
}
const vo = this.toVO(created);
await this.decorate([vo]);
vo.details = await this.getDetails(created.id);
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
return { code: 200, message: "创建勘察派单成功", data: vo };
}
@@ -503,13 +463,19 @@ export class SurveyOrderService {
delete data.salesmanId;
}
delete data.surveyNo;
delete data.templateCode;
const details = data.details;
delete data.details;
const payload = this.normalizePayload(data);
await record.update(payload);
// 仅当显式传入 details 时才整体替换明细,避免只改主表字段时误清空
if (Array.isArray(details)) {
await this.replaceDetails(id, details);
}
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
vo.details = await this.getDetails(id);
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
return { code: 200, message: "更新勘察派单成功", data: vo };
}
@@ -643,8 +609,8 @@ export class SurveyOrderService {
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
const surveyNo = record.surveyNo;
// 级联清理回执后删除工单
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
// 级联清理明细后删除工单
await this.detailModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
return { code: 200, message: "删除勘察派单成功", data: null };
+57 -1
View File
@@ -11,8 +11,11 @@ import RepairDispatch from "../entity/repair_dispatch.entity";
import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import SurveyOrder from "../entity/survey_order.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import CustomerRoom from "../entity/customer_room.entity";
import Customer from "../entity/customer.entity";
import { TokenUtil } from "../util/token";
import { SurveyOrderService } from "./survey_order.service";
import { hashPassword, comparePassword } from "../util/crypto";
import {
WorkerRegisterDTO,
@@ -69,6 +72,15 @@ export class WorkerAppService {
@InjectRepository(SurveyOrder)
surveyModel!: Repository<SurveyOrder>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(CustomerRoom)
roomModel!: Repository<CustomerRoom>;
@Inject()
surveyOrderService!: SurveyOrderService;
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
@InjectDataSource()
sequelize!: Sequelize;
@@ -346,7 +358,51 @@ export class WorkerAppService {
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
if (!row) return { code: 404, message: "订单不存在" };
return { code: 200, message: "获取成功", data: this.toOrderView(row.toJSON(), source) };
const data = this.toOrderView(row.toJSON(), source);
// 勘察单追加现场明细(含照片预签名 URL)与客户空间拓扑,供电工现场查看与录入
if (source === "survey") {
const [details, topology] = await Promise.all([
this.surveyOrderService.getDetails(Number(row.id)),
this.customerTopology(Number(row.customerId))
]);
data.details = details;
data.topology = topology;
}
return { code: 200, message: "获取成功", data };
}
// 客户空间拓扑(建筑 + 房间),供勘察现场选择建筑/楼层/房间
private async customerTopology(customerId: number): Promise<any> {
if (!customerId) return { buildings: [], rooms: [] };
const [buildings, rooms] = await Promise.all([
this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] })
]);
return {
buildings: buildings.map((b) => ({
id: b.id,
buildingName: b.buildingName,
floorCount: Number(b.floorCount) || 0,
floorDownCount: Number(b.floorDownCount) || 0
})),
rooms: rooms.map((r) => ({
id: r.id,
roomName: r.roomName,
buildingId: r.buildingId,
floorNo: r.floorNo || ""
}))
};
}
// 电工维护勘察明细:仅本人已接单且处于「现场勘察中(1)」的勘察单可写入(全量替换)
async saveSurveyDetails(workerId: number, id: number, dto: any): Promise<any> {
const order = await this.surveyModel.findByPk(id);
if (!order) return { code: 404, message: "订单不存在" };
if (Number(order.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该订单" };
if (Number(order.status) !== 1) return { code: 400, message: "仅现场勘察中的订单可维护勘察明细" };
await this.surveyOrderService.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.surveyOrderService.getDetails(id) };
}
// ================= 接单 / 完成 =================