Merge branch 'main' of http://git.dibox.tech/parttime/smart_grid_iot
This commit is contained in:
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 依赖全部
|
||||
@@ -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}`)
|
||||
};
|
||||
@@ -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}`)
|
||||
};
|
||||
@@ -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
@@ -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); }
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user