feat: 新增现场勘察派单全流程业务能力
1. 新增共享电工管理模块:包含实体、DTO、控制器、服务以及前后端API 2. 重构勘察工单实体与查询逻辑,完善工单全状态流程与字段 3. 新增勘察SOP模板与工单回执业务体系 4. 更新数据库字典与初始化脚本,补充业务数据 5. 新增勘察派单增删改查、指派、复核、催办等全流程接口 6. 修复用户服务角色关联SQL生成问题 7. 新增勘察派单前后端完整交互链路
This commit is contained in:
1 parent
1c83e09fa1
commit
6b00f61550
21 files changed
+4739
-388
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user