From d42e858fb064ddb354060bdfcd0df4eac97077ec Mon Sep 17 00:00:00 2001 From: wangkai <10903508@qq.com> Date: Fri, 2 Oct 2026 04:23:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20=E6=96=B0=E5=A2=9E=E6=8A=A5?= =?UTF-8?q?=E4=BF=AE=E6=B4=BE=E5=8D=95=E4=B8=8E=E7=BB=B4=E4=BF=9D=E6=B4=BE?= =?UTF-8?q?=E5=8D=95=E6=A8=A1=E5=9D=97=E5=85=A8=E6=A0=88=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: repair_request/repair_dispatch/maintenance_order/maintenance_detail 实体、DTO、Service(事务/单号/状态流转/行级权限)、Controller - 安全: JWT+RBAC 接入(dispatch:repair/dispatch:maintenance)、全参数化防SQL注入、操作日志埋点 - 前端: 报修派单(双页签)、维保派单(主从表单)页面, API封装, /dispatch 路由 - 菜单: db_seed.sql 与远程库同步 id=36/37, ADMIN/SALESMAN 角色绑定 --- .../dispatch-repair-maintenance/checklist.md | 18 + .../specs/dispatch-repair-maintenance/spec.md | 77 + .../dispatch-repair-maintenance/tasks.md | 49 + client/src/api/maintenance.ts | 22 + client/src/api/repair.ts | 32 + client/src/router/index.ts | 14 +- .../src/views/dispatch/maintenance/index.vue | 1258 ++++++++++++++++ client/src/views/dispatch/repair/index.vue | 1261 +++++++++++++++++ database/db_seed.sql | 21 +- .../src/controller/maintenance.controller.ts | 16 + server/src/controller/repair.controller.ts | 27 + server/src/dto/maintenance_order.dto.ts | 62 + server/src/dto/repair_dispatch.dto.ts | 28 + server/src/dto/repair_request.dto.ts | 12 + .../src/entity/maintenance_detail.entity.ts | 61 + server/src/entity/maintenance_order.entity.ts | 49 + server/src/entity/repair_dispatch.entity.ts | 82 ++ server/src/entity/repair_request.entity.ts | 56 + server/src/middleware/rbac.middleware.ts | 3 + server/src/service/maintenance.service.ts | 285 ++++ server/src/service/repair_dispatch.service.ts | 331 +++++ 21 files changed, 3760 insertions(+), 4 deletions(-) create mode 100644 .trae/specs/dispatch-repair-maintenance/checklist.md create mode 100644 .trae/specs/dispatch-repair-maintenance/spec.md create mode 100644 .trae/specs/dispatch-repair-maintenance/tasks.md create mode 100644 client/src/api/maintenance.ts create mode 100644 client/src/api/repair.ts create mode 100644 client/src/views/dispatch/maintenance/index.vue create mode 100644 client/src/views/dispatch/repair/index.vue create mode 100644 server/src/controller/maintenance.controller.ts create mode 100644 server/src/controller/repair.controller.ts create mode 100644 server/src/dto/maintenance_order.dto.ts create mode 100644 server/src/dto/repair_dispatch.dto.ts create mode 100644 server/src/dto/repair_request.dto.ts create mode 100644 server/src/entity/maintenance_detail.entity.ts create mode 100644 server/src/entity/maintenance_order.entity.ts create mode 100644 server/src/entity/repair_dispatch.entity.ts create mode 100644 server/src/entity/repair_request.entity.ts create mode 100644 server/src/service/maintenance.service.ts create mode 100644 server/src/service/repair_dispatch.service.ts diff --git a/.trae/specs/dispatch-repair-maintenance/checklist.md b/.trae/specs/dispatch-repair-maintenance/checklist.md new file mode 100644 index 0000000..ea3c027 --- /dev/null +++ b/.trae/specs/dispatch-repair-maintenance/checklist.md @@ -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) diff --git a/.trae/specs/dispatch-repair-maintenance/spec.md b/.trae/specs/dispatch-repair-maintenance/spec.md new file mode 100644 index 0000000..73d9c5a --- /dev/null +++ b/.trae/specs/dispatch-repair-maintenance/spec.md @@ -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 +(无) diff --git a/.trae/specs/dispatch-repair-maintenance/tasks.md b/.trae/specs/dispatch-repair-maintenance/tasks.md new file mode 100644 index 0000000..db28b7c --- /dev/null +++ b/.trae/specs/dispatch-repair-maintenance/tasks.md @@ -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 依赖全部 diff --git a/client/src/api/maintenance.ts b/client/src/api/maintenance.ts new file mode 100644 index 0000000..371b589 --- /dev/null +++ b/client/src/api/maintenance.ts @@ -0,0 +1,22 @@ +import request from '@/utils/request'; + +// 维保派单(维保工单) +export const maintenanceApi = { + // 分页查询维保工单 + getList: (params?: any) => request.get('/maintenance-orders', { params }), + + // 维保工单详情 + getDetail: (id: number) => request.get(`/maintenance-orders/${id}`), + + // 创建维保工单 + create: (data: any) => request.post('/maintenance-orders', data), + + // 更新维保工单 + update: (id: number, data: any) => request.put(`/maintenance-orders/${id}`, data), + + // 标记完成 + complete: (id: number) => request.put(`/maintenance-orders/${id}/complete`), + + // 删除维保工单 + remove: (id: number) => request.delete(`/maintenance-orders/${id}`) +}; diff --git a/client/src/api/repair.ts b/client/src/api/repair.ts new file mode 100644 index 0000000..d7a5191 --- /dev/null +++ b/client/src/api/repair.ts @@ -0,0 +1,32 @@ +import request from '@/utils/request'; + +// 报修申请 +export const repairRequestApi = { + // 分页查询报修申请 + getList: (params?: any) => request.get('/repair-requests', { params }), + + // 报修申请详情 + getDetail: (id: number) => request.get(`/repair-requests/${id}`) +}; + +// 报修派单 +export const repairDispatchApi = { + // 分页查询报修派单 + getList: (params?: any) => request.get('/repair-dispatchs', { params }), + + // 报修派单详情 + getDetail: (id: number) => request.get(`/repair-dispatchs/${id}`), + + // 创建报修派单 + create: (data: any) => request.post('/repair-dispatchs', data), + + // 更新报修派单 + update: (id: number, data: any) => request.put(`/repair-dispatchs/${id}`, data), + + // 标记完成 + complete: (id: number, data?: any) => + request.put(`/repair-dispatchs/${id}/complete`, data), + + // 删除报修派单 + remove: (id: number) => request.delete(`/repair-dispatchs/${id}`) +}; diff --git a/client/src/router/index.ts b/client/src/router/index.ts index e2856cb..da00428 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -154,9 +154,21 @@ const routes: RouteRecordRaw[] = [ { path: "dispatch", name: "Dispatch", - component: () => import("@/views/Placeholder.vue"), + 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: "device", name: "Device", diff --git a/client/src/views/dispatch/maintenance/index.vue b/client/src/views/dispatch/maintenance/index.vue new file mode 100644 index 0000000..37be050 --- /dev/null +++ b/client/src/views/dispatch/maintenance/index.vue @@ -0,0 +1,1258 @@ + + + + + diff --git a/client/src/views/dispatch/repair/index.vue b/client/src/views/dispatch/repair/index.vue new file mode 100644 index 0000000..f4964fc --- /dev/null +++ b/client/src/views/dispatch/repair/index.vue @@ -0,0 +1,1261 @@ + + + + + diff --git a/database/db_seed.sql b/database/db_seed.sql index 2140a02..ca8e1a2 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -132,8 +132,23 @@ 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; + -- 重置 sys_menu 自增序列 -SELECT setval('sys_menu_id_seq', 33, true); +SELECT setval('sys_menu_id_seq', 37, true); -- ------------------------------------------------------------ -- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行) @@ -194,10 +209,10 @@ 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), (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)及其角色绑定(幂等清理,确保每次执行都收敛) diff --git a/server/src/controller/maintenance.controller.ts b/server/src/controller/maintenance.controller.ts new file mode 100644 index 0000000..22ce9ad --- /dev/null +++ b/server/src/controller/maintenance.controller.ts @@ -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); } +} diff --git a/server/src/controller/repair.controller.ts b/server/src/controller/repair.controller.ts new file mode 100644 index 0000000..6c2a59a --- /dev/null +++ b/server/src/controller/repair.controller.ts @@ -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); } +} diff --git a/server/src/dto/maintenance_order.dto.ts b/server/src/dto/maintenance_order.dto.ts new file mode 100644 index 0000000..725ff5f --- /dev/null +++ b/server/src/dto/maintenance_order.dto.ts @@ -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[]; +} diff --git a/server/src/dto/repair_dispatch.dto.ts b/server/src/dto/repair_dispatch.dto.ts new file mode 100644 index 0000000..4262966 --- /dev/null +++ b/server/src/dto/repair_dispatch.dto.ts @@ -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; +} diff --git a/server/src/dto/repair_request.dto.ts b/server/src/dto/repair_request.dto.ts new file mode 100644 index 0000000..64ed49c --- /dev/null +++ b/server/src/dto/repair_request.dto.ts @@ -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; +} diff --git a/server/src/entity/maintenance_detail.entity.ts b/server/src/entity/maintenance_detail.entity.ts new file mode 100644 index 0000000..1873d67 --- /dev/null +++ b/server/src/entity/maintenance_detail.entity.ts @@ -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; +} diff --git a/server/src/entity/maintenance_order.entity.ts b/server/src/entity/maintenance_order.entity.ts new file mode 100644 index 0000000..4ac8121 --- /dev/null +++ b/server/src/entity/maintenance_order.entity.ts @@ -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; +} diff --git a/server/src/entity/repair_dispatch.entity.ts b/server/src/entity/repair_dispatch.entity.ts new file mode 100644 index 0000000..58cb1e8 --- /dev/null +++ b/server/src/entity/repair_dispatch.entity.ts @@ -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; +} diff --git a/server/src/entity/repair_request.entity.ts b/server/src/entity/repair_request.entity.ts new file mode 100644 index 0000000..f3ab1be --- /dev/null +++ b/server/src/entity/repair_request.entity.ts @@ -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; +} diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index e6cec2e..04e4e3d 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -26,6 +26,9 @@ const PERMISSION_MAP: Record = { '/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', }; // 超级管理员角色编码,拥有全部权限 diff --git a/server/src/service/maintenance.service.ts b/server/src/service/maintenance.service.ts new file mode 100644 index 0000000..5e35dba --- /dev/null +++ b/server/src/service/maintenance.service.ts @@ -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; + + @InjectRepository(MaintenanceDetail) + detailModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(Worker) + workerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + @Inject() + logService!: LogService; + + private async isAdmin(userId: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/server/src/service/repair_dispatch.service.ts b/server/src/service/repair_dispatch.service.ts new file mode 100644 index 0000000..d859d3e --- /dev/null +++ b/server/src/service/repair_dispatch.service.ts @@ -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; + + @InjectRepository(RepairDispatch) + dispatchModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(Worker) + workerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + @InjectDataSource() + sequelize!: Sequelize; + + @Inject() + logService!: LogService; + + private async isAdmin(userId: number): Promise { + 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 { + 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 { + if (await this.isAdmin(userId)) return true; + return this.isCustomerOwner(userId, order.customerId); + } + + // 行级隔离:非管理员仅可见「客户归属自己」的工单 + private async scopeWhere(baseWhere: any, ctx: Context): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +}