- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理 - 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式 - 新增频率预览接口,支持配置转换为cron表达式与执行时间预览 - 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
619 lines
27 KiB
TypeScript
619 lines
27 KiB
TypeScript
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 };
|
||
}
|
||
}
|