Files
smart_grid_iot/server/src/service/survey_order.service.ts
T
andyesfly 22d27a940d feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理
- 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式
- 新增频率预览接口,支持配置转换为cron表达式与执行时间预览
- 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
2026-10-17 10:08:42 +08:00

619 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError, literal } from "sequelize";
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 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";
import SysRole from "../entity/sysRole.entity";
import { UploadService } from "./upload.service";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
const DEFAULT_EXPECT_MINUTES = 45;
const DEFAULT_BASE_FEE = 350;
const DEFAULT_LOOP_FEE = 5;
// 报酬相关的价格编码
const PRICE_CODES = ["SURVEY_BASE_FEE", "SURVEY_LOOP_FEE"];
@Provide()
export class SurveyOrderService {
@InjectRepository(SurveyOrder)
surveyModel!: Repository<SurveyOrder>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SurveyDetail)
detailModel!: Repository<SurveyDetail>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
uploadService!: UploadService;
@Inject()
logService!: LogService;
@Inject()
noticeRuleService!: NoticeRuleService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
const customer = await this.customerModel.findByPk(customerId);
return !!customer && customer.salesmanId === userId;
}
// 工单可见性:管理员 / 责任业务员本人 / 客户归属业务员
private async isOrderVisible(userId: number, order: SurveyOrder): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
if (order.salesmanId === userId) return true;
return this.isCustomerOwner(userId, order.customerId);
}
// 业务负责人姓名
private async getUserRealName(userId: number): Promise<string> {
if (!userId) return "未知";
const user = await this.userModel.findByPk(userId, { attributes: ["id", "realName", "username"] });
return user?.realName || (user as any)?.username || "未知";
}
// 单号规则:KC + 东八区 yyyymmdd + 3 位序号,冲突重试 1 次
private async generateSurveyNo(retry: number = 0): 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 = `KC${yyyymmdd}`;
const last = await this.surveyModel.findOne({ where: { surveyNo: { [Op.like]: `${prefix}%` } }, order: [["survey_no", "DESC"]] });
let seq = 1;
if (last) seq = (parseInt(last.surveyNo.slice(-3), 10) || 0) + 1;
const result = `${prefix}${String(seq).padStart(3, "0")}`;
const exists = await this.surveyModel.findOne({ where: { surveyNo: result } });
if (exists) {
if (retry < 1) return this.generateSurveyNo(retry + 1);
throw new Error("勘察单号生成冲突,请重试");
}
return result;
}
// query 参数 → Sequelize where(关键字/客户/状态/优先级/电工/业务员/计划日期窗口/草稿)
private buildWhere(query: any): any {
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ surveyNo: { [Op.like]: `%${query.keyword}%` } },
{ taskTitle: { [Op.like]: `%${query.keyword}%` } },
{ siteLocation: { [Op.like]: `%${query.keyword}%` } },
{ remark: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
if (query.priority !== undefined && query.priority !== "") where.priority = Number(query.priority);
if (query.workerId !== undefined && query.workerId !== "") where.workerId = Number(query.workerId);
if (query.salesmanId !== undefined && query.salesmanId !== "") where.salesmanId = Number(query.salesmanId);
if (query.planStartDate || query.planEndDate) {
const range: any = {};
if (query.planStartDate) range[Op.gte] = new Date(`${query.planStartDate}T00:00:00+08:00`);
if (query.planEndDate) range[Op.lte] = new Date(`${query.planEndDate}T23:59:59+08:00`);
where.planStart = range;
}
// 台账与统计默认排除草稿
if (!Number(query.includeDraft)) where.isDraft = 0;
return where;
}
// 行级隔离:非管理员仅可见「责任业务员为自己」或「客户归属自己」的工单
private async scopeWhere(baseWhere: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const where: any = { ...baseWhere };
if (await this.isAdmin(userId)) return where;
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
const scopeOr: any[] = [{ salesmanId: userId }];
if (ids.length) scopeOr.push({ customerId: { [Op.in]: ids } });
// 与基础条件合并(避免覆盖 keyword 产生的 Op.or)
const existingAnd = where[Op.and] ? (Array.isArray(where[Op.and]) ? where[Op.and] : [where[Op.and]]) : [];
where[Op.and] = [...existingAnd, { [Op.or]: scopeOr }];
return where;
}
// 合并 where:Op.and / Op.or 做并集,其余键覆盖
private mergeWhere(base: any, extra: any): any {
const merged: any = { ...base };
for (const key of Object.keys(extra)) {
merged[key] = extra[key];
}
// Op.and / Op.or 为 symbol 键,不会被 Object.keys 返回,需显式取值合并
for (const opKey of [Op.and, Op.or] as any[]) {
if (!extra[opKey]) continue;
const b = merged[opKey] ? (Array.isArray(merged[opKey]) ? merged[opKey] : [merged[opKey]]) : [];
const e = Array.isArray(extra[opKey]) ? extra[opKey] : [extra[opKey]];
merged[opKey] = [...b, ...e];
}
return merged;
}
private parsePhotos(raw: any): string[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw;
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
// 输出前把 photos 从 JSON 字符串转为 key 数组
private toVO(record: any): any {
const json = record.toJSON ? record.toJSON() : { ...record };
json.photos = this.parsePhotos(json.photos);
return json;
}
// 批量补充客户/电工/业务员信息,避免 N+1
private async decorate(rows: any[]): Promise<any[]> {
if (rows.length === 0) return rows;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const workerIds = [...new Set(rows.map((r) => r.workerId).filter(Boolean))];
const salesmanIds = [...new Set(rows.map((r) => r.salesmanId).filter(Boolean))];
const [customers, workers, users] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName", "customerLevel", "contactPerson", "contactPhone", "address", "city", "salesmanId"]
})
: Promise.resolve([]),
workerIds.length
? this.workerModel.findAll({
where: { id: { [Op.in]: workerIds } },
attributes: ["id", "workerName", "phone", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"]
})
: Promise.resolve([]),
salesmanIds.length
? this.userModel.findAll({ where: { id: { [Op.in]: salesmanIds } }, attributes: ["id", "realName", "phone"] })
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const workerMap = new Map(workers.map((w) => [w.id, w]));
const userMap = new Map(users.map((u) => [u.id, u]));
for (const row of rows) {
const customer: any = customerMap.get(row.customerId);
const worker: any = workerMap.get(row.workerId);
const user: any = userMap.get(row.salesmanId);
row.customer = customer ? customer.toJSON() : null;
row.customerName = customer?.customerName || "";
row.worker = worker ? worker.toJSON() : null;
row.workerName = worker?.workerName || "";
row.salesmanName = user?.realName || "";
row.salesmanPhone = user?.phone || "";
}
return rows;
}
// 统一清洗入参:空串转 null、时间字符串转 Date、photos 数组转 JSON 字符串
private normalizePayload(data: any): any {
const payload: any = { ...data };
const dateKeys = ["planStart", "planEnd", "expectArrivalAt", "reviewedAt", "surveyTime", "lastUrgedAt"];
for (const key of dateKeys) {
if (payload[key]) payload[key] = payload[key] instanceof Date ? payload[key] : new Date(payload[key]);
}
if (payload.photos !== undefined) {
const keys = this.parsePhotos(payload.photos);
payload.photos = keys.length ? JSON.stringify(keys) : null;
}
for (const key of Object.keys(payload)) {
if (payload[key] === "") payload[key] = null;
}
return payload;
}
// 报价:基础出场费 + 回路补贴 × 回路数
private async calcFee(loopCount: number): Promise<{ baseFee: number; loopFee: number; loopCount: number; total: number }> {
const count = Number(loopCount) || 0;
const prices = await this.priceModel.findAll({ where: { priceCode: { [Op.in]: PRICE_CODES } } });
const priceMap = new Map(prices.map((p) => [p.priceCode, Number(p.unitPrice)]));
const baseFee = priceMap.has("SURVEY_BASE_FEE") ? (priceMap.get("SURVEY_BASE_FEE") as number) : DEFAULT_BASE_FEE;
const loopFee = priceMap.has("SURVEY_LOOP_FEE") ? (priceMap.get("SURVEY_LOOP_FEE") as number) : DEFAULT_LOOP_FEE;
const total = Number((baseFee + loopFee * count).toFixed(2));
return { baseFee, loopFee, loopCount: count, total };
}
// 明细入参清洗: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> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where = await this.scopeWhere(this.buildWhere(query), ctx);
const { count, rows } = await this.surveyModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]],
distinct: true
});
const list = await this.decorate(rows.map((r) => this.toVO(r)));
return { code: 200, message: "获取勘察派单列表成功", data: { list, total: count, page, pageSize } };
}
// KPI 统计卡(仅按当前用户可见范围统计,默认排除草稿)
async stats(ctx: Context): Promise<any> {
const where = await this.scopeWhere({ isDraft: 0 }, ctx);
const countBy = (extra: any) => this.surveyModel.count({ where: this.mergeWhere(where, extra) });
const [total, thisMonth, lastMonth, pendingAssign, pendingWarn, surveying, pendingReview, archived, urgent, expedited, regular, onTimeCount] =
await Promise.all([
countBy({ status: { [Op.ne]: STATUS.CANCELED } }),
countBy({ [Op.and]: [literal("(created_at AT TIME ZONE 'Asia/Shanghai') >= date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai')")] }),
countBy({
[Op.and]: [
literal(
"(created_at AT TIME ZONE 'Asia/Shanghai') >= date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai') - interval '1 month'"
),
literal("(created_at AT TIME ZONE 'Asia/Shanghai') < date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai')")
]
}),
countBy({ status: STATUS.PENDING_ASSIGN }),
countBy({ status: STATUS.PENDING_ASSIGN, [Op.and]: [literal("expect_arrival_at IS NOT NULL AND expect_arrival_at <= (NOW() + interval '2 hours')")] }),
countBy({ status: STATUS.SURVEYING }),
countBy({ status: STATUS.PENDING_REVIEW }),
countBy({ status: STATUS.ARCHIVED }),
countBy({ priority: 1 }),
countBy({ priority: 2 }),
countBy({ priority: 3 }),
countBy({ status: STATUS.ARCHIVED, [Op.and]: [literal("(expect_arrival_at IS NULL OR survey_time <= expect_arrival_at)")] })
]);
const avgRow: any = await this.surveyModel.findOne({
attributes: [[literal("AVG(EXTRACT(EPOCH FROM (reviewed_at - created_at))/3600)"), "avgHours"]],
where: this.mergeWhere(where, { status: STATUS.ARCHIVED }),
raw: true
});
const monthGrowth = lastMonth > 0 ? Number((((thisMonth - lastMonth) / lastMonth) * 100).toFixed(1)) : 100;
const onTimeRate = archived > 0 ? Number(((onTimeCount / archived) * 100).toFixed(1)) : 0;
const avgReviewHours = Number(avgRow?.avgHours) > 0 ? Number(Number(avgRow.avgHours).toFixed(1)) : 0;
const planRate = total > 0 ? Number(((archived / total) * 100).toFixed(1)) : 0;
return {
code: 200,
message: "获取勘察派单统计成功",
data: {
total,
monthGrowth,
pendingAssign,
pendingWarn,
surveying,
onTimeRate,
pendingReview,
avgReviewHours,
archived,
planRate,
priorityCounts: { urgent, expedited, regular }
}
};
}
// 报酬试算
async quote(loopCount?: string): Promise<any> {
const data = await this.calcFee(Number(loopCount) || 0);
return { code: 200, message: "获取勘察报价成功", data };
}
async detail(id: number, 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 };
const vo = this.toVO(record);
await this.decorate([vo]);
vo.details = await this.getDetails(id);
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
const photoKeys: string[] = vo.photos || [];
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
vo.photoUrls = photoKeys.map((k) => urlMap[k] || "");
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
// 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
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.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
}
await this.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
}
async create(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const isAdmin = await this.isAdmin(userId);
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
if (!isAdmin) data.salesmanId = userId;
const payload = this.normalizePayload(data);
delete payload.details;
payload.surveyNo = await this.generateSurveyNo();
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
if (payload.progressDone === undefined || payload.progressDone === null) payload.progressDone = 0;
if (!payload.source) payload.source = "ledger";
if (payload.status === undefined || payload.status === null) payload.status = STATUS.PENDING_ASSIGN;
if (payload.isDraft === undefined || payload.isDraft === null) payload.isDraft = 0;
if (payload.priority === undefined || payload.priority === null) payload.priority = 3;
// 未显式传价时按 dispatch_price 试算
if (payload.estimatedFee === undefined || payload.estimatedFee === null) {
payload.estimatedFee = (await this.calcFee(payload.loopCount || 0)).total;
}
let created: SurveyOrder;
try {
created = await this.surveyModel.create(payload);
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
payload.surveyNo = await this.generateSurveyNo();
created = await this.surveyModel.create(payload);
} else {
throw err;
}
}
// 创建时若携带勘察明细则一并写入
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 };
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
const userId = ctx.state.user.userId;
const isAdmin = await this.isAdmin(userId);
if (!isAdmin) {
const visible = record.salesmanId === userId || (await this.isCustomerOwner(userId, record.customerId));
if (!visible) return { code: 403, message: "无权操作该工单", data: null };
delete data.salesmanId;
}
delete data.surveyNo;
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 };
}
// 指派 / 改派核心逻辑(批量指派复用)
private async assignCore(id: number, worker: Worker, estimatedFee?: number): Promise<{ ok: boolean; code?: number; message?: string; data?: any }> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { ok: false, code: 404, message: "勘察派单不存在" };
if (record.status !== STATUS.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
return { ok: false, code: 400, message: "当前状态不可指派" };
}
const expectMinutes = record.expectMinutes || DEFAULT_EXPECT_MINUTES;
const patch: any = {
workerId: worker.id,
surveyor: worker.workerName,
status: STATUS.SURVEYING,
expectArrivalAt: new Date(Date.now() + expectMinutes * 60000)
};
if (estimatedFee !== undefined && estimatedFee !== null) patch.estimatedFee = estimatedFee;
await record.update(patch);
// 指派站内通知(埋点式;通知失败不得阻断业务)
try {
await this.noticeRuleService.notifyDispatchAssign({
orderTypeName: "勘察",
orderNo: record.surveyNo,
customerId: Number(record.customerId),
workerId: Number(worker.id),
detailText: record.taskTitle ? `任务:${record.taskTitle}。` : "",
bizType: "survey_order",
bizId: Number(record.id)
});
} catch (err) {
console.error("[notice] 勘察派单通知失败:", err);
}
const vo = this.toVO(record);
await this.decorate([vo]);
return { ok: true, data: vo };
}
async assign(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 };
const worker = await this.workerModel.findByPk(Number(dto.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
const result = await this.assignCore(id, worker, dto.estimatedFee);
if (!result.ok) return { code: result.code, message: result.message, data: null };
await this.logService.logAction(ctx, "勘察派单", "指派", record.surveyNo, `指派勘察派单 ${record.surveyNo} 至 ${worker.workerName}`);
return { code: 200, message: "派单成功", data: result.data };
}
async batchAssign(dto: any, ctx: Context): Promise<any> {
const worker = await this.workerModel.findByPk(Number(dto.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
const rawIds: any[] = dto.ids || [];
const ids: number[] = [...new Set<number>(rawIds.map((v) => Number(v)).filter(Boolean))];
let successCount = 0;
const failIds: number[] = [];
for (const id of ids) {
try {
const result = await this.assignCore(id, worker);
if (result.ok) successCount++;
else failIds.push(id);
} catch {
failIds.push(id);
}
}
await this.logService.logAction(ctx, "勘察派单", "批量指派", "", `批量指派 ${successCount} 条勘察派单至 ${worker.workerName},失败 ${failIds.length} 条`);
return {
code: 200,
message: `批量派单完成,成功 ${successCount} 条`,
data: { successCount, failCount: failIds.length, failIds }
};
}
// 技术复核:pass→已归档;reject→退回现场勘察中
async review(id: number, dto: any, ctx: Context): Promise<any> {
if (!(await this.isAdmin(ctx.state.user.userId))) return { code: 403, message: "无权限复核该工单", data: null };
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (record.status !== STATUS.PENDING_REVIEW) return { code: 400, message: "当前状态不可复核", data: null };
const patch: any = { reviewedAt: new Date(), reviewRemark: dto.remark || null };
if (dto.result === "pass") {
patch.status = STATUS.ARCHIVED;
patch.actualFee = record.actualFee ?? record.estimatedFee;
} else {
patch.status = STATUS.SURVEYING;
}
await record.update(patch);
const vo = this.toVO(record);
await this.decorate([vo]);
await this.logService.logAction(ctx, "勘察派单", "复核", record.surveyNo, `复核勘察派单 ${record.surveyNo}: ${dto.result === "pass" ? "通过归档" : "退回勘察"}`);
return { code: 200, message: dto.result === "pass" ? "复核通过,工单已归档" : "已退回现场勘察中", data: vo };
}
async urge(id: number, 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 (![STATUS.PENDING_ASSIGN, STATUS.SURVEYING, STATUS.PENDING_REVIEW].includes(record.status)) {
return { code: 400, message: "当前状态不可催办", data: null };
}
const urgeCount = (record.urgeCount || 0) + 1;
const lastUrgedAt = new Date();
await record.update({ urgeCount, lastUrgedAt });
await this.logService.logAction(ctx, "勘察派单", "催办", record.surveyNo, `催办勘察派单 ${record.surveyNo}(第 ${urgeCount} 次)`);
return { code: 200, message: "催办成功", data: { urgeCount, lastUrgedAt } };
}
async cancel(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.ARCHIVED || record.status === STATUS.CANCELED) {
return { code: 400, message: "当前状态不可取消", data: null };
}
await record.update({ status: STATUS.CANCELED, cancelReason: dto.reason || null });
await this.logService.logAction(ctx, "勘察派单", "取消", record.surveyNo, `取消勘察派单 ${record.surveyNo}`);
return { code: 200, message: "取消工单成功", data: this.toVO(record) };
}
async delete(id: number, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
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.detailModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
return { code: 200, message: "删除勘察派单成功", data: null };
}
}