feat: 新增电工APP订单相关功能及数据库扩展

为四类订单表(报修/安装/维保/勘察)新增人工费、材料费、距离、预计时长字段,新增订单池、我的订单、订单详情、接单、完成订单、收益汇总接口,新增数据库迁移脚本和演示数据,支撑电工APP订单管理闭环
This commit is contained in:
andyesfly committed 2026-10-04 09:26:59 +08:00
1 parent 7b6fe46d10
commit 91aa3c06f0
8 files changed
+764 -3

No files matched your search

+490 -2
View File
@@ -1,9 +1,17 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { InjectRepository, InjectDataSource } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, Sequelize } from "sequelize";
import dayjs from "dayjs";
import Worker from "../entity/worker.entity";
import WorkerCert from "../entity/worker_cert.entity";
import SysNotice from "../entity/sys_notice.entity";
import RepairRequest from "../entity/repair_request.entity";
import RepairDispatch from "../entity/repair_dispatch.entity";
import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity";
import { TokenUtil } from "../util/token";
import { hashPassword, comparePassword } from "../util/crypto";
import {
@@ -20,6 +28,18 @@ const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
// 证照业务类型
const BIZ_TYPE_WORKER_CERT = "worker_cert";
// 订单来源元数据:业务类型(前端标签)+ 业务名(收益流水)+ 图标(前端已按此对接)
const ORDER_SOURCE_META: any = {
repair: { type: "urgent", name: "报修维修", icon: "bolt" },
install: { type: "standard", name: "安装", icon: "construction" },
maintenance: { type: "regular", name: "维保", icon: "schedule" },
survey: { type: "regular", name: "勘察", icon: "search" }
};
// 合法的订单来源
const ORDER_SOURCES = ["repair", "install", "maintenance", "survey"];
// 订单池/我的订单标题最大展示长度(超出截断,避免前端换行错乱)
const TITLE_MAX = 24;
@Provide()
export class WorkerAppService {
@Inject()
@@ -34,6 +54,25 @@ export class WorkerAppService {
@InjectRepository(SysNotice)
noticeModel!: Repository<SysNotice>;
@InjectRepository(RepairRequest)
requestModel!: Repository<RepairRequest>;
@InjectRepository(RepairDispatch)
dispatchModel!: Repository<RepairDispatch>;
@InjectRepository(InstallOrder)
installModel!: Repository<InstallOrder>;
@InjectRepository(MaintenanceOrder)
maintenanceModel!: Repository<MaintenanceOrder>;
@InjectRepository(SurveyOrder)
surveyModel!: Repository<SurveyOrder>;
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
@InjectDataSource()
sequelize!: Sequelize;
// 电工注册:手机号唯一校验,初始为待审核(0)且离职(0)
async register(dto: WorkerRegisterDTO): Promise<any> {
const exist = await this.workerModel.findOne({ where: { phone: dto.phone } });
@@ -250,6 +289,452 @@ export class WorkerAppService {
return { code: 200, message: "获取成功", data: list };
}
// ================= 订单池 / 我的订单 / 订单详情 =================
// 订单来源是否合法(控制器据此返回 400)
isValidSource(source: string): boolean {
return ORDER_SOURCES.includes(source);
}
// 订单池:四类「未被任何电工接单」的订单合并,按发布时间倒序
async orderPool(): Promise<any> {
const [repairs, installs, maintenances, surveys] = await Promise.all([
this.requestModel.findAll({ where: { status: 0 }, include: [this.customerInclude()] }),
this.installModel.findAll({ where: { workerId: null, status: 0 }, include: [this.customerInclude()] }),
this.maintenanceModel.findAll({ where: { workerId: null, status: 0 }, include: [this.customerInclude()] }),
// 勘察草稿不进入订单池
this.surveyModel.findAll({ where: { workerId: null, status: 0, isDraft: 0 }, include: [this.customerInclude()] })
]);
const merged = [
...repairs.map(row => ({ row, source: "repair" })),
...installs.map(row => ({ row, source: "install" })),
...maintenances.map(row => ({ row, source: "maintenance" })),
...surveys.map(row => ({ row, source: "survey" }))
];
// 四类订单合并后统一按创建时间倒序,最新发布的订单优先展示
merged.sort((a, b) => this.createdTime(b.row) - this.createdTime(a.row));
const list = merged.map(item => this.toOrderView(item.row.toJSON(), item.source));
return { code: 200, message: "获取成功", data: { total: list.length, list } };
}
// 我的订单:当前电工已接单,可按 pending/completed 过滤
async myOrders(workerId: number, status?: string): Promise<any> {
const collected: any[] = [];
// 报修:经 repair_dispatch 反查当前电工已接的报修单
const dispatches = await this.dispatchModel.findAll({
where: { workerId },
include: [
{
model: RepairRequest,
as: "repair",
required: true,
include: [this.customerInclude()]
}
]
});
for (const dispatch of dispatches as any[]) {
const repair = dispatch.repair;
if (!repair) continue;
const orderStatus = Number(repair.status);
if (status === "pending" && orderStatus >= 2) continue;
if (status === "completed" && orderStatus !== 2) continue;
collected.push({ row: repair, source: "repair" });
}
// 安装/维保:worker_id 直接归属当前电工;pending=已派发(0),completed=已完成(1)
const workWhere: any = { workerId };
if (status === "pending") workWhere.status = 0;
if (status === "completed") workWhere.status = 1;
const [installs, maintenances] = await Promise.all([
this.installModel.findAll({ where: { ...workWhere }, include: [this.customerInclude()] }),
this.maintenanceModel.findAll({ where: { ...workWhere }, include: [this.customerInclude()] })
]);
installs.forEach(row => collected.push({ row, source: "install" }));
maintenances.forEach(row => collected.push({ row, source: "maintenance" }));
// 勘察:pending=现场勘察中(1)/待技术复核(2);completed=已归档(3)
const surveyWhere: any = { workerId };
if (status === "pending") surveyWhere.status = { [Op.in]: [1, 2] };
if (status === "completed") surveyWhere.status = 3;
const surveys = await this.surveyModel.findAll({ where: surveyWhere, include: [this.customerInclude()] });
surveys.forEach(row => collected.push({ row, source: "survey" }));
collected.sort((a, b) => this.createdTime(b.row) - this.createdTime(a.row));
const list = collected.map(item => this.toOrderView(item.row.toJSON(), item.source));
return { code: 200, message: "获取成功", data: { total: list.length, list } };
}
// 订单详情:单条完整视图(description 不截断)
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
if (!row) return { code: 404, message: "订单不存在" };
return { code: 200, message: "获取成功", data: this.toOrderView(row.toJSON(), source) };
}
// ================= 接单 / 完成 =================
// 接单:事务 + 条件更新做并发防护,affected 为 0 即已被他人抢走
async grabOrder(workerId: number, source: string, id: number): Promise<any> {
if (source === "repair") return this.grabRepair(workerId, id);
const affected = await this.sequelize.transaction(async t => {
// 勘察接单需同时进入「现场勘察中」
const payload: any = source === "survey" ? { workerId, status: 1 } : { workerId };
const [count] = await this.sourceModel(source).update(payload, {
where: { id, workerId: null },
transaction: t
});
return count;
});
if (!affected) return { code: 409, message: "订单已被他人接单" };
return { code: 200, message: "接单成功", data: { id: `${source}-${id}`, source } };
}
// 报修接单:需额外创建 repair_dispatch 并回写报修单状态
private async grabRepair(workerId: number, id: number): Promise<any> {
const result = await this.sequelize.transaction(async t => {
// 已有派单说明该报修已被接单,直接判定冲突
const existed = await this.dispatchModel.findOne({ where: { repairId: id }, transaction: t });
if (existed) return { code: 409, message: "订单已被他人接单" };
// 原子抢占:仅当报修单仍为待处理(0)时才更新成功,避免并发下重复接单
const [affected] = await this.requestModel.update({ status: 1 }, { where: { id, status: 0 }, transaction: t });
if (!affected) return { code: 409, message: "订单已被他人接单" };
const repair = await this.requestModel.findByPk(id, { transaction: t });
// 理论上前置更新成功即存在,此处兜底避免脏数据导致空指针并回滚
if (!repair) return { code: 409, message: "订单已被他人接单" };
const dispatchNo = await this.generateDispatchNo(t);
// 派单表冗余报修单关键字段,便于管理端派单列表直接读取
await this.dispatchModel.create(
{
dispatchNo,
repairId: repair.id,
customerId: repair.customerId,
requester: repair.requester,
requesterPhone: repair.requesterPhone,
requestTime: repair.requestTime,
buildingId: repair.buildingId,
floorNo: repair.floorNo,
location: repair.location,
repairContent: repair.repairContent,
workerId,
status: 0
} as any,
{ transaction: t }
);
return { code: 200 };
});
if (result.code !== 200) return result;
return { code: 200, message: "接单成功", data: { id: `repair-${id}`, source: "repair" } };
}
// 完成订单:校验归属(不属于当前电工返回 403)
async completeOrder(workerId: number, source: string, id: number): Promise<any> {
if (source === "repair") return this.completeRepair(workerId, id);
const row: any = await this.sourceModel(source).findByPk(id);
if (!row) return { code: 404, message: "订单不存在" };
if (Number(row.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该订单" };
// 勘察完成进入「待技术复核(2)」,其余进入「已完成(1)」
const targetStatus = source === "survey" ? 2 : 1;
const isTransition = Number(row.status) !== targetStatus;
await this.sequelize.transaction(async t => {
await row.update({ status: targetStatus }, { transaction: t });
if (isTransition) await this.bumpCompletedCount(workerId, t);
});
return { code: 200, message: "已完成" };
}
// 报修完成:派单置已处理 + 报修单置已完成
private async completeRepair(workerId: number, id: number): Promise<any> {
const dispatch = await this.dispatchModel.findOne({ where: { repairId: id } });
if (!dispatch) return { code: 404, message: "订单不存在" };
if (Number(dispatch.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该订单" };
const repair = await this.requestModel.findByPk(id);
if (!repair) return { code: 404, message: "订单不存在" };
// 仅「未完成 → 完成」的首次完成才累计完成数,重复调用不重复计数
const isTransition = Number(dispatch.status) !== 1 || Number(repair.status) !== 2;
await this.sequelize.transaction(async t => {
await dispatch.update({ status: 1, completedAt: new Date() }, { transaction: t });
await repair.update({ status: 2 }, { transaction: t });
if (isTransition) await this.bumpCompletedCount(workerId, t);
});
return { code: 200, message: "已完成" };
}
// ================= 收益汇总 =================
// 收益汇总:累计/可提现/本月/近 7 日趋势/最近 20 条流水
async earningsSummary(workerId: number): Promise<any> {
const completed: any[] = [];
// 报修:以派单已处理(1)判定完成,完成时间取 dispatch.completed_at,缺失回退报修单创建时间
const dispatches = await this.dispatchModel.findAll({
where: { workerId, status: 1 },
include: [{ model: RepairRequest, as: "repair" }]
});
for (const dispatch of dispatches as any[]) {
const repair = dispatch.repair;
if (!repair) continue;
completed.push({
source: "repair",
rowId: repair.id,
jobId: repair.repairNo,
amount: this.toMoney(this.toMoney(repair.laborFee) + this.toMoney(repair.materialFee)),
time: dispatch.completedAt || repair.created_at
});
}
const [installs, maintenances, surveys] = await Promise.all([
this.installModel.findAll({ where: { workerId, status: 1 } }),
this.maintenanceModel.findAll({ where: { workerId, status: 1 } }),
// 勘察:电工完成(2 待技术复核)或已归档(3) 均视为已完成收益
this.surveyModel.findAll({ where: { workerId, status: { [Op.in]: [2, 3] } } })
]);
for (const row of installs as any[]) {
completed.push({
source: "install",
rowId: row.id,
jobId: row.installNo,
amount: this.toMoney(this.toMoney(row.laborFee) + this.toMoney(row.materialFee)),
time: row.updated_at || row.created_at
});
}
for (const row of maintenances as any[]) {
completed.push({
source: "maintenance",
rowId: row.id,
jobId: row.maintenanceNo,
amount: this.toMoney(this.toMoney(row.laborFee) + this.toMoney(row.materialFee)),
time: row.updated_at || row.created_at
});
}
for (const row of surveys as any[]) {
completed.push({
source: "survey",
rowId: row.id,
jobId: row.surveyNo,
amount: this.toMoney(row.actualFee ?? row.estimatedFee ?? 0),
time: row.updated_at || row.created_at
});
}
const totalBalance = this.toMoney(completed.reduce((sum, item) => sum + item.amount, 0));
const now = dayjs();
const monthlyEarnings = this.toMoney(
completed
.filter(item => item.time && dayjs(item.time).isSame(now, "month"))
.reduce((sum, item) => sum + item.amount, 0)
);
// 近 7 天(含今天,index 6 = 今天)每日收益
const weeklyTrend = [0, 0, 0, 0, 0, 0, 0];
const todayStart = now.startOf("day");
for (const item of completed) {
if (!item.time) continue;
const diff = todayStart.diff(dayjs(item.time).startOf("day"), "day");
if (diff >= 0 && diff < 7) weeklyTrend[6 - diff] += item.amount;
}
const transactions = completed
.filter(item => item.time)
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 20)
.map(item => {
const meta = ORDER_SOURCE_META[item.source];
return {
id: `${item.source}-${item.rowId}`,
type: "earning",
title: `任务 ID: #${item.jobId}`,
subtitle: `${dayjs(item.time).format("YYYY年MM月DD日")} · ${meta.name}`,
amount: this.toMoney(item.amount),
status: "settled",
icon: meta.icon
};
});
return {
code: 200,
message: "获取成功",
data: {
totalBalance,
withdrawableAmount: totalBalance,
monthlyEarnings,
weeklyTrend: weeklyTrend.map(v => this.toMoney(v)),
transactions
}
};
}
// ================= 订单通用辅助 =================
// 统一订单视图(前端已按此字段名对接,勿改)
private toOrderView(row: any, source: string): any {
const customer = row.customer || {};
const customerName = customer.customerName || "";
const base = { id: `${source}-${row.id}`, source, status: Number(row.status ?? 0) };
if (source === "repair") {
const content = row.repairContent || "";
const laborFee = this.toMoney(row.laborFee);
const materialFee = this.toMoney(row.materialFee);
const title = content ? this.truncateText(content, TITLE_MAX) : `${customerName} · 报修`;
return {
...base,
jobId: row.repairNo || "",
title,
type: ORDER_SOURCE_META.repair.type,
amount: this.toMoney(laborFee + materialFee),
distance: this.toNumber(row.distance),
schedule: row.requestTime ? dayjs(row.requestTime).format("MM-DD HH:mm") : "立即处理",
description: content || title,
customerName,
customerPhone: row.requesterPhone || customer.contactPhone || "",
photos: this.toPhotoArray(row.photos),
address: row.location || customer.address || "",
laborFee,
materialFee,
estimatedDuration: row.estimatedDuration || ""
};
}
if (source === "survey") {
const taskTitle = row.taskTitle || "";
const remark = row.remark || "";
const title = taskTitle || `${customerName} · 勘察`;
const amount = row.actualFee !== null && row.actualFee !== undefined ? row.actualFee : row.estimatedFee;
return {
...base,
jobId: row.surveyNo || "",
title,
type: ORDER_SOURCE_META.survey.type,
amount: this.toMoney(amount ?? 0),
distance: this.toNumber(row.distance),
schedule: row.planStart ? dayjs(row.planStart).format("MM-DD HH:mm") : "预约灵活",
description: remark || taskTitle || title,
customerName,
customerPhone: row.techContact || customer.contactPhone || "",
// 勘察 photos 为 JSON 数组字符串,需容错解析
photos: this.toPhotoArray(row.photos),
address: row.siteLocation || customer.address || "",
laborFee: 0,
materialFee: 0,
estimatedDuration: row.estimatedDuration || ""
};
}
// install / maintenance
const bizName = source === "install" ? "安装" : "维保";
const laborFee = this.toMoney(row.laborFee);
const materialFee = this.toMoney(row.materialFee);
const scheduleValue = source === "install" ? row.dispatchDate : row.maintenanceDate;
return {
...base,
jobId: (source === "install" ? row.installNo : row.maintenanceNo) || "",
title: `${customerName} · ${bizName}`,
type: ORDER_SOURCE_META[source].type,
amount: this.toMoney(laborFee + materialFee),
distance: this.toNumber(row.distance),
schedule: scheduleValue ? String(scheduleValue).slice(0, 10) : "预约灵活",
description: row.remark || `${customerName} · ${bizName}`,
customerName,
customerPhone: customer.contactPhone || "",
photos: [],
address: customer.address || "",
laborFee,
materialFee,
estimatedDuration: row.estimatedDuration || ""
};
}
// 各来源对应的仓储(返回 any 以避免泛型联合带来的类型噪音)
private sourceModel(source: string): any {
if (source === "install") return this.installModel;
if (source === "maintenance") return this.maintenanceModel;
if (source === "survey") return this.surveyModel;
return this.requestModel;
}
// 订单展示用 include:仅取客户必要字段,避免 N+1 与多余数据
private customerInclude(): any {
return {
model: Customer,
as: "customer",
attributes: ["id", "customerName", "contactPerson", "contactPhone", "address"]
};
}
// 派单号规则:PD + YYYYMMDD + 4 位当日序号(北京时间),冲突重试 1 次
private async generateDispatchNo(t: any, retry: number = 0): Promise<string> {
const prefix = `PD${dayjs().add(8, "hour").format("YYYYMMDD")}`;
const last = await this.dispatchModel.findOne({
where: { dispatchNo: { [Op.like]: `${prefix}%` } },
order: [["dispatch_no", "DESC"]],
transaction: t
});
let seq = 1;
if (last) seq = (parseInt(last.dispatchNo.slice(-4), 10) || 0) + 1;
const result = `${prefix}${String(seq).padStart(4, "0")}`;
const exists = await this.dispatchModel.findOne({ where: { dispatchNo: result }, transaction: t });
if (exists) {
if (retry < 1) return this.generateDispatchNo(t, retry + 1);
throw new Error("派单号生成冲突,请重试");
}
return result;
}
// 完成数 +1(仅在未完成 → 完成的转换时调用)
private async bumpCompletedCount(workerId: number, t: any): Promise<void> {
await this.workerModel.increment("completedCount", { by: 1, where: { id: workerId }, transaction: t });
}
// 抽取订单创建时间(新老字段兼容),用于合并排序
private createdTime(row: any): number {
const raw = row?.created_at ?? row?.createdAt;
const time = raw ? new Date(raw).getTime() : 0;
return Number.isNaN(time) ? 0 : time;
}
// 金额统一保留 2 位小数并转 number(DECIMAL 经 Sequelize 返回字符串)
private toMoney(value: any): number {
const num = Number(value);
return Number.isFinite(num) ? Number(num.toFixed(2)) : 0;
}
// 距离等数值字段统一转 number,非法值兜底 0,保证接口不返回 null/NaN
private toNumber(value: any): number {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
}
// 照片字段容错:JSONB 直接为数组;勘察为 JSON 数组字符串需解析
private toPhotoArray(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 [];
}
}
// 标题超长截断
private truncateText(text: string, max: number): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) : text;
}
// 查询某电工的全部证照
private async listCertRows(workerId: number): Promise<WorkerCert[]> {
return await this.certModel.findAll({
@@ -289,6 +774,7 @@ export class WorkerAppService {
}
// 电工基础信息(不含 passwordHash)
// 注意:rating 为 DECIMAL,Sequelize 返回字符串,必须转 number 供前端直接运算/展示
private basicInfo(worker: Worker): any {
return {
id: worker.id,
@@ -296,7 +782,9 @@ export class WorkerAppService {
phone: worker.phone,
workerTitle: worker.workerTitle,
auditStatus: worker.auditStatus,
status: worker.status
status: worker.status,
rating: worker.rating === null || worker.rating === undefined ? 5.0 : Number(worker.rating),
completedCount: worker.completedCount === null || worker.completedCount === undefined ? 0 : Number(worker.completedCount)
};
}