This commit is contained in:
andyesfly committed 2026-10-02 16:11:04 +08:00
commit 7b6fe46d10
21 files changed
+3856 -110

No files matched your search

@@ -0,0 +1,18 @@
# Checklist
- [x] 后端实体 RepairRequest / RepairDispatch 字段与 db_design D8 一致(表存在,未使用自动建表)
- [x] 报修派单接口:列表/详情/创建(事务+状态流转+单号)/更新/完成(状态0→1+completed_at)/删除 全部实现
- [x] 重复派单返回 400,已完成派单不可编辑
- [x] 后端实体 MaintenanceOrder / MaintenanceDetail 字段与 db_design D9 一致
- [x] 维保接口:主从创建/更新事务、列表/详情(含明细)、完成、删除 全部实现
- [x] 全部接口接入 JWT + RBAC(dispatch:repair / dispatch:maintenance),401/403 正确返回
- [x] 行级数据权限:非 ADMIN 按 customer_id 归属过滤;列表支持 workerId 筛选(APP 兼容)
- [x] 查询全部参数化(防 SQL 注入),排序白名单
- [x] 报修/维保创建/更新/完成/删除均写入 sys_operation_log
- [x] 前端 views/dispatch/repair、views/dispatch/maintenance 页面实现(搜索/表格/分页/弹窗/状态标签)
- [x] 前端无 v-html(防 XSS)
- [x] 菜单:db_seed.sql 与远程库均含 id=36 报修派单、id=37 维保派单,挂父菜单 id=3,ADMIN/SALESMAN 角色已绑定
- [x] 路由 /dispatch/repair、/dispatch/maintenance 注册且带 permission meta
- [x] server tsc --noEmit 通过
- [x] client vue-tsc --noEmit 通过
- [x] 核心接口 curl 冒烟通过(登录→列表→创建→详情→完成→删除,重复派单 400)
@@ -0,0 +1,77 @@
# 派单管理模块(报修派单 + 维保派单)Spec
## Why
运营派单模块目前仅有"安装派单"落地(项目管理下)。根据系统设计文档(db_design.html D8 报修域 / D9 维保域),报修派单、维保派单是运营核心业务(客户小程序报修 → 运营平台派单 → APP 电工接单处理 → 客户评分),当前 Web 端缺失完整实现。
## What Changes
- 后端新增报修派单全链路:报修单查询 + 报修派单 CRUD/派单/完成(`repair_request` / `repair_dispatch` 表已存在)
- 后端新增维保派单全链路:维保单主从(单头 `maintenance_order` + 明细 `maintenance_detail`)CRUD/完成
- 全部接口接入 JWT + RBAC 鉴权、防 SQL 注入(参数化)、操作日志埋点、统一响应格式兼容 APP
- 前端新增"报修派单"、"维保派单"两个台账页面(复用安装派单页面模式),挂载到"运营派单"菜单
- 菜单 seed 与远程库同步(父菜单 id=3 "运营派单" 下新增子菜单)
## Impact
- Affected specs: 运营派单域(D8 报修域、D9 维保域)
- Affected code:
- server: entity/ dto/ service/ controller/(新增 4 个实体域文件组)、middleware/rbac.middleware.ts、service/log.service.ts(复用)
- client: views/dispatch/repair、views/dispatch/maintenance、api/、router/index.ts
- database: db_seed.sql(菜单 seed)、远程库 sys_menu/sys_role_menu 同步
## ADDED Requirements
### Requirement: 报修单查询(repair_request)
系统 SHALL 提供报修单列表与详情查询接口,支持按客户、状态(0待处理 1已派单 2已完成)、报修时间范围、关键字(单号/报修人/报修电话)筛选。
#### Scenario: 查询报修单列表
- **WHEN** 运营人员带筛选条件 GET /api/repair-requests
- **THEN** 返回分页列表 {code,message,data:{list,total,page,pageSize}}
### Requirement: 报修派单(repair_dispatch)
系统 SHALL 支持从"待处理"报修单创建报修派单(选择共享电工),事务内将报修单状态置为"已派单",并自动生成派单号 `WH-REP-YYYY-NNNN`。报修派单支持 CRUD、标记完成(status 0→1,写 completed_at)、评分(rating)查看。已完成派单不可编辑。
#### Scenario: 报修单派单成功
- **WHEN** 运营人员对 status=0 的报修单提交派单(customer_id + worker_id + 报修信息)
- **THEN** 创建 repair_dispatch 记录,报修单 status 变为 1,返回新派单数据
#### Scenario: 重复派单被拒绝
- **WHEN** 对已派单(status=1/2)的报修单再次派单
- **THEN** 返回 400 错误"该报修单已派单"
### Requirement: 维保派单主从(maintenance_order + maintenance_detail)
系统 SHALL 支持维保单(手工录入单号 maintenance_no + 客户 + 维保人 + 维保日期 + 备注 + 状态)及其明细行(客户设备 equip_id/equip_name/equip_type、建筑/楼层/位置、配电间情况/回路情况/终端情况/维保情况说明、照片)的一体化创建、更新、列表、详情、完成、删除。
#### Scenario: 创建维保单(含明细)
- **WHEN** 运营人员提交维保单头 + ≥1 条明细
- **THEN** 事务内创建 maintenance_order 及 maintenance_detail 多条记录,返回主单详情
### Requirement: 接口鉴权
所有 `/api/repair-requests`、`/api/repair-dispatchs`、`/api/maintenance-orders` 接口 SHALL 经过 JWT 鉴权,并按权限码 `dispatch:repair` / `dispatch:maintenance` 做 RBAC 校验(ADMIN 放行,非 ADMIN 需菜单权限);未带 token 返回 401,无权限返回 403。
#### Scenario: 未登录访问
- **WHEN** 无 token 调用上述接口
- **THEN** 返回 401 "未登录"
### Requirement: 防 SQL 注入
后端查询 SHALL 全部使用 Sequelize 参数化查询(Op.like 绑定参数、排序白名单),禁止字符串拼接 SQL。
### Requirement: 行级数据权限
非 ADMIN 用户 SHALL 仅能访问其名下客户(customer_id 归属过滤)的数据,与 install_order 模式一致。列表 SHALL 支持按 workerId 筛选(供 APP 电工查本人派单)。
### Requirement: 操作日志
报修派单创建/更新/完成/删除、维保派单创建/更新/完成/删除 SHALL 写入 sys_operation_log(复用 log.service.logAction)。
### Requirement: 前端页面
系统 SHALL 提供"报修派单"(views/dispatch/repair/index.vue)与"维保派单"(views/dispatch/maintenance/index.vue)两个台账页面,复用安装派单页面的搜索区/表格/分页/弹窗表单模式;前端 SHALL 禁止 v-html(防 XSS),全部文本插值输出。
#### Scenario: 报修派单页操作
- **WHEN** 运营人员在报修派单页对报修单执行"派单"并选择电工
- **THEN** 提交成功提示"派单成功",列表刷新,报修单状态更新
### Requirement: 接口兼容 APP
所有接口 SHALL 返回统一结构 {code,message,data},分页参数统一 page/pageSize/total,时间字段 ISO 字符串,报修/维保列表支持 workerId 查询参数,便于 APP/小程序消费。
## MODIFIED Requirements
(无既有需求修改)
## REMOVED Requirements
(无)
@@ -0,0 +1,49 @@
# Tasks
- [x] Task 1: 后端报修模块(repair_request + repair_dispatch)
- [x] 1.1 实体:RepairRequest.entity.ts、RepairDispatch.entity.ts(对齐 db_design D8,表已存在,timestamps:false,索引对齐)
- [x] 1.2 DTO:repair_request.dto.ts(QueryRepairRequestDto)、repair_dispatch.dto.ts(Query/Create/Update/Complete,class-validator 全量校验)
- [x] 1.3 Service:RepairDispatchService(列表/详情/创建派单事务(报修单状态流转)/更新/完成/删除,行级权限,单号 WH-REP-YYYY-NNNN,防 SQL 注入,LogService 埋点)
- [x] 1.4 Controller:repair.controller.ts(GET /api/repair-requests、GET /api/repair-requests/:id、GET/POST/PUT/DELETE /api/repair-dispatchs、PUT /api/repair-dispatchs/:id/complete)
- [x] Task 2: 后端维保模块(maintenance_order + maintenance_detail)
- [x] 2.1 实体:MaintenanceOrder.entity.ts、MaintenanceDetail.entity.ts(对齐 db_design D9,表已存在)
- [x] 2.2 DTO:maintenance_order.dto.ts(Query/Create/Update 含明细数组校验)
- [x] 2.3 Service:MaintenanceService(主从事务创建/更新、列表/详情含明细、完成、删除,行级权限,LogService 埋点)
- [x] 2.4 Controller:maintenance.controller.ts(/api/maintenance-orders 全端点 + /:id/complete)
- [x] Task 3: 鉴权接入与菜单同步
- [x] 3.1 rbac.middleware.ts PERMISSION_MAP 增加 '/api/repair-requests'/'/api/repair-dispatchs' → 'dispatch:repair'、'/api/maintenance-orders' → 'dispatch:maintenance'
- [x] 3.2 db_seed.sql:父菜单 id=3 下新增子菜单 id=36 报修派单 /dispatch/repair、id=37 维保派单 /dispatch/maintenance(component views/dispatch/repair/index、views/dispatch/maintenance/index),sys_role_menu 绑定 ADMIN(1)/SALESMAN(2)
- [x] 3.3 远程库同步:INSERT/UPDATE sys_menu(36/37)+ sys_role_menu + 序列收敛
- [x] Task 4: 前端 API 封装
- [x] 4.1 client/src/api/repair.ts:repairRequestApi(列表/详情)、repairDispatchApi(列表/详情/创建/更新/完成/删除)
- [x] 4.2 client/src/api/maintenance.ts:maintenanceApi(列表/详情/创建/更新/完成/删除)
- [x] 4.3 确认/复用 worker 下拉接口与 customer 下拉接口(workerApi.getOptions、customerApi.getCustomerOptions 已存在复用)
- [x] Task 5: 前端报修派单页面
- [x] 5.1 views/dispatch/repair/index.vue:双页签(报修单 + 报修派单);搜索区(客户/状态/日期/关键字)、表格、分页
- [x] 5.2 派单弹窗:选报修单+选电工,提交调 POST /api/repair-dispatchs
- [x] 5.3 编辑/完成/删除弹窗与确认,状态标签,无 v-html
- [x] Task 6: 前端维保派单页面
- [x] 6.1 views/dispatch/maintenance/index.vue:搜索区/表格/分页
- [x] 6.2 创建/编辑弹窗:单头字段 + 明细行动态增删(设备/巡检项),主从提交
- [x] 6.3 完成/删除确认、状态标签,无 v-html
- [x] Task 7: 路由与菜单注册
- [x] 7.1 client/src/router/index.ts:/dispatch/repair、/dispatch/maintenance 路由(meta.permission = dispatch:repair / dispatch:maintenance)
- [x] Task 8: 构建与安全验证
- [x] 8.1 server npx tsc --noEmit 通过;client npx vue-tsc --noEmit 通过
- [x] 8.2 SQL 注入审查(全参数化)、前端 XSS 审查(无 v-html)
- [x] 8.3 接口鉴权验证(401)、核心接口冒烟(列表/创建/详情/完成/删除/重复派单 400)
# Task Dependencies
- Task 3 依赖 Task 1/2 的 controller 路由确定
- Task 4 依赖 Task 1/2 的接口定义
- Task 5 依赖 Task 4
- Task 6 依赖 Task 4
- Task 7 依赖 Task 5/6 的页面文件
- Task 8 依赖全部
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request';
// 维保派单(维保工单)
export const maintenanceApi = {
// 分页查询维保工单
getList: (params?: any) => request.get<any>('/maintenance-orders', { params }),
// 维保工单详情
getDetail: (id: number) => request.get<any>(`/maintenance-orders/${id}`),
// 创建维保工单
create: (data: any) => request.post<any>('/maintenance-orders', data),
// 更新维保工单
update: (id: number, data: any) => request.put<any>(`/maintenance-orders/${id}`, data),
// 标记完成
complete: (id: number) => request.put<any>(`/maintenance-orders/${id}/complete`),
// 删除维保工单
remove: (id: number) => request.delete(`/maintenance-orders/${id}`)
};
+32
View File
@@ -0,0 +1,32 @@
import request from '@/utils/request';
// 报修申请
export const repairRequestApi = {
// 分页查询报修申请
getList: (params?: any) => request.get<any>('/repair-requests', { params }),
// 报修申请详情
getDetail: (id: number) => request.get<any>(`/repair-requests/${id}`)
};
// 报修派单
export const repairDispatchApi = {
// 分页查询报修派单
getList: (params?: any) => request.get<any>('/repair-dispatchs', { params }),
// 报修派单详情
getDetail: (id: number) => request.get<any>(`/repair-dispatchs/${id}`),
// 创建报修派单
create: (data: any) => request.post<any>('/repair-dispatchs', data),
// 更新报修派单
update: (id: number, data: any) => request.put<any>(`/repair-dispatchs/${id}`, data),
// 标记完成
complete: (id: number, data?: any) =>
request.put<any>(`/repair-dispatchs/${id}/complete`, data),
// 删除报修派单
remove: (id: number) => request.delete(`/repair-dispatchs/${id}`)
};
+19 -7
View File
@@ -32,11 +32,11 @@ const routes: RouteRecordRaw[] = [
},
// 业务模块
{
path: "customer/info",
name: "CustomerInfo",
component: () => import("@/views/customer/info/index.vue"),
meta: { requiresAuth: true, title: "客户档案管理", permission: "customer:list" }
},
path: "customer/info",
name: "CustomerInfo",
component: () => import("@/views/customer/info/index.vue"),
meta: { requiresAuth: true, title: "客户档案管理", permission: "customer:list" }
},
{
path: "customer/department",
name: "CustomerDept",
@@ -154,9 +154,21 @@ const routes: RouteRecordRaw[] = [
{
path: "dispatch",
name: "Dispatch",
redirect: "/dispatch/price",
redirect: "/dispatch/repair",
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
},
{
path: "dispatch/repair",
name: "DispatchRepair",
component: () => import("@/views/dispatch/repair/index.vue"),
meta: { requiresAuth: true, title: "报修派单", permission: "dispatch:repair" }
},
{
path: "dispatch/maintenance",
name: "DispatchMaintenance",
component: () => import("@/views/dispatch/maintenance/index.vue"),
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
},
{
path: "dispatch/price",
name: "DispatchPrice",
@@ -271,7 +283,7 @@ router.beforeEach(async (to, _from, next) => {
return;
}
} catch (error) {
console.error('获取用户信息失败:', error);
console.error("获取用户信息失败:", error);
next({
path: "/login",
query: { redirect: to.fullPath }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17 -2
View File
@@ -132,6 +132,21 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(运营派单子菜单,parent_id=3,id:36-37)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(36, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 1, 2, 'dispatch:repair', 1),
(37, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 2, 2, 'dispatch:maintenance',1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 运营派单子菜单(parent_id=3,id:34-36 为菜单项,37-46 为按钮权限)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 1, 2, 'dispatch:price', 1),
@@ -241,11 +256,11 @@ ON CONFLICT (user_id, role_id) DO NOTHING;
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33),
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 36), (1, 37),
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46),
(2, 1), (2, 2), (2, 5),
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33)
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 36), (2, 37)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 移除已废弃的菜单(客户建筑/客户房间,id=25/26)及其角色绑定(幂等清理,确保每次执行都收敛)
@@ -0,0 +1,16 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { MaintenanceService } from "../service/maintenance.service";
@Controller("/api/maintenance-orders")
export class MaintenanceController {
@Inject() ctx!: Context;
@Inject() maintenanceService!: MaintenanceService;
@Get("/") async list(@Query() query: any) { return this.maintenanceService.listOrders(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.maintenanceService.getOrderDetail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.maintenanceService.createOrder(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.maintenanceService.updateOrder(Number(id), body, this.ctx); }
@Put("/:id/complete") async complete(@Param("id") id: string) { return this.maintenanceService.completeOrder(Number(id), this.ctx); }
@Del("/:id") async delete(@Param("id") id: string) { return this.maintenanceService.removeOrder(Number(id), this.ctx); }
}
@@ -0,0 +1,27 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { RepairDispatchService } from "../service/repair_dispatch.service";
import { QueryRepairRequestDto } from "../dto/repair_request.dto";
import { QueryRepairDispatchDto, CreateRepairDispatchDto, UpdateRepairDispatchDto, CompleteRepairDispatchDto } from "../dto/repair_dispatch.dto";
@Controller("/api/repair-requests")
export class RepairRequestController {
@Inject() ctx!: Context;
@Inject() repairDispatchService!: RepairDispatchService;
@Get("/") async list(@Query() query: QueryRepairRequestDto) { return this.repairDispatchService.listRequests(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.repairDispatchService.getRequestDetail(Number(id), this.ctx); }
}
@Controller("/api/repair-dispatchs")
export class RepairDispatchController {
@Inject() ctx!: Context;
@Inject() repairDispatchService!: RepairDispatchService;
@Get("/") async list(@Query() query: QueryRepairDispatchDto) { return this.repairDispatchService.listDispatch(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.repairDispatchService.getDispatchDetail(Number(id), this.ctx); }
@Post("/") async create(@Body() dto: CreateRepairDispatchDto) { return this.repairDispatchService.createDispatch(dto, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() dto: UpdateRepairDispatchDto) { return this.repairDispatchService.updateDispatch(Number(id), dto, this.ctx); }
@Put("/:id/complete") async complete(@Param("id") id: string, @Body() dto: CompleteRepairDispatchDto) { return this.repairDispatchService.completeDispatch(Number(id), dto, this.ctx); }
@Del("/:id") async remove(@Param("id") id: string) { return this.repairDispatchService.removeDispatch(Number(id), this.ctx); }
}
+62
View File
@@ -0,0 +1,62 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsInt,
IsArray,
IsDateString,
IsIn,
Min,
Max,
MaxLength,
ArrayMinSize,
ValidateNested
} from "class-validator";
import { Type } from "class-transformer";
export class MaintenanceOrderQueryDTO {
@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() workerId?: number;
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
// 维保日期范围 [startTime, endTime],YYYY-MM-DD
@IsOptional() @IsDateString() startTime?: string;
@IsOptional() @IsDateString() endTime?: string;
}
export class MaintenanceDetailItemDTO {
@IsOptional() @IsInt() equipId?: number;
// 若 equipId 已给而 equipName 为空,创建时按客户设备自动回填名称
@IsOptional() @IsString() @MaxLength(128) equipName?: string;
@IsOptional() @IsString() @MaxLength(32) equipType?: string;
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
@IsOptional() @IsString() @MaxLength(256) location?: string;
@IsOptional() @IsString() panelCondition?: string;
@IsOptional() @IsString() circuitCondition?: string;
@IsOptional() @IsString() terminalCondition?: string;
@IsOptional() @IsString() maintenanceDesc?: string;
@IsOptional() photos?: any;
}
export class MaintenanceOrderCreateDTO {
@IsNotEmpty({ message: "维保单号不能为空" }) @IsString() @MaxLength(64) maintenanceNo!: string;
@IsNotEmpty({ message: "所属客户不能为空" }) @IsInt() customerId!: number;
@IsOptional() @IsString() @MaxLength(64) maintainer?: string;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsDateString() maintenanceDate?: string;
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
@IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => MaintenanceDetailItemDTO) details!: MaintenanceDetailItemDTO[];
}
export class MaintenanceOrderUpdateDTO {
@IsOptional() @IsString() @MaxLength(64) maintenanceNo?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsString() @MaxLength(64) maintainer?: string;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsDateString() maintenanceDate?: string;
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
@IsOptional() @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => MaintenanceDetailItemDTO) details?: MaintenanceDetailItemDTO[];
}
+28
View File
@@ -0,0 +1,28 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsDateString, IsIn, IsArray, Min, Max, MaxLength, Matches } from "class-validator";
export class QueryRepairDispatchDto {
@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() workerId?: number;
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
// 报修时间范围 [startTime, endTime]
@IsOptional() @IsDateString() startTime?: string;
@IsOptional() @IsDateString() endTime?: string;
}
export class CreateRepairDispatchDto {
@IsNotEmpty({ message: "报修单ID不能为空" }) @IsInt() repairId!: number;
@IsNotEmpty({ message: "共享电工不能为空" }) @IsInt() workerId!: number;
}
export class UpdateRepairDispatchDto {
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) dispatcher?: string;
}
export class CompleteRepairDispatchDto {
@IsOptional() @IsArray() processPhotos?: any[];
@IsOptional() @IsInt() @Min(0) @Max(5) rating?: number;
}
+12
View File
@@ -0,0 +1,12 @@
import { IsOptional, IsString, IsInt, IsDateString, IsIn, Min, Max, MaxLength } from "class-validator";
export class QueryRepairRequestDto {
@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() @IsIn([0, 1, 2]) status?: number;
// 报修时间范围 [startTime, endTime]
@IsOptional() @IsDateString() startTime?: string;
@IsOptional() @IsDateString() endTime?: string;
}
@@ -0,0 +1,61 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import MaintenanceOrder from "./maintenance_order.entity";
import Customer from "./customer.entity";
@Table({
tableName: "maintenance_detail",
comment: "维保派单明细表",
timestamps: false,
indexes: [{ name: "idx_maint_detail_maint", fields: ["maintenance_id"] }]
})
export default class MaintenanceDetail extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => MaintenanceOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "maintenance_id", comment: "维保派单ID" })
maintenanceId!: number;
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备ID" })
equipId!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@Column({ type: DataType.STRING(128), field: "equip_name", comment: "设备名称" })
equipName!: string;
@Column({ type: DataType.STRING(32), field: "equip_type", comment: "设备类型:配电间/井/箱" })
equipType!: string;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "楼栋ID" })
buildingId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "楼层号" })
floorNo!: string;
@Column({ type: DataType.STRING(256), comment: "位置" })
location!: string;
@Column({ type: DataType.TEXT, field: "panel_condition", comment: "配电间或电箱情况" })
panelCondition!: string;
@Column({ type: DataType.TEXT, field: "circuit_condition", comment: "回路情况" })
circuitCondition!: string;
@Column({ type: DataType.TEXT, field: "terminal_condition", comment: "终端情况" })
terminalCondition!: string;
@Column({ type: DataType.TEXT, field: "maintenance_desc", comment: "维保情况说明" })
maintenanceDesc!: string;
@Column({ type: DataType.JSONB, field: "photos", comment: "现场照片(JSONB)" })
photos!: any;
@BelongsTo(() => MaintenanceOrder, { foreignKey: "maintenanceId", as: "order" })
order!: MaintenanceOrder;
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,49 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import Worker from "./worker.entity";
@Table({
tableName: "maintenance_order",
comment: "维保派单信息表",
timestamps: false,
indexes: [
{ name: "idx_maint_customer", fields: ["customer_id"] },
{ name: "idx_maint_worker", fields: ["worker_id"] }
]
})
export default class MaintenanceOrder extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "maintenance_no", comment: "维保单号(唯一,手工录入不自动生成)" })
maintenanceNo!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@Column({ type: DataType.STRING(64), comment: "维保人" })
maintainer!: string;
@ForeignKey(() => Worker)
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" })
workerId!: number;
@Column({ type: DataType.DATEONLY, field: "maintenance_date", comment: "维保日期" })
maintenanceDate!: string;
@Column({ type: DataType.TEXT, comment: "备注" })
remark!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已完成" })
status!: number;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => Worker, { foreignKey: "workerId", as: "worker" })
worker!: Worker;
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,82 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import Worker from "./worker.entity";
import RepairRequest from "./repair_request.entity";
@Table({
tableName: "repair_dispatch",
comment: "报修派单信息表",
timestamps: false,
indexes: [
{ name: "idx_repair_disp_customer", fields: ["customer_id"] },
{ name: "idx_repair_disp_worker", fields: ["worker_id"] },
{ name: "idx_repair_disp_status", fields: ["status"] }
]
})
export default class RepairDispatch extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "dispatch_no", comment: "派单号(唯一)" })
dispatchNo!: string;
@ForeignKey(() => RepairRequest)
@Column({ type: DataType.BIGINT, allowNull: false, field: "repair_id", comment: "报修单ID" })
repairId!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@Column({ type: DataType.STRING(64), comment: "报修人" })
requester!: string;
@Column({ type: DataType.STRING(20), field: "requester_phone", comment: "报修电话" })
requesterPhone!: string;
@Column({ type: DataType.DATE, field: "request_time", comment: "报修时间" })
requestTime!: Date;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID" })
buildingId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "楼层" })
floorNo!: string;
@Column({ type: DataType.STRING(256), comment: "位置" })
location!: string;
@Column({ type: DataType.TEXT, field: "repair_content", comment: "报修内容" })
repairContent!: string;
@Column({ type: DataType.STRING(64), comment: "派发人" })
dispatcher!: string;
@ForeignKey(() => Worker)
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" })
workerId!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已处理" })
status!: number;
@Column({ type: DataType.JSONB, field: "process_photos", comment: "处理完成照片(JSONB)" })
processPhotos!: any;
@Column({ type: DataType.SMALLINT, comment: "评分(客户评,0-5)" })
rating!: number;
@Column({ type: DataType.DATE, field: "completed_at", comment: "完成时间" })
completedAt!: Date;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => Worker, { foreignKey: "workerId", as: "worker" })
worker!: Worker;
@BelongsTo(() => RepairRequest, { foreignKey: "repairId", as: "repair" })
repair!: RepairRequest;
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,56 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
@Table({
tableName: "repair_request",
comment: "报修单信息表",
timestamps: false,
indexes: [
{ name: "idx_repair_customer", fields: ["customer_id"] },
{ name: "idx_repair_status", fields: ["status"] }
]
})
export default class RepairRequest extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "repair_no", comment: "报修单号(唯一)" })
repairNo!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@Column({ type: DataType.STRING(64), comment: "报修人" })
requester!: string;
@Column({ type: DataType.STRING(20), field: "requester_phone", comment: "报修电话" })
requesterPhone!: string;
@Column({ type: DataType.DATE, field: "request_time", comment: "报修时间" })
requestTime!: Date;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID" })
buildingId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "楼层" })
floorNo!: string;
@Column({ type: DataType.STRING(256), comment: "位置" })
location!: string;
@Column({ type: DataType.TEXT, field: "repair_content", comment: "报修内容" })
repairContent!: string;
@Column({ type: DataType.JSONB, comment: "报修照片(JSONB)" })
photos!: any;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0待处理 1已派单 2已完成" })
status!: number;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@CreatedAt
created_at!: Date;
}
+94 -101
View File
@@ -1,124 +1,117 @@
import { Middleware, IMiddleware } from '@midwayjs/core';
import { Context, NextFunction } from '@midwayjs/koa';
import { Provide, Inject } from '@midwayjs/core';
import { InjectRepository } from '@midwayjs/sequelize';
import { PermissionService } from '../service/permission.service';
import SysUserRole from '../entity/sysUserRole.entity';
import SysRole from '../entity/sysRole.entity';
import { Middleware, IMiddleware } from "@midwayjs/core";
import { Context, NextFunction } from "@midwayjs/koa";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { PermissionService } from "../service/permission.service";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
// 请求路径前缀 → 菜单权限标识映射
const PERMISSION_MAP: Record<string, string> = {
'/api/users': 'system:user',
'/api/roles': 'system:role',
'/api/menus': 'system:menu',
'/api/dicts': 'system:dict',
'/api/logs': 'system:log',
'/api/jobs': 'system:job',
'/api/customers': 'customer:list',
'/api/customer-departments': 'customer:department',
'/api/customer-accounts': 'customer:account',
'/api/customer-visits': 'customer:visit',
'/api/upload': 'customer:visit',
'/api/customer-buildings': 'customer:building',
'/api/customer-rooms': 'customer:room',
'/api/survey-orders': 'project:survey',
'/api/project-plans': 'project:plan',
'/api/contracts': 'project:contract',
'/api/install-orders': 'project:install',
'/api/acceptances': 'project:acceptance',
'/api/visit-records': 'project:visit',
'/api/service-prices': 'dispatch:price',
'/api/dispatch-prices': 'dispatch:cost',
'/api/workers': 'dispatch:worker',
"/api/users": "system:user",
"/api/roles": "system:role",
"/api/menus": "system:menu",
"/api/dicts": "system:dict",
"/api/logs": "system:log",
"/api/jobs": "system:job",
"/api/customers": "customer:list",
"/api/customer-departments": "customer:department",
"/api/customer-accounts": "customer:account",
"/api/customer-visits": "customer:visit",
"/api/upload": "customer:visit",
"/api/customer-buildings": "customer:building",
"/api/customer-rooms": "customer:room",
"/api/survey-orders": "project:survey",
"/api/project-plans": "project:plan",
"/api/contracts": "project:contract",
"/api/install-orders": "project:install",
"/api/acceptances": "project:acceptance",
"/api/visit-records": "project:visit",
"/api/repair-requests": "dispatch:repair",
"/api/repair-dispatchs": "dispatch:repair",
"/api/maintenance-orders": "dispatch:maintenance",
"/api/service-prices": "dispatch:price",
"/api/dispatch-prices": "dispatch:cost",
"/api/workers": "dispatch:worker"
};
// 超级管理员角色编码,拥有全部权限
const ADMIN_ROLE_CODE = 'ADMIN';
const ADMIN_ROLE_CODE = "ADMIN";
@Provide()
export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
ignore = [
'/api/auth/*',
'/api/health',
'/api/dashboard/*',
'/api/worker-app/*',
'/api/workers/options',
'/api/workers/recommend',
];
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/workers/options", "/api/workers/recommend"];
@Inject()
permissionService!: PermissionService;
@Inject()
permissionService!: PermissionService;
@InjectRepository(SysUserRole)
userRoleModel!: typeof SysUserRole;
@InjectRepository(SysUserRole)
userRoleModel!: typeof SysUserRole;
@InjectRepository(SysRole)
roleModel!: typeof SysRole;
@InjectRepository(SysRole)
roleModel!: typeof SysRole;
resolve() {
return async (ctx: Context, next: NextFunction) => {
const user = ctx.state.user;
resolve() {
return async (ctx: Context, next: NextFunction) => {
const user = ctx.state.user;
if (!user) {
ctx.status = 401;
ctx.body = {
code: 401,
message: '未登录',
};
return;
}
if (!user) {
ctx.status = 401;
ctx.body = {
code: 401,
message: "未登录"
};
return;
}
// 路径前缀匹配,未匹配到任何权限映射的请求直接放行
const permissionCode = this.matchPermissionCode(ctx.path);
if (!permissionCode) {
await next();
return;
}
// 路径前缀匹配,未匹配到任何权限映射的请求直接放行
const permissionCode = this.matchPermissionCode(ctx.path);
if (!permissionCode) {
await next();
return;
}
// 查询用户角色编码,超级管理员直接放行
const roleCodes = await this.getUserRoleCodes(user.userId);
if (roleCodes.includes(ADMIN_ROLE_CODE)) {
await next();
return;
}
// 查询用户角色编码,超级管理员直接放行
const roleCodes = await this.getUserRoleCodes(user.userId);
if (roleCodes.includes(ADMIN_ROLE_CODE)) {
await next();
return;
}
// 检查用户菜单中是否包含该权限标识
const hasPermission = await this.permissionService.checkUserPermission(
user.userId,
permissionCode
);
// 检查用户菜单中是否包含该权限标识
const hasPermission = await this.permissionService.checkUserPermission(user.userId, permissionCode);
if (!hasPermission) {
ctx.status = 403;
ctx.body = {
code: 403,
message: '没有访问权限',
};
return;
}
if (!hasPermission) {
ctx.status = 403;
ctx.body = {
code: 403,
message: "没有访问权限"
};
return;
}
await next();
};
}
await next();
};
}
private matchPermissionCode(path: string): string {
for (const [prefix, code] of Object.entries(PERMISSION_MAP)) {
if (path === prefix || path.startsWith(prefix + '/')) {
return code;
}
}
return '';
}
private matchPermissionCode(path: string): string {
for (const [prefix, code] of Object.entries(PERMISSION_MAP)) {
if (path === prefix || path.startsWith(prefix + "/")) {
return code;
}
}
return "";
}
private async getUserRoleCodes(userId: number): Promise<string[]> {
const userRoles = await this.userRoleModel.findAll({
where: { userId },
include: [this.roleModel],
});
private async getUserRoleCodes(userId: number): Promise<string[]> {
const userRoles = await this.userRoleModel.findAll({
where: { userId },
include: [this.roleModel]
});
return userRoles.map(ur => ur.role?.roleCode).filter(Boolean) as string[];
}
return userRoles.map((ur) => ur.role?.roleCode).filter(Boolean) as string[];
}
}
+285
View File
@@ -0,0 +1,285 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Context } from "@midwayjs/koa";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import MaintenanceDetail from "../entity/maintenance_detail.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
// 维保派单状态:0已派发 1已完成
const STATUS = { DISPATCHED: 0, COMPLETED: 1 };
@Provide()
export class MaintenanceService {
@InjectRepository(MaintenanceOrder)
orderModel!: Repository<MaintenanceOrder>;
@InjectRepository(MaintenanceDetail)
detailModel!: Repository<MaintenanceDetail>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
const customer = await this.customerModel.findByPk(customerId);
return !!customer && customer.salesmanId === userId;
}
// 工单可见性:管理员 / 客户归属业务员
private async isOrderVisible(userId: number, order: MaintenanceOrder): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
return this.isCustomerOwner(userId, order.customerId);
}
// query 参数 → Sequelize where(关键字/客户/电工/状态/维保日期范围)
private buildWhere(query: any): any {
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ maintenanceNo: { [Op.like]: `%${query.keyword}%` } },
{ maintainer: { [Op.like]: `%${query.keyword}%` } },
{ remark: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.workerId !== undefined && query.workerId !== "") where.workerId = Number(query.workerId);
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
// maintenance_date 范围 [startTime, endTime],YYYY-MM-DD
if (query.startTime || query.endTime) {
const cond: any = {};
if (query.startTime) cond[Op.gte] = query.startTime;
if (query.endTime) cond[Op.lte] = query.endTime;
where.maintenanceDate = cond;
}
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);
where.customerId = ids.length ? { [Op.in]: ids } : -1;
return where;
}
// 批量补充客户/电工信息,避免 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 [customers, workers] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName", "contactPerson", "contactPhone", "address"]
})
: Promise.resolve([]),
workerIds.length
? this.workerModel.findAll({
where: { id: { [Op.in]: workerIds } },
attributes: ["id", "workerName", "phone", "workerTitle"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const workerMap = new Map(workers.map((w) => [w.id, w]));
for (const row of rows) {
const customer: any = customerMap.get(row.customerId);
const worker: any = workerMap.get(row.workerId);
row.customer = customer ? customer.toJSON() : null;
row.customerName = customer?.customerName || "";
row.worker = worker ? worker.toJSON() : null;
row.workerName = worker?.workerName || "";
}
return rows;
}
// 统一清洗入参:空串转 null
private normalizePayload(data: any): any {
const payload: any = { ...data };
for (const key of Object.keys(payload)) {
if (payload[key] === "") payload[key] = null;
}
return payload;
}
async listOrders(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where = await this.scopeWhere(this.buildWhere(query), ctx);
const { count, rows } = await this.orderModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["id", "DESC"]],
distinct: true
});
const list = await this.decorate(rows.map((r) => r.toJSON()));
return { code: 200, message: "获取维保派单列表成功", data: { list, total: count, page, pageSize } };
}
async getOrderDetail(id: number, ctx: Context): Promise<any> {
const record = await this.orderModel.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 vo: any = record.toJSON();
await this.decorate([vo]);
const details = await this.detailModel.findAll({ where: { maintenanceId: id }, order: [["id", "ASC"]] });
vo.details = details.map((d) => d.toJSON());
return { code: 200, message: "获取维保派单详情成功", data: vo };
}
async createOrder(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
if (!(await this.isCustomerOwner(userId, Number(data.customerId)))) return { code: 403, message: "无权限为该客户创建维保派单", data: null };
const exists = await this.orderModel.findOne({ where: { maintenanceNo: data.maintenanceNo } });
if (exists) return { code: 400, message: "维保单号已存在", data: null };
if (data.workerId) {
const worker = await this.workerModel.findByPk(Number(data.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
}
const payload = this.normalizePayload(data);
const details = Array.isArray(payload.details) ? payload.details : [];
delete payload.details;
if (payload.status === undefined || payload.status === null) payload.status = STATUS.DISPATCHED;
const t = await this.orderModel.sequelize!.transaction();
let created!: MaintenanceOrder;
const detailRows: MaintenanceDetail[] = [];
try {
created = await this.orderModel.create(payload, { transaction: t });
for (const item of details) {
const detailPayload = this.normalizePayload({ ...item, maintenanceId: created.id, customerId: created.customerId });
detailRows.push(await this.detailModel.create(detailPayload, { transaction: t }));
}
await t.commit();
} catch (err: any) {
await t.rollback();
if (err instanceof UniqueConstraintError) return { code: 400, message: "维保单号已存在", data: null };
throw err;
}
const vo: any = created.toJSON();
await this.decorate([vo]);
vo.details = detailRows.map((d) => d.toJSON());
await this.logService.logAction(ctx, "维保派单", "创建", created.maintenanceNo, `创建维保派单 ${created.maintenanceNo}`);
return { code: 200, message: "创建维保派单成功", data: vo };
}
async updateOrder(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.orderModel.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.COMPLETED) return { code: 400, message: "已完成的派单不可修改", data: null };
if (data.maintenanceNo !== undefined && data.maintenanceNo !== record.maintenanceNo) {
const exists = await this.orderModel.findOne({ where: { maintenanceNo: data.maintenanceNo, id: { [Op.ne]: id } } });
if (exists) return { code: 400, message: "维保单号已存在", data: null };
}
if (data.customerId !== undefined && Number(data.customerId) !== Number(record.customerId)) {
if (!(await this.isCustomerOwner(ctx.state.user.userId, Number(data.customerId)))) {
return { code: 403, message: "无权限转移到该客户", data: null };
}
}
if (data.workerId) {
const worker = await this.workerModel.findByPk(Number(data.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
}
const payload = this.normalizePayload(data);
const details = Array.isArray(payload.details) ? payload.details : [];
delete payload.details;
const t = await this.orderModel.sequelize!.transaction();
const detailRows: MaintenanceDetail[] = [];
try {
await record.update(payload, { transaction: t });
if (details.length) {
await this.detailModel.destroy({ where: { maintenanceId: id }, transaction: t });
for (const item of details) {
const detailPayload = this.normalizePayload({ ...item, maintenanceId: id, customerId: record.customerId });
detailRows.push(await this.detailModel.create(detailPayload, { transaction: t }));
}
}
await t.commit();
} catch (err: any) {
await t.rollback();
throw err;
}
const vo: any = record.toJSON();
await this.decorate([vo]);
vo.details = detailRows.map((d) => d.toJSON());
await this.logService.logAction(ctx, "维保派单", "更新", record.maintenanceNo, `更新维保派单 ${record.maintenanceNo}`);
return { code: 200, message: "更新维保派单成功", data: vo };
}
// 完成派单:仅允许 0已派发 → 1已完成
async completeOrder(id: number, ctx: Context): Promise<any> {
const record = await this.orderModel.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.DISPATCHED) return { code: 400, message: "仅已派发状态的派单可标记完成", data: null };
await record.update({ status: STATUS.COMPLETED });
const vo: any = record.toJSON();
await this.decorate([vo]);
await this.logService.logAction(ctx, "维保派单", "完成", record.maintenanceNo, `完成维保派单 ${record.maintenanceNo}`);
return { code: 200, message: "维保派单已完成", data: vo };
}
async removeOrder(id: number, ctx: Context): Promise<any> {
const record = await this.orderModel.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 };
const maintenanceNo = record.maintenanceNo;
const t = await this.orderModel.sequelize!.transaction();
try {
await this.detailModel.destroy({ where: { maintenanceId: id }, transaction: t });
await record.destroy({ transaction: t });
await t.commit();
} catch (err: any) {
await t.rollback();
throw err;
}
await this.logService.logAction(ctx, "维保派单", "删除", maintenanceNo, `删除维保派单 ${maintenanceNo}`);
return { code: 200, message: "删除维保派单成功", data: null };
}
}
@@ -0,0 +1,331 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository, InjectDataSource } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, Sequelize } from "sequelize";
import { Context } from "@midwayjs/koa";
import RepairRequest from "../entity/repair_request.entity";
import RepairDispatch from "../entity/repair_dispatch.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { LogService } from "./log.service";
// 报修单状态:0待处理 1已派单 2已完成
const REQUEST_STATUS = { PENDING: 0, DISPATCHED: 1, COMPLETED: 2 };
// 报修派单状态:0已派发 1已处理
const DISPATCH_STATUS = { DISPATCHED: 0, PROCESSED: 1 };
@Provide()
export class RepairDispatchService {
@InjectRepository(RepairRequest)
requestModel!: Repository<RepairRequest>;
@InjectRepository(RepairDispatch)
dispatchModel!: Repository<RepairDispatch>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@InjectDataSource()
sequelize!: Sequelize;
@Inject()
logService!: LogService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
const customer = await this.customerModel.findByPk(customerId);
return !!customer && customer.salesmanId === userId;
}
// 工单可见性:管理员 / 客户归属业务员
private async isOrderVisible(userId: number, order: RepairDispatch): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
return this.isCustomerOwner(userId, order.customerId);
}
// 行级隔离:非管理员仅可见「客户归属自己」的工单
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);
where.customerId = ids.length ? { [Op.in]: ids } : -1;
return where;
}
// 排序白名单:仅允许 created_at / id,映射成实际列名
private buildOrder(sort?: string): [string, string][] {
const allow = new Set(["created_at", "id"]);
const field = sort && allow.has(sort) ? sort : "created_at";
return [[field, "DESC"]];
}
// 派单号规则:WH-REP-YYYY-NNNN(年内累计序号),冲突重试 1 次
private async generateDispatchNo(t?: any, retry: number = 0): Promise<string> {
const now = new Date();
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
const year = d.getUTCFullYear();
const prefix = `WH-REP-${year}-`;
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;
}
// 报修单 query 参数 → Sequelize where(关键字/客户/状态/报修时间范围)
private buildRequestWhere(query: any): any {
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ repairNo: { [Op.like]: `%${query.keyword}%` } },
{ requester: { [Op.like]: `%${query.keyword}%` } },
{ requesterPhone: { [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);
// startTime/endTime 报修时间范围,作用于 request_time
if (query.startTime || query.endTime) {
const cond: any = {};
if (query.startTime) cond[Op.gte] = query.startTime;
if (query.endTime) cond[Op.lte] = query.endTime;
where.requestTime = cond;
}
return where;
}
// 派单 query 参数 → Sequelize where(关键字/客户/电工/状态/报修时间范围)
private buildDispatchWhere(query: any): any {
const where: any = this.buildRequestWhere(query);
if (query.workerId !== undefined && query.workerId !== "") where.workerId = Number(query.workerId);
return where;
}
// 统一清洗入参:空串转 null
private normalizePayload(data: any): any {
const payload: any = { ...data };
for (const key of Object.keys(payload)) {
if (payload[key] === "") payload[key] = null;
}
return payload;
}
// 扁平化关联信息,兼容前端字段(与安装派单响应结构一致)
private decorateRequest(rows: any[]): any[] {
if (rows.length === 0) return rows;
for (const row of rows) {
row.customerName = row.customer?.customerName || "";
}
return rows;
}
private decorateDispatch(rows: any[]): any[] {
if (rows.length === 0) return rows;
for (const row of rows) {
row.customerName = row.customer?.customerName || "";
row.workerName = row.worker?.workerName || "";
row.repairNo = row.repair?.repairNo || "";
}
return rows;
}
// ================= 报修单 =================
async listRequests(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where = await this.scopeWhere(this.buildRequestWhere(query), ctx);
const { count, rows } = await this.requestModel.findAndCountAll({
where,
include: [{ model: Customer, attributes: ["id", "customerNo", "customerName", "contactPerson", "contactPhone", "address"] }],
limit: pageSize,
offset: (page - 1) * pageSize,
order: this.buildOrder(query.sort),
distinct: true
});
const list = this.decorateRequest(rows.map((r: any) => r.toJSON()));
return { code: 200, message: "获取报修单列表成功", data: { list, total: count, page, pageSize } };
}
async getRequestDetail(id: number, ctx: Context): Promise<any> {
const record = await this.requestModel.findByPk(id, {
include: [{ model: Customer, attributes: ["id", "customerNo", "customerName", "contactPerson", "contactPhone", "address", "salesmanId"] }]
});
if (!record) return { code: 404, message: "报修单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该报修单", data: null };
const vo: any = record.toJSON();
this.decorateRequest([vo]);
return { code: 200, message: "获取报修单详情成功", data: vo };
}
// ================= 派单 =================
async listDispatch(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where = await this.scopeWhere(this.buildDispatchWhere(query), ctx);
const { count, rows } = await this.dispatchModel.findAndCountAll({
where,
include: [
{ model: Customer, attributes: ["id", "customerNo", "customerName", "contactPerson", "contactPhone", "address"] },
{ model: Worker, attributes: ["id", "workerName", "phone", "workerTitle"] },
{ model: RepairRequest, as: "repair", attributes: ["id", "repairNo", "status"] }
],
limit: pageSize,
offset: (page - 1) * pageSize,
order: this.buildOrder(query.sort),
distinct: true
});
const list = this.decorateDispatch(rows.map((r: any) => r.toJSON()));
return { code: 200, message: "获取报修派单列表成功", data: { list, total: count, page, pageSize } };
}
async getDispatchDetail(id: number, ctx: Context): Promise<any> {
const record = await this.dispatchModel.findByPk(id, {
include: [
{ model: Customer, attributes: ["id", "customerNo", "customerName", "contactPerson", "contactPhone", "address", "salesmanId"] },
{ model: Worker, attributes: ["id", "workerName", "phone", "workerTitle"] },
{ model: RepairRequest, as: "repair", attributes: ["id", "repairNo", "status"] }
]
});
if (!record) return { code: 404, message: "报修派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权限访问该报修派单", data: null };
const vo: any = record.toJSON();
this.decorateDispatch([vo]);
return { code: 200, message: "获取报修派单详情成功", data: vo };
}
// 创建派单:事务内校验报修单、生成派单号、创建派单并回写报修单状态
async createDispatch(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const repairId = Number(data.repairId);
const workerId = Number(data.workerId);
const created: any = await this.sequelize.transaction(async (t) => {
const repair = await this.requestModel.findByPk(repairId, { transaction: t });
if (!repair) return { code: 404, message: "报修单不存在", data: null };
if (repair.status !== REQUEST_STATUS.PENDING) return { code: 400, message: "该报修单已派单", data: null };
if (!(await this.isCustomerOwner(userId, repair.customerId))) return { code: 403, message: "无权限为该客户派单", data: null };
const worker = await this.workerModel.findByPk(workerId, { transaction: t });
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
const dispatchNo = await this.generateDispatchNo(t);
const record = 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,
dispatcher: (ctx.state.user as any)?.username || "",
workerId,
status: DISPATCH_STATUS.DISPATCHED
}, { transaction: t });
// 回写报修单状态:0待处理 → 1已派单
await repair.update({ status: REQUEST_STATUS.DISPATCHED }, { transaction: t });
return { code: 200, record };
});
if (created.code !== 200) return created;
const vo: any = created.record.toJSON();
this.decorateDispatch([vo]);
await this.logService.logAction(ctx, "报修派单", "创建", created.record.dispatchNo, `创建报修派单 ${created.record.dispatchNo}`);
return { code: 200, message: "创建报修派单成功", data: vo };
}
async updateDispatch(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.dispatchModel.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 !== DISPATCH_STATUS.DISPATCHED) return { code: 400, message: "已处理的派单不可修改", data: null };
if (data.workerId !== undefined && data.workerId !== "" && data.workerId !== null) {
const worker = await this.workerModel.findByPk(Number(data.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
}
const payload = this.normalizePayload(data);
delete payload.repairId;
delete payload.customerId;
delete payload.status;
await record.update(payload);
const vo: any = record.toJSON();
this.decorateDispatch([vo]);
await this.logService.logAction(ctx, "报修派单", "更新", record.dispatchNo, `更新报修派单 ${record.dispatchNo}`);
return { code: 200, message: "更新报修派单成功", data: vo };
}
// 完成派单:仅允许 0已派发 → 1已处理
async completeDispatch(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.dispatchModel.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 !== DISPATCH_STATUS.DISPATCHED) return { code: 400, message: "仅已派发状态的派单可标记完成", data: null };
const payload: any = { status: DISPATCH_STATUS.PROCESSED, completedAt: new Date() };
if (data.processPhotos !== undefined && data.processPhotos !== null) payload.processPhotos = data.processPhotos;
if (data.rating !== undefined && data.rating !== "" && data.rating !== null) payload.rating = Number(data.rating);
await record.update(payload);
const vo: any = record.toJSON();
this.decorateDispatch([vo]);
await this.logService.logAction(ctx, "报修派单", "完成", record.dispatchNo, `完成报修派单 ${record.dispatchNo}`);
return { code: 200, message: "报修派单已完成", data: vo };
}
// 删除派单:事务内删除并回退关联报修单状态(1已派单 → 0待处理)
async removeDispatch(id: number, ctx: Context): Promise<any> {
const record = await this.dispatchModel.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 };
const dispatchNo = record.dispatchNo;
const repairId = record.repairId;
await this.sequelize.transaction(async (t) => {
await record.destroy({ transaction: t });
const repair = await this.requestModel.findByPk(repairId, { transaction: t });
if (repair && repair.status === REQUEST_STATUS.DISPATCHED) {
await repair.update({ status: REQUEST_STATUS.PENDING }, { transaction: t });
}
});
await this.logService.logAction(ctx, "报修派单", "删除", dispatchNo, `删除报修派单 ${dispatchNo}`);
return { code: 200, message: "删除报修派单成功", data: null };
}
}