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; @InjectRepository(Customer) customerModel!: Repository; @InjectRepository(Worker) workerModel!: Repository; @InjectRepository(SurveyDetail) detailModel!: Repository; @InjectRepository(DispatchPrice) priceModel!: Repository; @InjectRepository(SysUser) userModel!: Repository; @InjectRepository(SysUserRole) userRoleModel!: Repository; @InjectRepository(SysRole) roleModel!: Repository; @Inject() uploadService!: UploadService; @Inject() logService!: LogService; @Inject() noticeRuleService!: NoticeRuleService; private async isAdmin(userId: number): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(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 { 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 { 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 { const data = await this.calcFee(Number(loopCount) || 0); return { code: 200, message: "获取勘察报价成功", data }; } async detail(id: number, ctx: Context): Promise { 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 { 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 { 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 { 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 { 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 { 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(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 { 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 { 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 { 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 { 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 }; } }