From 91aa3c06f0cc515a7c6b906ce61bfe20a442d4d8 Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Sun, 4 Oct 2026 09:26:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=B5=E5=B7=A5APP?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为四类订单表(报修/安装/维保/勘察)新增人工费、材料费、距离、预计时长字段,新增订单池、我的订单、订单详情、接单、完成订单、收益汇总接口,新增数据库迁移脚本和演示数据,支撑电工APP订单管理闭环 --- .../migrations/20261003_worker_app_orders.sql | 69 +++ database/seed_worker_app_orders.sql | 117 +++++ .../src/controller/worker_app.controller.ts | 47 +- server/src/entity/install_order.entity.ts | 12 + server/src/entity/maintenance_order.entity.ts | 12 + server/src/entity/repair_request.entity.ts | 12 + server/src/entity/survey_order.entity.ts | 6 + server/src/service/worker_app.service.ts | 492 +++++++++++++++++- 8 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 database/migrations/20261003_worker_app_orders.sql create mode 100644 database/seed_worker_app_orders.sql diff --git a/database/migrations/20261003_worker_app_orders.sql b/database/migrations/20261003_worker_app_orders.sql new file mode 100644 index 0000000..51a8030 --- /dev/null +++ b/database/migrations/20261003_worker_app_orders.sql @@ -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; diff --git a/database/seed_worker_app_orders.sql b/database/seed_worker_app_orders.sql new file mode 100644 index 0000000..a7a6278 --- /dev/null +++ b/database/seed_worker_app_orders.sql @@ -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; diff --git a/server/src/controller/worker_app.controller.ts b/server/src/controller/worker_app.controller.ts index e47712b..9fa25b9 100644 --- a/server/src/controller/worker_app.controller.ts +++ b/server/src/controller/worker_app.controller.ts @@ -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; diff --git a/server/src/entity/install_order.entity.ts b/server/src/entity/install_order.entity.ts index 1edc6da..e88114e 100644 --- a/server/src/entity/install_order.entity.ts +++ b/server/src/entity/install_order.entity.ts @@ -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; diff --git a/server/src/entity/maintenance_order.entity.ts b/server/src/entity/maintenance_order.entity.ts index 4ac8121..8605004 100644 --- a/server/src/entity/maintenance_order.entity.ts +++ b/server/src/entity/maintenance_order.entity.ts @@ -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; diff --git a/server/src/entity/repair_request.entity.ts b/server/src/entity/repair_request.entity.ts index f3ab1be..721c5d1 100644 --- a/server/src/entity/repair_request.entity.ts +++ b/server/src/entity/repair_request.entity.ts @@ -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; diff --git a/server/src/entity/survey_order.entity.ts b/server/src/entity/survey_order.entity.ts index d8918df..3deecc0 100644 --- a/server/src/entity/survey_order.entity.ts +++ b/server/src/entity/survey_order.entity.ts @@ -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; diff --git a/server/src/service/worker_app.service.ts b/server/src/service/worker_app.service.ts index 404bad1..cd77074 100644 --- a/server/src/service/worker_app.service.ts +++ b/server/src/service/worker_app.service.ts @@ -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; + @InjectRepository(RepairRequest) + requestModel!: Repository; + + @InjectRepository(RepairDispatch) + dispatchModel!: Repository; + + @InjectRepository(InstallOrder) + installModel!: Repository; + + @InjectRepository(MaintenanceOrder) + maintenanceModel!: Repository; + + @InjectRepository(SurveyOrder) + surveyModel!: Repository; + + // 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数 + @InjectDataSource() + sequelize!: Sequelize; + // 电工注册:手机号唯一校验,初始为待审核(0)且离职(0) async register(dto: WorkerRegisterDTO): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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) }; }