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

@@ -0,0 +1,69 @@
-- 电工 APP(VoltLink)订单池 / 订单 / 收益功能扩展
-- 背景:电工 APP 需要统一的「订单池 - 我的订单 - 收益」闭环。四类派单表
-- (repair_request / install_order / maintenance_order / survey_order)此前缺少
-- 报价(人工费/材料费)、距离、预计时长等展示字段,前端无法计算预估收益与列表展示。
-- 本期为四张表补齐这些展示/计价字段(勘察复用已有 estimated_fee / actual_fee,不新增费用列),
-- 并补充「按电工 + 状态」的联合查询索引,支撑订单池与收益统计。
-- 幂等:新增列一律带 DEFAULT + IF NOT EXISTS;索引用 IF NOT EXISTS;重复执行安全。
-- 注意:repair_request / install_order / maintenance_order 无 updated_at 列,收益时间以 created_at 兜底,
-- 无需为此新增 updated_at(避免改动既有审计语义)。
BEGIN;
-- ------------------------------------------------------------
-- 1) repair_request 表扩展:报价与展示字段
-- ------------------------------------------------------------
ALTER TABLE repair_request ADD COLUMN IF NOT EXISTS labor_fee NUMERIC(10,2) NOT NULL DEFAULT 0; -- 人工费(预估价,电工收益 = 人工费 + 材料费)
ALTER TABLE repair_request ADD COLUMN IF NOT EXISTS material_fee NUMERIC(10,2) NOT NULL DEFAULT 0; -- 材料费(预估价)
ALTER TABLE repair_request ADD COLUMN IF NOT EXISTS distance NUMERIC(6,2) NOT NULL DEFAULT 0; -- 距电工距离(km)
ALTER TABLE repair_request ADD COLUMN IF NOT EXISTS estimated_duration VARCHAR(64); -- 预计时长文本,如 "1.5 - 2 小时"
COMMENT ON COLUMN repair_request.labor_fee IS '人工费(预估价,电工收益 = 人工费 + 材料费)';
COMMENT ON COLUMN repair_request.material_fee IS '材料费(预估价)';
COMMENT ON COLUMN repair_request.distance IS '距电工距离(km)';
COMMENT ON COLUMN repair_request.estimated_duration IS '预计时长文本,如 "1.5 - 2 小时"';
-- ------------------------------------------------------------
-- 2) install_order 表扩展:报价与展示字段
-- ------------------------------------------------------------
ALTER TABLE install_order ADD COLUMN IF NOT EXISTS labor_fee NUMERIC(10,2) NOT NULL DEFAULT 0; -- 人工费(预估价)
ALTER TABLE install_order ADD COLUMN IF NOT EXISTS material_fee NUMERIC(10,2) NOT NULL DEFAULT 0; -- 材料费(预估价)
ALTER TABLE install_order ADD COLUMN IF NOT EXISTS distance NUMERIC(6,2) NOT NULL DEFAULT 0; -- 距电工距离(km)
ALTER TABLE install_order ADD COLUMN IF NOT EXISTS estimated_duration VARCHAR(64); -- 预计时长文本
COMMENT ON COLUMN install_order.labor_fee IS '人工费(预估价,电工收益 = 人工费 + 材料费)';
COMMENT ON COLUMN install_order.material_fee IS '材料费(预估价)';
COMMENT ON COLUMN install_order.distance IS '距电工距离(km)';
COMMENT ON COLUMN install_order.estimated_duration IS '预计时长文本,如 "1.5 - 2 小时"';
-- ------------------------------------------------------------
-- 3) maintenance_order 表扩展:报价与展示字段
-- ------------------------------------------------------------
ALTER TABLE maintenance_order ADD COLUMN IF NOT EXISTS labor_fee NUMERIC(10,2) NOT NULL DEFAULT 0; -- 人工费(预估价)
ALTER TABLE maintenance_order ADD COLUMN IF NOT EXISTS material_fee NUMERIC(10,2) NOT NULL DEFAULT 0; -- 材料费(预估价)
ALTER TABLE maintenance_order ADD COLUMN IF NOT EXISTS distance NUMERIC(6,2) NOT NULL DEFAULT 0; -- 距电工距离(km)
ALTER TABLE maintenance_order ADD COLUMN IF NOT EXISTS estimated_duration VARCHAR(64); -- 预计时长文本
COMMENT ON COLUMN maintenance_order.labor_fee IS '人工费(预估价,电工收益 = 人工费 + 材料费)';
COMMENT ON COLUMN maintenance_order.material_fee IS '材料费(预估价)';
COMMENT ON COLUMN maintenance_order.distance IS '距电工距离(km)';
COMMENT ON COLUMN maintenance_order.estimated_duration IS '预计时长文本,如 "1.5 - 2 小时"';
-- ------------------------------------------------------------
-- 4) survey_order 表扩展:距离与预计时长(金额复用 estimated_fee / actual_fee)
-- ------------------------------------------------------------
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS distance NUMERIC(6,2) NOT NULL DEFAULT 0; -- 距电工距离(km)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS estimated_duration VARCHAR(64); -- 预计时长文本
COMMENT ON COLUMN survey_order.distance IS '距电工距离(km)';
COMMENT ON COLUMN survey_order.estimated_duration IS '预计时长文本,如 "1.5 - 2 小时"';
-- ------------------------------------------------------------
-- 5) 查询索引:订单池按「未接单 + 状态」过滤,我的订单/收益按「电工 + 状态」过滤
-- 既有单列索引(worker_id / status)在多条件查询下选择性不足,补充联合索引。
-- ------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_repair_disp_worker_status ON repair_dispatch (worker_id, status);
CREATE INDEX IF NOT EXISTS idx_install_worker_status ON install_order (worker_id, status);
CREATE INDEX IF NOT EXISTS idx_maint_worker_status ON maintenance_order (worker_id, status);
CREATE INDEX IF NOT EXISTS idx_survey_worker_status ON survey_order (worker_id, status);
COMMIT;
+117
View File
@@ -0,0 +1,117 @@
-- ============================================================
-- 电工 APP(VoltLink)订单池演示数据
--
-- 依赖:
-- 1) db_init.sql 基础表结构
-- 2) 20261003_worker_app_orders.sql 报价/距离/时长等展示字段
--
-- 用法:
-- psql -U postgres -d xintong -f database/seed_worker_app_orders.sql
--
-- 特性:
-- - 独立事务(BEGIN/COMMIT)
-- - 幂等可重复执行:全部使用「固定业务单号 + ON CONFLICT DO NOTHING」
-- - 不写死主键,id 均由序列自增
-- - 生成「未被任何电工接单」的订单池数据:报修 3 / 安装 2 / 维保 2 / 勘察 2
-- ============================================================
BEGIN;
-- ------------------------------------------------------------
-- 1. 演示客户(customer_no 唯一,作为全部演示订单的归属客户)
-- ------------------------------------------------------------
INSERT INTO customer (customer_no, customer_name, province, city, district, address, contact_person, contact_phone, customer_level, status)
VALUES ('KH-DEMO-0001', '示例智电科技园', '江苏省', '南京市', '江宁区', '江宁区智汇路 88 号', '张工', '13800000000', '企业', 3)
ON CONFLICT (customer_no) DO NOTHING;
-- ------------------------------------------------------------
-- 2. 演示方案(install_order.plan_id 为必填外键,需保证存在可引用的 project_plan)
-- 优先保证存在一条固定的演示方案;install_order 插入时再兜底取「任意已存在的方案」
-- ------------------------------------------------------------
INSERT INTO project_plan (plan_no, customer_id, registrar, register_time, plan_desc, status)
SELECT 'FA-DEMO-0001', c.id, '演示业务员', CURRENT_DATE, '电工 APP 订单池演示方案(自动生成)', 1
FROM customer c
WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (plan_no) DO NOTHING;
-- ------------------------------------------------------------
-- 3. 报修单(status=0 待处理,进入订单池)—— 3 条,金额各不相同
-- ------------------------------------------------------------
INSERT INTO repair_request
(repair_no, customer_id, requester, requester_phone, request_time, location, repair_content, photos, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'BX-DEMO-0001', c.id, '张工', '13800000001', NOW() - INTERVAL '2 hour', 'A 栋 3 层配电间',
'配电箱空开频繁跳闸,怀疑线路过载,请尽快上门排查处理', '[]'::jsonb, 0, 150.00, 80.00, 5.20, '1 - 1.5 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (repair_no) DO NOTHING;
INSERT INTO repair_request
(repair_no, customer_id, requester, requester_phone, request_time, location, repair_content, photos, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'BX-DEMO-0002', c.id, '李主管', '13800000002', NOW() - INTERVAL '5 hour', 'B 栋 1 层强电井',
'楼层照明回路部分失电,需检测并更换损坏断路器', '[]'::jsonb, 0, 260.00, 120.00, 12.40, '2 - 3 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (repair_no) DO NOTHING;
INSERT INTO repair_request
(repair_no, customer_id, requester, requester_phone, request_time, location, repair_content, photos, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'BX-DEMO-0003', c.id, '王工', '13800000003', NOW() - INTERVAL '30 minute', 'C 栋 2 层办公室',
'插座无电,需排查线路并更换面板', '[]'::jsonb, 0, 90.00, 35.00, 3.10, '1 小时以内'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (repair_no) DO NOTHING;
-- ------------------------------------------------------------
-- 4. 安装派单(worker_id 为空,status=0 已派发,进入订单池)—— 2 条
-- plan_id 优先取已存在的任意方案,其次回退到演示方案
-- ------------------------------------------------------------
INSERT INTO install_order
(install_no, customer_id, installer, worker_id, dispatch_date, start_date, end_date, plan_id, remark, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'AZ-DEMO-0001', c.id, NULL, NULL, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE + 3,
COALESCE((SELECT id FROM project_plan ORDER BY id LIMIT 1), (SELECT id FROM project_plan WHERE plan_no = 'FA-DEMO-0001')),
'智能电表安装 12 台,含桥架布线', 0, 480.00, 260.00, 12.50, '4 - 6 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (install_no) DO NOTHING;
INSERT INTO install_order
(install_no, customer_id, installer, worker_id, dispatch_date, start_date, end_date, plan_id, remark, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'AZ-DEMO-0002', c.id, NULL, NULL, CURRENT_DATE + 1, CURRENT_DATE + 1, CURRENT_DATE + 2,
COALESCE((SELECT id FROM project_plan ORDER BY id LIMIT 1), (SELECT id FROM project_plan WHERE plan_no = 'FA-DEMO-0001')),
'智能网关与采集器安装调试 3 套', 0, 320.00, 180.00, 6.80, '3 - 4 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (install_no) DO NOTHING;
-- ------------------------------------------------------------
-- 5. 维保派单(worker_id 为空,status=0 已派发,进入订单池)—— 2 条
-- ------------------------------------------------------------
INSERT INTO maintenance_order
(maintenance_no, customer_id, maintainer, worker_id, maintenance_date, remark, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'WB-DEMO-0001', c.id, NULL, NULL, CURRENT_DATE + 2,
'季度维保:配电柜除尘、紧固、绝缘检测', 0, 200.00, 60.00, 8.30, '2 - 3 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (maintenance_no) DO NOTHING;
INSERT INTO maintenance_order
(maintenance_no, customer_id, maintainer, worker_id, maintenance_date, remark, status, labor_fee, material_fee, distance, estimated_duration)
SELECT 'WB-DEMO-0002', c.id, NULL, NULL, CURRENT_DATE + 5,
'年度维保:变压器巡检与接地电阻测试', 0, 180.00, 40.00, 15.60, '2 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (maintenance_no) DO NOTHING;
-- ------------------------------------------------------------
-- 6. 勘察派单(worker_id 为空,status=0 待指派,is_draft=0,进入订单池)—— 2 条
-- 金额复用 estimated_fee / actual_fee,不新增费用列
-- ------------------------------------------------------------
INSERT INTO survey_order
(survey_no, customer_id, surveyor, worker_id, survey_time, priority, task_title, site_location, source, tech_contact,
plan_start, plan_end, is_draft, estimated_fee, actual_fee, photos, remark, status, distance, estimated_duration)
SELECT 'KC-DEMO-0001', c.id, NULL, NULL, NOW(), 3, '新增大功率充电桩勘察', 'C 栋地下车库', 'ledger', '13800000002',
NOW() + INTERVAL '1 day', NOW() + INTERVAL '1 day 2 hour', 0, 260.00, NULL, '[]', '需评估配电容量与布线路径', 0, 8.30, '2 - 3 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (survey_no) DO NOTHING;
INSERT INTO survey_order
(survey_no, customer_id, surveyor, worker_id, survey_time, priority, task_title, site_location, source, tech_contact,
plan_start, plan_end, is_draft, estimated_fee, actual_fee, photos, remark, status, distance, estimated_duration)
SELECT 'KC-DEMO-0002', c.id, NULL, NULL, NOW(), 3, '配电房扩容勘察', 'A 栋 1 层配电房', 'ledger', '13800000003',
NOW() + INTERVAL '2 day', NOW() + INTERVAL '2 day 2 hour', 0, 180.00, NULL, '[]', '需确认扩容后回路数与母线规格', 0, 4.60, '1.5 - 2 小时'
FROM customer c WHERE c.customer_no = 'KH-DEMO-0001'
ON CONFLICT (survey_no) DO NOTHING;
COMMIT;
+46 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Del, Inject, Body, Param } from "@midwayjs/core";
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { WorkerAppService } from "../service/worker_app.service";
import {
@@ -72,6 +72,51 @@ export class WorkerAppController {
return this.workerAppService.listNotices(this.currentWorkerId());
}
// 订单池:静态路径必须声明在动态路径 /orders/:source/:id 之前
@Get("/orders/pool")
async orderPool() {
return this.workerAppService.orderPool();
}
// 我的订单(可按 status=pending|completed 过滤)
@Get("/orders")
async myOrders(@Query("status") status?: string) {
return this.workerAppService.myOrders(this.currentWorkerId(), status);
}
// 订单详情
@Get("/orders/:source/:id")
async orderDetail(@Param("source") source: string, @Param("id") id: string) {
if (!this.workerAppService.isValidSource(source)) {
return { code: 400, message: "不支持的订单来源" };
}
return this.workerAppService.orderDetail(this.currentWorkerId(), source, Number(id));
}
// 接单
@Post("/orders/:source/:id/grab")
async grabOrder(@Param("source") source: string, @Param("id") id: string) {
if (!this.workerAppService.isValidSource(source)) {
return { code: 400, message: "不支持的订单来源" };
}
return this.workerAppService.grabOrder(this.currentWorkerId(), source, Number(id));
}
// 完成订单
@Put("/orders/:source/:id/complete")
async completeOrder(@Param("source") source: string, @Param("id") id: string) {
if (!this.workerAppService.isValidSource(source)) {
return { code: 400, message: "不支持的订单来源" };
}
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
}
// 收益汇总
@Get("/earnings/summary")
async earningsSummary() {
return this.workerAppService.earningsSummary(this.currentWorkerId());
}
// 当前登录电工 ID(由 WorkerAuthMiddleware 写入)
private currentWorkerId(): number {
return this.ctx.state.worker?.workerId;
+12
View File
@@ -52,6 +52,18 @@ export default class InstallOrder extends Model {
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已完成" })
status!: number;
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "labor_fee", comment: "人工费(电工收益 = 人工费 + 材料费)" })
laborFee!: number;
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "material_fee", comment: "材料费" })
materialFee!: number;
@Column({ type: DataType.DECIMAL(6, 2), allowNull: false, defaultValue: 0, field: "distance", comment: "距电工距离(km)" })
distance!: number;
@Column({ type: DataType.STRING(64), field: "estimated_duration", comment: "预计时长文本" })
estimatedDuration!: string;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@@ -38,6 +38,18 @@ export default class MaintenanceOrder extends Model {
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已完成" })
status!: number;
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "labor_fee", comment: "人工费(电工收益 = 人工费 + 材料费)" })
laborFee!: number;
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "material_fee", comment: "材料费" })
materialFee!: number;
@Column({ type: DataType.DECIMAL(6, 2), allowNull: false, defaultValue: 0, field: "distance", comment: "距电工距离(km)" })
distance!: number;
@Column({ type: DataType.STRING(64), field: "estimated_duration", comment: "预计时长文本" })
estimatedDuration!: string;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@@ -48,6 +48,18 @@ export default class RepairRequest extends Model {
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0待处理 1已派单 2已完成" })
status!: number;
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "labor_fee", comment: "人工费(电工收益 = 人工费 + 材料费)" })
laborFee!: number;
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "material_fee", comment: "材料费" })
materialFee!: number;
@Column({ type: DataType.DECIMAL(6, 2), allowNull: false, defaultValue: 0, field: "distance", comment: "距电工距离(km)" })
distance!: number;
@Column({ type: DataType.STRING(64), field: "estimated_duration", comment: "预计时长文本" })
estimatedDuration!: string;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
+6
View File
@@ -119,6 +119,12 @@ export default class SurveyOrder extends Model {
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消" })
status!: number;
@Column({ type: DataType.DECIMAL(6, 2), allowNull: false, defaultValue: 0, field: "distance", comment: "距电工距离(km)" })
distance!: number;
@Column({ type: DataType.STRING(64), field: "estimated_duration", comment: "预计时长文本" })
estimatedDuration!: string;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
+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)
};
}