feat: 新增现场勘察派单全流程业务能力

1.  新增共享电工管理模块:包含实体、DTO、控制器、服务以及前后端API
2.  重构勘察工单实体与查询逻辑,完善工单全状态流程与字段
3.  新增勘察SOP模板与工单回执业务体系
4.  更新数据库字典与初始化脚本,补充业务数据
5.  新增勘察派单增删改查、指派、复核、催办等全流程接口
6.  修复用户服务角色关联SQL生成问题
7.  新增勘察派单前后端完整交互链路
This commit is contained in:
andyesfly committed 2026-09-15 08:50:55 +08:00
1 parent 1c83e09fa1
commit 6b00f61550
21 files changed
+4739 -388

No files matched your search

+467 -35
View File
@@ -1,13 +1,29 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
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 SurveySopItem from "../entity/survey_sop_item.entity";
import SurveySopResult from "../entity/survey_sop_result.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";
// 勘察工单状态: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;
const DEFAULT_LOOP_FEE = 5;
// 报酬相关的价格编码
const PRICE_CODES = ["SURVEY_BASE_FEE", "SURVEY_LOOP_FEE"];
@Provide()
export class SurveyOrderService {
@InjectRepository(SurveyOrder)
@@ -16,6 +32,21 @@ export class SurveyOrderService {
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SurveySopItem)
sopItemModel!: Repository<SurveySopItem>;
@InjectRepository(SurveySopResult)
sopResultModel!: Repository<SurveySopResult>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@@ -33,6 +64,21 @@ export class SurveyOrderService {
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);
@@ -50,73 +96,459 @@ export class SurveyOrderService {
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 };
}
// 按模板批量生成回执记录(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
);
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [{ surveyor: { [Op.like]: `%${query.keyword}%` } }, { remark: { [Op.like]: `%${query.keyword}%` } }];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.status !== undefined) where.status = query.status;
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
});
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) return { code: 200, message: "获取勘察派单列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) return { code: 200, message: "获取勘察派单列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
} else {
where.customerId = { [Op.in]: ids };
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 }
}
}
};
}
const { count, rows } = await this.surveyModel.findAndCountAll({ where, limit: pageSize, offset: (page - 1) * pageSize, order: [["created_at", "DESC"]] });
return { code: 200, message: "获取勘察派单列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } };
// 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);
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.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该勘察派单", data: null };
return { code: 200, message: "获取勘察派单详情成功", data: record.toJSON() };
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.sopResults = await this.getSopResults(id);
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
// 工单回执 + 检查项信息(手动 join,避免额外关联)
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]));
return results.map((r) => {
const json: any = r.toJSON();
const item: any = itemMap.get(json.sopItemId);
return {
...json,
itemName: item?.itemName || "",
itemDesc: item?.itemDesc || "",
receiptType: item?.receiptType || "",
receiptRequire: item?.receiptRequire || "",
required: item?.required ?? false
};
});
}
async create(data: any, ctx: Context): Promise<any> {
if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
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 templateCode = data.templateCode || DEFAULT_TEMPLATE;
const payload = this.normalizePayload(data);
delete payload.templateCode;
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 {
data.surveyNo = await this.generateSurveyNo();
const created = await this.surveyModel.create(data);
return { code: 200, message: "创建勘察派单成功", data: created.toJSON() };
created = await this.surveyModel.create(payload);
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
data.surveyNo = await this.generateSurveyNo();
const created = await this.surveyModel.create(data);
return { code: 200, message: "创建勘察派单成功", data: created.toJSON() };
payload.surveyNo = await this.generateSurveyNo();
created = await this.surveyModel.create(payload);
} else {
throw err;
}
throw err;
}
await this.createSopResults(created.id, templateCode);
const vo = this.toVO(created);
await this.decorate([vo]);
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 };
if (!(await this.isAdmin(ctx.state.user.userId))) {
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该勘察派单", data: null };
delete data.customerId;
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;
await record.update(data);
return { code: 200, message: "更新勘察派单成功", data: record.toJSON() };
delete data.templateCode;
const payload = this.normalizePayload(data);
await record.update(payload);
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
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);
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 };
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);
}
}
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]);
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 });
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 });
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 };
// 级联清理回执后删除工单
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
return { code: 200, message: "删除勘察派单成功", data: null };
}
}
}
+5 -7
View File
@@ -34,17 +34,15 @@ export class UserService {
where.status = query.status;
}
// 角色过滤条件必须下移到 SysUserRole 自身的 roleId 上:
// 若改在内层 SysRole 上同时设置 where + required,Sequelize 在 findAndCountAll(subQuery+distinct)
// 下会生成缺少中间表 sys_user_role 的非法 SQL(42P01 丢失FROM子句项)
const includeOptions: any = [
{
model: SysUserRole,
required: !!query.roleId,
include: [
{
model: SysRole,
where: query.roleId ? { id: query.roleId } : {},
required: !!query.roleId
}
]
...(query.roleId ? { where: { roleId: query.roleId } } : {}),
include: [{ model: SysRole, required: false }]
}
];
+98
View File
@@ -0,0 +1,98 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import Worker from "../entity/worker.entity";
// 电工状态:1在岗 0离职
const STATUS_ON_DUTY = 1;
// 推荐位数量
const RECOMMEND_LIMIT = 5;
// 经验分基准场次(达到即满分)
const EXP_BASE_COUNT = 50;
@Provide()
export class WorkerService {
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
// 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize || query.limit) || 10;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ workerName: { [Op.like]: `%${query.keyword}%` } },
{ phone: { [Op.like]: `%${query.keyword}%` } },
{ certInfo: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
const { count, rows } = await this.workerModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["rating", "DESC"]],
distinct: true
});
return { code: 200, message: "获取共享电工列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page, pageSize } };
}
// 下拉选项:仅在岗电工
async options(ctx: Context): Promise<any> {
const rows = await this.workerModel.findAll({
where: { status: STATUS_ON_DUTY },
attributes: ["id", "workerName", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"],
order: [["rating", "DESC"]]
});
return { code: 200, message: "获取共享电工选项成功", data: rows.map((r) => r.toJSON()) };
}
// 智能推荐:评分 50% + 资质 30% + 经验 20% 加权,取前 5 名
async recommend(query: any, ctx: Context): Promise<any> {
const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY } });
if (rows.length === 0) return { code: 200, message: "获取推荐电工成功", data: [] };
const certKeyword: string = query.certKeyword || "";
const list = rows.map((row) => {
const certInfo: string = row.certInfo || "";
const rating = Number(row.rating) || 0;
const completedCount = Number(row.completedCount) || 0;
const scoreRating = rating / 5;
let scoreCert = row.certVerified ? (certInfo ? 1 : 0.7) : 0.4;
// 关键词命中持证资质时资质分置满
if (certKeyword && certInfo.includes(certKeyword)) scoreCert = 1;
const scoreExp = Math.min(completedCount / EXP_BASE_COUNT, 1);
const score = 0.5 * scoreRating + 0.3 * scoreCert + 0.2 * scoreExp;
return {
id: row.id,
workerName: row.workerName,
workerTitle: row.workerTitle,
rating,
certInfo,
certVerified: row.certVerified,
completedCount,
matchRate: Number((score * 100).toFixed(1)),
rank: 0,
isTop: false
};
});
list.sort((a, b) => b.matchRate - a.matchRate || b.rating - a.rating || b.completedCount - a.completedCount);
const data = list.slice(0, RECOMMEND_LIMIT).map((item, index) => {
const rank = index + 1;
return { ...item, rank, isTop: rank === 1 };
});
return { code: 200, message: "获取推荐电工成功", data };
}
}