feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理 - 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式 - 新增频率预览接口,支持配置转换为cron表达式与执行时间预览 - 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
This commit is contained in:
1 parent
f72644c24f
commit
22d27a940d
29 files changed
+1908
-576
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { JobService } from "../service/job.service";
|
||||
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO } from "../dto/job.dto";
|
||||
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO, CronPreviewDTO } from "../dto/job.dto";
|
||||
|
||||
@Controller("/api/jobs")
|
||||
export class JobController {
|
||||
@@ -19,6 +19,12 @@ export class JobController {
|
||||
return this.jobService.clearLogs(parsed);
|
||||
}
|
||||
|
||||
/** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
|
||||
@Post("/cron/preview")
|
||||
async previewCron(@Body() dto: CronPreviewDTO) {
|
||||
return this.jobService.previewCron(dto.freqType, dto.freqConfig);
|
||||
}
|
||||
|
||||
@Put("/:id")
|
||||
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
||||
return this.jobService.update(Number(id), dto);
|
||||
|
||||
@@ -11,9 +11,6 @@ export class SurveyOrderController {
|
||||
|
||||
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
||||
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
|
||||
@Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
|
||||
return this.surveyOrderService.listSopItems(templateCode);
|
||||
}
|
||||
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
|
||||
|
||||
// 批量指派
|
||||
@@ -23,9 +20,9 @@ export class SurveyOrderController {
|
||||
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
|
||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
||||
|
||||
// 现场回执:按 SOP 检查项增量录入(照片/截图 key 数组、表单值、备注)
|
||||
@Put("/:id/sop-results") async saveSopResults(@Param("id") id: string, @Body() body: any) {
|
||||
return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
|
||||
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
|
||||
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
|
||||
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
|
||||
}
|
||||
|
||||
// 指派 / 改派共享电工
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
|
||||
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query, Files } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { UploadFileInfo } from "@midwayjs/upload";
|
||||
import { WorkerAppService } from "../service/worker_app.service";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
import { UploadService } from "../service/upload.service";
|
||||
import {
|
||||
WorkerRegisterDTO,
|
||||
WorkerLoginDTO,
|
||||
@@ -20,6 +22,24 @@ export class WorkerAppController {
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
@Inject()
|
||||
uploadService!: UploadService;
|
||||
|
||||
// 勘察现场照片上传:电工 token 走 /api/worker-app/* 通道,复用对象存储上传能力
|
||||
@Post("/upload/image")
|
||||
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
|
||||
const file = files && files[0];
|
||||
if (!file) {
|
||||
return { code: 400, message: "请选择要上传的图片", data: null };
|
||||
}
|
||||
try {
|
||||
const data = await this.uploadService.uploadFile(file);
|
||||
return { code: 200, message: "上传成功", data };
|
||||
} catch (err: any) {
|
||||
return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
|
||||
}
|
||||
}
|
||||
|
||||
// 注册(匿名)
|
||||
@Post("/auth/register")
|
||||
async register(@Body() body: WorkerRegisterDTO) {
|
||||
@@ -130,6 +150,15 @@ export class WorkerAppController {
|
||||
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
|
||||
}
|
||||
|
||||
// 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片
|
||||
@Put("/orders/:source/:id/details")
|
||||
async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) {
|
||||
if (source !== "survey") {
|
||||
return { code: 400, message: "仅勘察订单支持维护明细" };
|
||||
}
|
||||
return this.workerAppService.saveSurveyDetails(this.currentWorkerId(), Number(id), body);
|
||||
}
|
||||
|
||||
// 收益汇总
|
||||
@Get("/earnings/summary")
|
||||
async earningsSummary() {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
||||
|
||||
/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
|
||||
export class UpdateJobDTO {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: "cron 表达式不能为空" })
|
||||
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
|
||||
cronExpression!: string;
|
||||
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||
@MaxLength(16, { message: "频率类型非法" })
|
||||
freqType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
freqConfig?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
|
||||
export class CronPreviewDTO {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||
@MaxLength(16, { message: "频率类型非法" })
|
||||
freqType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
freqConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class JobStatusDTO {
|
||||
@IsInt()
|
||||
@Min(0, { message: "状态值非法" })
|
||||
|
||||
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
|
||||
// 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
|
||||
// 勘察明细(建筑/楼层/房间 + 现场照片),非持久化列,仅用于创建时一并写入
|
||||
@IsOptional() @IsArray() details?: any[];
|
||||
}
|
||||
|
||||
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
||||
@@ -70,7 +70,7 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
|
||||
@IsOptional() @IsArray() declare details?: any[];
|
||||
}
|
||||
|
||||
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import SurveyOrder from "./survey_order.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "survey_detail",
|
||||
comment: "勘察工单明细表(建筑/楼层/房间 + 现场照片)",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [{ name: "idx_survey_detail_order", fields: ["survey_order_id"] }]
|
||||
})
|
||||
export default class SurveyDetail extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@ForeignKey(() => SurveyOrder)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
|
||||
surveyOrderId!: number;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building)" })
|
||||
buildingId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
|
||||
buildingName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层:正数为地上、负数为地下,如 1 / -1" })
|
||||
floorNo!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "room_id", comment: "房间ID(→ customer_room,可空)" })
|
||||
roomId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "room_name", comment: "房间名称(冗余,可空)" })
|
||||
roomName!: string;
|
||||
|
||||
@Column({ type: DataType.TEXT, field: "photos", comment: "现场照片对象key(JSON数组字符串)" })
|
||||
photos!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(255), comment: "备注" })
|
||||
remark!: string;
|
||||
|
||||
@BelongsTo(() => SurveyOrder, { foreignKey: "surveyOrderId", as: "surveyOrder" })
|
||||
surveyOrder!: SurveyOrder;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "survey_sop_item",
|
||||
comment: "勘察 SOP 模板检查项表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{ name: "uk_sop_template_order", unique: true, fields: ["template_code", "sort_order"] }
|
||||
]
|
||||
})
|
||||
export default class SurveySopItem extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, defaultValue: "standard_dist", field: "template_code", comment: "SOP模板编码:standard_dist标准配电改造模板" })
|
||||
templateCode!: string;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "检查项序号" })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, field: "item_name", comment: "检查项名称" })
|
||||
itemName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(255), field: "item_desc", comment: "检查项说明" })
|
||||
itemDesc!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "photo", field: "receipt_type", comment: "回执形式:photo现场照片 form清单表单 meter测仪截图" })
|
||||
receiptType!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "receipt_require", comment: "回执指标要求,如「照片 ≥ 2 张」" })
|
||||
receiptRequire!: string;
|
||||
|
||||
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: true, comment: "是否必填" })
|
||||
required!: boolean;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
|
||||
import SurveyOrder from "./survey_order.entity";
|
||||
import SurveySopItem from "./survey_sop_item.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "survey_sop_result",
|
||||
comment: "勘察 SOP 工单回执表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{ name: "uk_sop_result_order_item", unique: true, fields: ["survey_order_id", "sop_item_id"] },
|
||||
{ name: "idx_sop_result_order", fields: ["survey_order_id"] }
|
||||
]
|
||||
})
|
||||
export default class SurveySopResult extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@ForeignKey(() => SurveyOrder)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
|
||||
surveyOrderId!: number;
|
||||
|
||||
@ForeignKey(() => SurveySopItem)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "sop_item_id", comment: "SOP检查项ID(→ survey_sop_item)" })
|
||||
sopItemId!: number;
|
||||
|
||||
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false, comment: "是否已完成回执" })
|
||||
checked!: boolean;
|
||||
|
||||
@Column({ type: DataType.TEXT, field: "receipt_value", comment: "回执内容(照片对象key的JSON数组字符串/表单文本值/测仪截图key的JSON数组字符串)" })
|
||||
receiptValue!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(255), comment: "回执备注" })
|
||||
remark!: string;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "checked_at", comment: "回执时间" })
|
||||
checkedAt!: Date;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -36,6 +36,20 @@ export default class SysJob extends Model {
|
||||
})
|
||||
cronExpression!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(16),
|
||||
field: "freq_type",
|
||||
comment: "频率类型: minute/hour/day/week/month/custom"
|
||||
})
|
||||
freqType!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSONB,
|
||||
field: "freq_config",
|
||||
comment: "频率配置(人性化配置表单回填用)"
|
||||
})
|
||||
freqConfig!: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSONB,
|
||||
field: "params",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
// ================= 接单 / 完成 =================
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 定时任务频率模型:把「人性化频率配置」翻译成 cron 表达式,并提供中文描述。
|
||||
*
|
||||
* 约定:cron_expression 始终是调度的唯一真值;freq_type / freq_config 只服务于
|
||||
* 配置界面的回填与展示,不做任何调度判断。
|
||||
*/
|
||||
|
||||
/** 频率类型:minute 每 N 分钟 / hour 每 N 小时 / day 每天 / week 每周 / month 每月 / custom 自定义 */
|
||||
export type FreqType = "minute" | "hour" | "day" | "week" | "month" | "custom";
|
||||
|
||||
export const FREQ_TYPES: FreqType[] = ["minute", "hour", "day", "week", "month", "custom"];
|
||||
|
||||
/** 频率配置(字段按 freqType 取用) */
|
||||
export interface FreqConfig {
|
||||
/** minute/hour:间隔数量 */
|
||||
interval?: number;
|
||||
/** hour:每小时的第几分钟 */
|
||||
minute?: number;
|
||||
/** day/week/month:执行时间点,格式 HH:mm */
|
||||
times?: string[];
|
||||
/** week:星期,0=周日 ... 6=周六 */
|
||||
weekdays?: number[];
|
||||
/** month:日期 1-31 */
|
||||
days?: number[];
|
||||
/** custom:自定义 cron 表达式 */
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
cronExpression?: string;
|
||||
/** 规范化后的频率配置(去重/排序),用于原样落库保证下次编辑可回填 */
|
||||
freqConfig?: FreqConfig;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 执行时间点上限,避免表达式过长 */
|
||||
const MAX_TIMES = 10;
|
||||
|
||||
/** 星期文案(cron 取值 0=周日 ... 6=周六) */
|
||||
const WEEKDAY_LABELS: Record<number, string> = {
|
||||
0: "周日",
|
||||
1: "周一",
|
||||
2: "周二",
|
||||
3: "周三",
|
||||
4: "周四",
|
||||
5: "周五",
|
||||
6: "周六"
|
||||
};
|
||||
|
||||
function isIntegerInRange(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
function isAllField(field: string): boolean {
|
||||
return field === "*" || field === "?";
|
||||
}
|
||||
|
||||
function isPlainNumber(field: string): boolean {
|
||||
return /^\d+$/.test(field);
|
||||
}
|
||||
|
||||
/** 解析「星号斜杠 + 数字」的步长写法,非步长返回 null */
|
||||
function parseStepField(field: string): number | null {
|
||||
const matched = /^\*\/(\d+)$/.exec(field);
|
||||
return matched ? Number(matched[1]) : null;
|
||||
}
|
||||
|
||||
/** 解析 HH:mm,非法返回 null */
|
||||
function parseTimeOfDay(text: unknown): { hour: number; minute: number } | null {
|
||||
if (typeof text !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hour = Number(matched[1]);
|
||||
const minute = Number(matched[2]);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** 时间点列表规范化:去重 + 按时间升序 */
|
||||
function normalizeTimes(times: unknown): { list: string[] } | { error: string } {
|
||||
if (!Array.isArray(times) || times.length === 0) {
|
||||
return { error: "请至少选择 1 个执行时间" };
|
||||
}
|
||||
|
||||
if (times.length > MAX_TIMES) {
|
||||
return { error: `执行时间最多 ${MAX_TIMES} 个` };
|
||||
}
|
||||
|
||||
const minutes = new Set<number>();
|
||||
|
||||
for (const item of times) {
|
||||
const parsed = parseTimeOfDay(item);
|
||||
if (!parsed) {
|
||||
return { error: "执行时间格式应为 HH:mm(如 08:30)" };
|
||||
}
|
||||
minutes.add(parsed.hour * 60 + parsed.minute);
|
||||
}
|
||||
|
||||
const list = [...minutes]
|
||||
.sort((a, b) => a - b)
|
||||
.map(total => `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`);
|
||||
|
||||
return { list };
|
||||
}
|
||||
|
||||
/** 由时间点列表得到 cron 的分钟段与小时段(各自去重升序) */
|
||||
function timesToCronFields(list: string[]): { minutes: string; hours: string } {
|
||||
const minutes = new Set<number>();
|
||||
const hours = new Set<number>();
|
||||
|
||||
for (const item of list) {
|
||||
const [hourText, minuteText] = item.split(":");
|
||||
hours.add(Number(hourText));
|
||||
minutes.add(Number(minuteText));
|
||||
}
|
||||
|
||||
return {
|
||||
minutes: [...minutes].sort((a, b) => a - b).join(","),
|
||||
hours: [...hours].sort((a, b) => a - b).join(",")
|
||||
};
|
||||
}
|
||||
|
||||
/** 星期集合规范化:去重 + 升序(cron 段内保持标准升序) */
|
||||
function normalizeWeekdays(weekdays: unknown): { list: number[] } | { error: string } {
|
||||
if (!Array.isArray(weekdays) || weekdays.length === 0) {
|
||||
return { error: "请至少选择 1 个星期" };
|
||||
}
|
||||
|
||||
const set = new Set<number>();
|
||||
for (const item of weekdays) {
|
||||
if (!isIntegerInRange(item, 0, 6)) {
|
||||
return { error: "星期取值非法" };
|
||||
}
|
||||
set.add(item);
|
||||
}
|
||||
|
||||
const list = [...set].sort((a, b) => a - b);
|
||||
|
||||
return { list };
|
||||
}
|
||||
|
||||
/** 日期集合规范化:去重 + 升序 */
|
||||
function normalizeDays(days: unknown): { list: number[] } | { error: string } {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
return { error: "请至少选择 1 个日期" };
|
||||
}
|
||||
|
||||
const set = new Set<number>();
|
||||
for (const item of days) {
|
||||
if (!isIntegerInRange(item, 1, 31)) {
|
||||
return { error: "日期取值需为 1-31" };
|
||||
}
|
||||
set.add(item);
|
||||
}
|
||||
|
||||
const list = [...set].sort((a, b) => a - b);
|
||||
|
||||
return { list };
|
||||
}
|
||||
|
||||
/** 星期显示文案:入参为显示序号(1=周一 ... 7=周日) */
|
||||
function weekdayLabel(displayDay: number): string {
|
||||
return WEEKDAY_LABELS[displayDay === 7 ? 0 : displayDay];
|
||||
}
|
||||
|
||||
/** 星期显示顺序:以周一为第 1 天、周日为第 7 天,便于区间折叠与阅读 */
|
||||
function weekdayDisplayOrder(day: number): number {
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
/** 星期集合 → 文案:连续区间折叠为「X至Y」,其余用「、」连接 */
|
||||
function formatWeekdays(weekdays: number[]): string {
|
||||
const ordered = [...new Set(weekdays.map(weekdayDisplayOrder))].sort((a, b) => a - b);
|
||||
if (!ordered.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let start = ordered[0];
|
||||
let end = ordered[0];
|
||||
|
||||
for (let index = 1; index <= ordered.length; index++) {
|
||||
const current: number | undefined = ordered[index];
|
||||
|
||||
if (current !== undefined && current === end + 1) {
|
||||
end = current;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push(start === end ? weekdayLabel(start) : `${weekdayLabel(start)}至${weekdayLabel(end)}`);
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
start = current;
|
||||
end = current;
|
||||
}
|
||||
|
||||
return chunks.join("、");
|
||||
}
|
||||
|
||||
/** 由 freqType + freqConfig 翻译为 cron 表达式与中文描述(不含 cron 合法性校验) */
|
||||
export function buildSchedule(freqType: string, freqConfig?: FreqConfig | null): ScheduleResult {
|
||||
const cfg: FreqConfig = freqConfig && typeof freqConfig === "object" ? freqConfig : {};
|
||||
|
||||
switch (freqType) {
|
||||
case "minute": {
|
||||
if (!isIntegerInRange(cfg.interval, 1, 59)) {
|
||||
return { valid: false, error: "间隔需为 1-59 的整数(分钟)" };
|
||||
}
|
||||
|
||||
const interval = cfg.interval;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
cronExpression: interval === 1 ? "* * * * *" : `*/${interval} * * * *`,
|
||||
freqConfig: { interval },
|
||||
description: interval === 1 ? "每分钟执行" : `每 ${interval} 分钟执行`
|
||||
};
|
||||
}
|
||||
|
||||
case "hour": {
|
||||
if (!isIntegerInRange(cfg.interval, 1, 23)) {
|
||||
return { valid: false, error: "间隔需为 1-23 的整数(小时)" };
|
||||
}
|
||||
if (!isIntegerInRange(cfg.minute, 0, 59)) {
|
||||
return { valid: false, error: "分钟需为 0-59 的整数" };
|
||||
}
|
||||
|
||||
const interval = cfg.interval;
|
||||
const minute = cfg.minute;
|
||||
const hourField = interval === 1 ? "*" : `*/${interval}`;
|
||||
const minuteText = minute === 0 ? "整点" : `第 ${minute} 分`;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
cronExpression: `${minute} ${hourField} * * *`,
|
||||
freqConfig: { interval, minute },
|
||||
description: interval === 1 ? `每小时${minuteText}执行` : `每 ${interval} 小时${minuteText}执行`
|
||||
};
|
||||
}
|
||||
|
||||
case "day": {
|
||||
const normalized = normalizeTimes(cfg.times);
|
||||
if ("error" in normalized) {
|
||||
return { valid: false, error: normalized.error };
|
||||
}
|
||||
|
||||
const fields = timesToCronFields(normalized.list);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
cronExpression: `${fields.minutes} ${fields.hours} * * *`,
|
||||
freqConfig: { times: normalized.list },
|
||||
description: `每天 ${normalized.list.join("、")} 执行`
|
||||
};
|
||||
}
|
||||
|
||||
case "week": {
|
||||
const normalizedDays = normalizeWeekdays(cfg.weekdays);
|
||||
if ("error" in normalizedDays) {
|
||||
return { valid: false, error: normalizedDays.error };
|
||||
}
|
||||
|
||||
const normalizedTimes = normalizeTimes(cfg.times);
|
||||
if ("error" in normalizedTimes) {
|
||||
return { valid: false, error: normalizedTimes.error };
|
||||
}
|
||||
|
||||
const fields = timesToCronFields(normalizedTimes.list);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
cronExpression: `${fields.minutes} ${fields.hours} * * ${normalizedDays.list.join(",")}`,
|
||||
freqConfig: { weekdays: normalizedDays.list, times: normalizedTimes.list },
|
||||
description: `每周(${formatWeekdays(normalizedDays.list)})${normalizedTimes.list.join("、")} 执行`
|
||||
};
|
||||
}
|
||||
|
||||
case "month": {
|
||||
const normalizedDays = normalizeDays(cfg.days);
|
||||
if ("error" in normalizedDays) {
|
||||
return { valid: false, error: normalizedDays.error };
|
||||
}
|
||||
|
||||
const normalizedTimes = normalizeTimes(cfg.times);
|
||||
if ("error" in normalizedTimes) {
|
||||
return { valid: false, error: normalizedTimes.error };
|
||||
}
|
||||
|
||||
const fields = timesToCronFields(normalizedTimes.list);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
cronExpression: `${fields.minutes} ${fields.hours} ${normalizedDays.list.join(",")} * *`,
|
||||
freqConfig: { days: normalizedDays.list, times: normalizedTimes.list },
|
||||
description: `每月 ${normalizedDays.list.join("、")} 日 ${normalizedTimes.list.join("、")} 执行`
|
||||
};
|
||||
}
|
||||
|
||||
case "custom": {
|
||||
const expression = typeof cfg.expression === "string" ? cfg.expression.trim() : "";
|
||||
|
||||
if (!expression) {
|
||||
return { valid: false, error: "请输入 cron 表达式" };
|
||||
}
|
||||
if (expression.length > 64) {
|
||||
return { valid: false, error: "cron 表达式长度不能超过 64" };
|
||||
}
|
||||
|
||||
// 表达式合法性由调度器(cron 包)统一校验,这里只做描述
|
||||
return {
|
||||
valid: true,
|
||||
cronExpression: expression,
|
||||
freqConfig: { expression },
|
||||
description: describeCron(expression)
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { valid: false, error: "频率类型非法" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析逗号分隔的分钟/小时段;含区间、步长等复杂写法时返回 null */
|
||||
function parseSimpleNumberField(field: string, min: number, max: number): number[] | null {
|
||||
if (isAllField(field)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = field.split(",");
|
||||
const values: number[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isPlainNumber(part)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = Number(part);
|
||||
if (value < min || value > max) {
|
||||
return null;
|
||||
}
|
||||
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
return values.length ? [...new Set(values)].sort((a, b) => a - b) : null;
|
||||
}
|
||||
|
||||
/** 由分钟段与小时段还原成 HH:mm 列表(两者均为简单数字列表时) */
|
||||
function parseTimeList(minuteField: string, hourField: string): string[] | null {
|
||||
const minutes = parseSimpleNumberField(minuteField, 0, 59);
|
||||
const hours = parseSimpleNumberField(hourField, 0, 23);
|
||||
|
||||
if (!minutes || !hours) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list: string[] = [];
|
||||
for (const hour of hours) {
|
||||
for (const minute of minutes) {
|
||||
list.push(`${pad2(hour)}:${pad2(minute)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 解析星期段(cron 取值,0=周日):支持数字、逗号列表、a-b 区间;无法识别返回 null */
|
||||
function parseWeekdayField(field: string): number[] | null {
|
||||
const list: number[] = [];
|
||||
|
||||
for (const part of field.split(",")) {
|
||||
const rangeMatched = /^(\d)-(\d)$/.exec(part);
|
||||
|
||||
if (rangeMatched) {
|
||||
const start = Number(rangeMatched[1]);
|
||||
const end = Number(rangeMatched[2]);
|
||||
// cron 中 7 等价于周日
|
||||
if (start > end || start < 0 || end > 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let day = start; day <= end; day++) {
|
||||
list.push(day === 7 ? 0 : day);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isPlainNumber(part)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const day = Number(part);
|
||||
if (day < 0 || day > 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
list.push(day === 7 ? 0 : day);
|
||||
}
|
||||
|
||||
return [...new Set(list)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* cron 表达式 → 中文描述。
|
||||
* 只覆盖本系统会生成/常见的写法(通配、简单数字列表、步长、星期区间),
|
||||
* 无法有把握描述时原样返回表达式,避免给出误导性文案。
|
||||
*/
|
||||
export function describeCron(expression: string): string {
|
||||
if (!expression || typeof expression !== "string") {
|
||||
return expression;
|
||||
}
|
||||
|
||||
const fields = expression.trim().split(/\s+/);
|
||||
let minuteField: string;
|
||||
let hourField: string;
|
||||
let dayOfMonthField: string;
|
||||
let monthField: string;
|
||||
let dayOfWeekField: string;
|
||||
|
||||
if (fields.length === 6) {
|
||||
const [secondField, ...rest] = fields;
|
||||
// 秒不为 0 的表达式不描述,原样返回
|
||||
if (secondField !== "0") {
|
||||
return expression;
|
||||
}
|
||||
[minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = rest;
|
||||
} else if (fields.length === 5) {
|
||||
[minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields;
|
||||
} else {
|
||||
return expression;
|
||||
}
|
||||
|
||||
// 月份限定不描述
|
||||
if (!isAllField(monthField)) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
const dayOfMonthFixed = !isAllField(dayOfMonthField);
|
||||
const dayOfWeekFixed = !isAllField(dayOfWeekField);
|
||||
// 日与星期同时限定在 cron 中是「或」语义,为避免歧义不做描述
|
||||
if (dayOfMonthFixed && dayOfWeekFixed) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
if (dayOfMonthFixed) {
|
||||
const days = parseSimpleNumberField(dayOfMonthField, 1, 31);
|
||||
const times = parseTimeList(minuteField, hourField);
|
||||
if (!days || !times) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return `每月 ${days.join("、")} 日 ${times.join("、")} 执行`;
|
||||
}
|
||||
|
||||
if (dayOfWeekFixed) {
|
||||
const weekdays = parseWeekdayField(dayOfWeekField);
|
||||
const times = parseTimeList(minuteField, hourField);
|
||||
if (!weekdays || !times) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
if (weekdays.length === 7) {
|
||||
return `每天 ${times.join("、")} 执行`;
|
||||
}
|
||||
|
||||
return `每周(${formatWeekdays(weekdays)})${times.join("、")} 执行`;
|
||||
}
|
||||
|
||||
if (isAllField(minuteField) && isAllField(hourField)) {
|
||||
return "每分钟执行";
|
||||
}
|
||||
|
||||
const minuteStep = parseStepField(minuteField);
|
||||
if (minuteStep !== null && isAllField(hourField)) {
|
||||
return minuteStep === 1 ? "每分钟执行" : `每 ${minuteStep} 分钟执行`;
|
||||
}
|
||||
|
||||
if (isPlainNumber(minuteField) && isAllField(hourField)) {
|
||||
const minute = Number(minuteField);
|
||||
return minute === 0 ? "每小时整点执行" : `每小时第 ${minute} 分执行`;
|
||||
}
|
||||
|
||||
const hourStep = parseStepField(hourField);
|
||||
if (hourStep !== null && isPlainNumber(minuteField)) {
|
||||
const minute = Number(minuteField);
|
||||
const minuteText = minute === 0 ? "整点" : `第 ${minute} 分`;
|
||||
return hourStep === 1 ? `每小时${minuteText}执行` : `每 ${hourStep} 小时${minuteText}执行`;
|
||||
}
|
||||
|
||||
const times = parseTimeList(minuteField, hourField);
|
||||
if (!times) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return `每天 ${times.join("、")} 执行`;
|
||||
}
|
||||
Reference in new issue
Block a user