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

@@ -8,8 +8,38 @@ export class SurveyOrderController {
@Inject() surveyOrderService!: SurveyOrderService;
@Get("/") async list(@Query() query: any) { return this.surveyOrderService.list(query, this.ctx); }
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
@Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
return this.surveyOrderService.listSopItems(templateCode);
}
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
// 批量指派
@Post("/batch-assign") async batchAssign(@Body() body: any) { return this.surveyOrderService.batchAssign(body, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.surveyOrderService.detail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
// 指派 / 改派共享电工
@Put("/:id/assign") async assign(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.assign(Number(id), body, this.ctx);
}
// 技术复核:pass 归档 / reject 退回现场
@Put("/:id/review") async review(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.review(Number(id), body, this.ctx);
}
// 催办
@Post("/:id/urge") async urge(@Param("id") id: string) { return this.surveyOrderService.urge(Number(id), this.ctx); }
// 取消工单
@Put("/:id/cancel") async cancel(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.cancel(Number(id), body, this.ctx);
}
@Del("/:id") async delete(@Param("id") id: string) { return this.surveyOrderService.delete(Number(id), this.ctx); }
}
}
@@ -0,0 +1,15 @@
import { Controller, Get, Inject, Query } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { WorkerService } from "../service/worker.service";
@Controller("/api/workers")
export class WorkerController {
@Inject() ctx!: Context;
@Inject() workerService!: WorkerService;
@Get("/") async list(@Query() query: any) { return this.workerService.list(query, this.ctx); }
// 静态路径必须声明在动态路径之前
@Get("/options") async options() { return this.workerService.options(this.ctx); }
@Get("/recommend") async recommend(@Query() query: any) { return this.workerService.recommend(query, this.ctx); }
}
+83 -13
View File
@@ -1,27 +1,97 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MaxLength } from "class-validator";
import {
IsNotEmpty,
IsOptional,
IsString,
IsInt,
IsNumber,
IsArray,
IsDateString,
IsIn,
ArrayNotEmpty,
Matches,
Min,
Max,
MaxLength
} from "class-validator";
export class SurveyOrderQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsInt() @Min(0) @Max(5) status?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsInt() salesmanId?: number;
// 计划到场日期窗口(YYYY-MM-DD)
@IsOptional() @IsString() @MaxLength(10) planStartDate?: string;
@IsOptional() @IsString() @MaxLength(10) planEndDate?: string;
// 是否包含草稿:字符串或数字,服务层统一转 number 判定
@IsOptional() includeDraft?: string | number;
}
export class SurveyOrderCreateDTO {
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) surveyor?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) taskTitle?: string;
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) siteLocation?: string;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
@IsOptional() @IsInt() @Min(0) loopCount?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) techContact?: string;
@IsOptional() @IsDateString() planStart?: string;
@IsOptional() @IsDateString() planEnd?: string;
@IsOptional() @IsInt() @Min(0) expectMinutes?: number;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) slaLevel?: string;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsString() surveyTime?: string;
@IsOptional() @IsInt() salesmanId?: number;
@IsOptional() @IsNumber() estimatedFee?: number;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsInt() @Min(0) @Max(2) status?: number;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
// 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
}
export class SurveyOrderUpdateDTO {
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) surveyor?: string;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsString() surveyTime?: string;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsInt() @Min(0) @Max(2) status?: number;
}
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@IsOptional() @IsInt() declare customerId: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare taskTitle?: string;
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) declare siteLocation?: string;
@IsOptional() @IsInt() @Min(1) @Max(3) declare priority?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare loopSpec?: string;
@IsOptional() @IsInt() @Min(0) declare loopCount?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare techContact?: string;
@IsOptional() @IsDateString() declare planStart?: string;
@IsOptional() @IsDateString() declare planEnd?: string;
@IsOptional() @IsInt() @Min(0) declare expectMinutes?: number;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare slaLevel?: string;
@IsOptional() @IsInt() declare workerId?: number;
@IsOptional() @IsInt() declare salesmanId?: number;
@IsOptional() @IsNumber() declare estimatedFee?: number;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
}
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
export class SurveyOrderAssignDTO {
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
@IsOptional() @IsNumber() estimatedFee?: number;
}
// 批量指派
export class SurveyOrderBatchAssignDTO {
@IsArray() @ArrayNotEmpty({ message: '请至少选择一条工单' }) @IsInt({ each: true }) ids!: number[];
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
}
// 技术复核
export class SurveyOrderReviewDTO {
@IsIn(['pass', 'reject'], { message: '复核结果不合法' }) result!: 'pass' | 'reject';
@IsOptional() @IsString() @MaxLength(500) @Matches(/^[^<>]*$/) remark?: string;
}
// 取消工单
export class SurveyOrderCancelDTO {
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) reason?: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { IsOptional, IsString, IsInt, Min, Max, MaxLength } from "class-validator";
export class WorkerQueryDTO {
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
// 兼容部分前端分页参数命名
@IsOptional() @IsInt() @Min(1) @Max(200) limit?: number;
}
export class WorkerRecommendQueryDTO {
@IsOptional() @IsInt() @Min(0) loopCount?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsString() @MaxLength(64) certKeyword?: string;
}
@@ -0,0 +1,28 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "dispatch_price",
comment: "共享电工派单价格信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class DispatchPrice extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "price_code", comment: "派单价格编码(唯一)" })
priceCode!: string;
@Column({ type: DataType.STRING(32), allowNull: false, field: "dispatch_type", comment: "派单类型:勘察/安装/预警/维修/维保" })
dispatchType!: string;
@Column({ type: DataType.STRING(32), field: "price_type", comment: "价格类型:单价/总价提成" })
priceType!: string;
@Column({ type: DataType.DECIMAL(10, 2), field: "unit_price", comment: "服务单价" })
unitPrice!: number;
@CreatedAt
created_at!: Date;
}
+93 -6
View File
@@ -1,15 +1,20 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import Worker from "./worker.entity";
@Table({
tableName: "survey_order",
comment: "客户勘察派单信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
updatedAt: "updated_at",
indexes: [
{ name: "idx_survey_customer", fields: ["customer_id"] },
{ name: "idx_survey_worker", fields: ["worker_id"] }
{ name: "idx_survey_worker", fields: ["worker_id"] },
{ name: "idx_survey_status", fields: ["status"] },
{ name: "idx_survey_priority", fields: ["priority"] },
{ name: "idx_survey_salesman", fields: ["salesman_id"] },
{ name: "idx_survey_plan_start", fields: ["plan_start"] }
]
})
export default class SurveyOrder extends Model {
@@ -26,21 +31,103 @@ export default class SurveyOrder extends Model {
@Column({ type: DataType.STRING(64), comment: "勘察人(共享电工)" })
surveyor!: string;
@ForeignKey(() => Worker)
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" })
workerId!: number;
@Column({ type: DataType.DATE, field: "survey_time", comment: "勘察时间" })
surveyTime!: Date;
@Column({ type: DataType.SMALLINT, defaultValue: 3, field: "priority", comment: "优先级:1特急P1 2加急P2 3常规P3" })
priority!: number;
@Column({ type: DataType.STRING(128), field: "task_title", comment: "勘察任务标题" })
taskTitle!: string;
@Column({ type: DataType.STRING(255), field: "site_location", comment: "具体勘察物理区域" })
siteLocation!: string;
@Column({ type: DataType.STRING(32), defaultValue: "ledger", field: "source", comment: "工单来源:ledger台账派工 visit拜访转勘察 customer客户发起" })
source!: string;
@Column({ type: DataType.STRING(128), field: "loop_spec", comment: "主进线规约与断路器容量" })
loopSpec!: string;
@Column({ type: DataType.INTEGER, field: "loop_count", comment: "预估分支测绘回路数" })
loopCount!: number;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "progress_done", comment: "已完成测绘回路数" })
progressDone!: number;
@Column({ type: DataType.INTEGER, field: "progress_total", comment: "应测绘回路总数" })
progressTotal!: number;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "drawing_count", comment: "待技术复核图纸数" })
drawingCount!: number;
@Column({ type: DataType.BIGINT, field: "salesman_id", comment: "责任业务员ID(→ sys_user)" })
salesmanId!: number;
@Column({ type: DataType.STRING(128), field: "tech_contact", comment: "现场技术联系人" })
techContact!: string;
@Column({ type: DataType.DATE, field: "plan_start", comment: "计划到场窗口开始" })
planStart!: Date;
@Column({ type: DataType.DATE, field: "plan_end", comment: "计划到场窗口结束" })
planEnd!: Date;
@Column({ type: DataType.INTEGER, field: "expect_minutes", comment: "要求接单后到场时限(分钟)" })
expectMinutes!: number;
@Column({ type: DataType.DATE, field: "expect_arrival_at", comment: "期望到场时限(指派时按 expect_minutes 推算)" })
expectArrivalAt!: Date;
@Column({ type: DataType.STRING(32), field: "sla_level", comment: "履约质保要求" })
slaLevel!: string;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "urge_count", comment: "催办次数" })
urgeCount!: number;
@Column({ type: DataType.DATE, field: "last_urged_at", comment: "最近催办时间" })
lastUrgedAt!: Date;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "is_draft", comment: "是否草稿:0正式工单 1草稿" })
isDraft!: number;
@Column({ type: DataType.DECIMAL(10, 2), field: "estimated_fee", comment: "预估劳务费(基础出场费+回路补贴)" })
estimatedFee!: number;
@Column({ type: DataType.DECIMAL(10, 2), field: "actual_fee", comment: "实际结算劳务费" })
actualFee!: number;
@Column({ type: DataType.STRING(1024), field: "photos", comment: "现场取证照片对象key(JSON数组字符串)" })
photos!: string;
@Column({ type: DataType.TEXT, field: "review_remark", comment: "技术复核意见" })
reviewRemark!: string;
@Column({ type: DataType.DATE, field: "reviewed_at", comment: "技术复核时间" })
reviewedAt!: Date;
@Column({ type: DataType.STRING(255), field: "cancel_reason", comment: "取消原因" })
cancelReason!: string;
@Column({ type: DataType.TEXT, comment: "备注" })
remark!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已接单 2已完成" })
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消" })
status!: number;
@BelongsTo(() => Customer)
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => Worker, { foreignKey: "workerId", as: "worker" })
worker!: Worker;
@CreatedAt
created_at!: Date;
}
@UpdatedAt
updated_at!: Date;
}
@@ -0,0 +1,40 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "survey_sop_item",
comment: "勘察 SOP 模板检查项表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "uk_sop_template_order", unique: true, fields: ["template_code", "sort_order"] }
]
})
export default class SurveySopItem extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, defaultValue: "standard_dist", field: "template_code", comment: "SOP模板编码:standard_dist标准配电改造模板" })
templateCode!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "检查项序号" })
sortOrder!: number;
@Column({ type: DataType.STRING(128), allowNull: false, field: "item_name", comment: "检查项名称" })
itemName!: string;
@Column({ type: DataType.STRING(255), field: "item_desc", comment: "检查项说明" })
itemDesc!: string;
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "photo", field: "receipt_type", comment: "回执形式:photo现场照片 form清单表单 meter测仪截图" })
receiptType!: string;
@Column({ type: DataType.STRING(64), field: "receipt_require", comment: "回执指标要求,如「照片 ≥ 2 张」" })
receiptRequire!: string;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: true, comment: "是否必填" })
required!: boolean;
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,45 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
import SurveySopItem from "./survey_sop_item.entity";
@Table({
tableName: "survey_sop_result",
comment: "勘察 SOP 工单回执表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "uk_sop_result_order_item", unique: true, fields: ["survey_order_id", "sop_item_id"] },
{ name: "idx_sop_result_order", fields: ["survey_order_id"] }
]
})
export default class SurveySopResult extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => SurveyOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@ForeignKey(() => SurveySopItem)
@Column({ type: DataType.BIGINT, allowNull: false, field: "sop_item_id", comment: "SOP检查项ID(→ survey_sop_item)" })
sopItemId!: number;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false, comment: "是否已完成回执" })
checked!: boolean;
@Column({ type: DataType.STRING(1024), field: "receipt_value", comment: "回执内容(照片对象key数组/表单值/截图key)" })
receiptValue!: string;
@Column({ type: DataType.STRING(255), comment: "回执备注" })
remark!: string;
@Column({ type: DataType.DATE, field: "checked_at", comment: "回执时间" })
checkedAt!: Date;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+47
View File
@@ -0,0 +1,47 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
@Table({
tableName: "worker",
comment: "共享电工信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [{ name: "idx_worker_phone", fields: ["phone"] }]
})
export default class Worker extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, field: "worker_name", comment: "电工姓名" })
workerName!: string;
@Column({ type: DataType.STRING(20), allowNull: false, comment: "手机号" })
phone!: string;
@Column({ type: DataType.STRING(18), field: "id_card", comment: "身份证号" })
idCard!: string;
@Column({ type: DataType.STRING(32), field: "worker_title", comment: "职衔:电工/资深班组长/特种高压电工" })
workerTitle!: string;
@Column({ type: DataType.DECIMAL(3, 2), defaultValue: 5.0, field: "rating", comment: "服务评分(0-5)" })
rating!: number;
@Column({ type: DataType.STRING(128), field: "cert_info", comment: "持证资质" })
certInfo!: string;
@Column({ type: DataType.BOOLEAN, defaultValue: true, field: "cert_verified", comment: "资质是否已核验" })
certVerified!: boolean;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "completed_count", comment: "历史累计勘察场次" })
completedCount!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在岗 0离职" })
status!: number;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+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 };
}
}