Przeglądaj źródła

feat(wm-dispatch): #60 综合工单管理(创建/分派/处理/完成/统计)

- Entity: WorkOrder(增强), WorkOrderLog, WorkOrderAssignment
- DTO: CreateRequest, QueryRequest, AssignRequest, ProcessRequest, StatVO, BatchAssignRequest, BatchCancelRequest
- Mapper: WorkOrderMapper(增强统计SQL), WorkOrderLogMapper, WorkOrderAssignmentMapper
- Service: WorkOrderLifecycleService(全生命周期), WorkOrderLedgerService(台账查询), WorkOrderStatisticsService(统计分析)
- Controller: WorkOrderController(16个端点)
- DDL: V3__workorder.sql(3张表+索引)
- Test: WorkOrderLifecycleServiceTest(15个), WorkOrderStatisticsServiceTest(3个)

功能特性:
- 工单创建: 支持REPAIR/INSPECTION/COMPLAINT/EMERGENCY四种类型 + LOW/MEDIUM/HIGH/URGENT优先级 + 附件
- 工单分派: 手动/自动分派 + 多人协作(主负责人+协助人)
- 工单处理: 状态流转(待办→已分派→处理中→已完成/已驳回/已取消) + 处理日志
- 工单统计: 完成率/平均处理时长/按类型分布/按优先级分布/按人员分布/逾期数/今日统计
bot_dev2 5 dni temu
rodzic
commit
32c3272a69
20 zmienionych plików z 1600 dodań i 9 usunięć
  1. 87
    0
      db/postgresql/V3__workorder.sql
  2. 152
    0
      wm-dispatch/src/main/java/com/water/dispatch/controller/WorkOrderController.java
  3. 94
    8
      wm-dispatch/src/main/java/com/water/dispatch/entity/WorkOrder.java
  4. 53
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/WorkOrderAssignment.java
  5. 47
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/WorkOrderLog.java
  6. 40
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderAssignRequest.java
  7. 17
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderBatchAssignRequest.java
  8. 17
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderBatchCancelRequest.java
  9. 54
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderCreateRequest.java
  10. 28
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderProcessRequest.java
  11. 48
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderQueryRequest.java
  12. 42
    0
      wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderStatVO.java
  13. 9
    0
      wm-dispatch/src/main/java/com/water/dispatch/mapper/WorkOrderAssignmentMapper.java
  14. 9
    0
      wm-dispatch/src/main/java/com/water/dispatch/mapper/WorkOrderLogMapper.java
  15. 27
    1
      wm-dispatch/src/main/java/com/water/dispatch/mapper/WorkOrderMapper.java
  16. 83
    0
      wm-dispatch/src/main/java/com/water/dispatch/service/WorkOrderLedgerService.java
  17. 260
    0
      wm-dispatch/src/main/java/com/water/dispatch/service/WorkOrderLifecycleService.java
  18. 84
    0
      wm-dispatch/src/main/java/com/water/dispatch/service/WorkOrderStatisticsService.java
  19. 347
    0
      wm-dispatch/src/test/java/com/water/dispatch/service/WorkOrderLifecycleServiceTest.java
  20. 102
    0
      wm-dispatch/src/test/java/com/water/dispatch/service/WorkOrderStatisticsServiceTest.java

+ 87
- 0
db/postgresql/V3__workorder.sql Wyświetl plik

@@ -0,0 +1,87 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 综合工单管理 DDL
3
+-- 版本: V3
4
+-- 描述: 工单创建/分派/处理/完成/统计
5
+-- =============================================
6
+
7
+-- ==================== 工单主表 ====================
8
+CREATE TABLE IF NOT EXISTS dispatch_work_order (
9
+    id BIGSERIAL PRIMARY KEY,
10
+    order_no VARCHAR(50) UNIQUE NOT NULL,
11
+    title VARCHAR(200) NOT NULL,
12
+    description TEXT,
13
+    type VARCHAR(30) NOT NULL DEFAULT 'REPAIR',       -- REPAIR/INSPECTION/COMPLAINT/EMERGENCY
14
+    priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM',    -- LOW/MEDIUM/HIGH/URGENT
15
+    status INT NOT NULL DEFAULT 0,                     -- 0:待办 1:已分派 2:处理中 4:已完成 5:已驳回 6:已取消
16
+    assignee_id BIGINT,
17
+    assignee_name VARCHAR(50),
18
+    creator_id BIGINT,
19
+    creator_name VARCHAR(50),
20
+    source VARCHAR(50),                                 -- 来源: manual/phone/app/alert
21
+    facility_id BIGINT,
22
+    location VARCHAR(500),
23
+    longitude DOUBLE PRECISION,
24
+    latitude DOUBLE PRECISION,
25
+    attachments TEXT,                                   -- JSON数组
26
+    deadline TIMESTAMP,
27
+    assigned_at TIMESTAMP,
28
+    started_at TIMESTAMP,
29
+    completed_at TIMESTAMP,
30
+    result TEXT,
31
+    reject_reason TEXT,
32
+    remark TEXT,
33
+    deleted SMALLINT DEFAULT 0,
34
+    created_at TIMESTAMP DEFAULT NOW(),
35
+    updated_at TIMESTAMP DEFAULT NOW()
36
+);
37
+COMMENT ON TABLE dispatch_work_order IS '工单主表';
38
+COMMENT ON COLUMN dispatch_work_order.type IS '工单类型: REPAIR(维修)/INSPECTION(巡检)/COMPLAINT(投诉)/EMERGENCY(应急)';
39
+COMMENT ON COLUMN dispatch_work_order.priority IS '优先级: LOW/MEDIUM/HIGH/URGENT';
40
+COMMENT ON COLUMN dispatch_work_order.status IS '状态: 0待办/1已分派/2处理中/4已完成/5已驳回/6已取消';
41
+
42
+CREATE INDEX IF NOT EXISTS idx_wo_order_no ON dispatch_work_order(order_no);
43
+CREATE INDEX IF NOT EXISTS idx_wo_status ON dispatch_work_order(status);
44
+CREATE INDEX IF NOT EXISTS idx_wo_type ON dispatch_work_order(type);
45
+CREATE INDEX IF NOT EXISTS idx_wo_assignee ON dispatch_work_order(assignee_id);
46
+CREATE INDEX IF NOT EXISTS idx_wo_creator ON dispatch_work_order(creator_id);
47
+CREATE INDEX IF NOT EXISTS idx_wo_created_at ON dispatch_work_order(created_at);
48
+
49
+-- ==================== 工单处理日志 ====================
50
+CREATE TABLE IF NOT EXISTS dispatch_work_order_log (
51
+    id BIGSERIAL PRIMARY KEY,
52
+    work_order_id BIGINT NOT NULL REFERENCES dispatch_work_order(id),
53
+    order_no VARCHAR(50),
54
+    stage VARCHAR(30),
55
+    from_status INT,
56
+    to_status INT,
57
+    operator_id BIGINT,
58
+    operator_name VARCHAR(50),
59
+    action_desc TEXT,
60
+    attachments TEXT,
61
+    created_at TIMESTAMP DEFAULT NOW()
62
+);
63
+COMMENT ON TABLE dispatch_work_order_log IS '工单处理日志';
64
+
65
+CREATE INDEX IF NOT EXISTS idx_wolog_wo_id ON dispatch_work_order_log(work_order_id);
66
+CREATE INDEX IF NOT EXISTS idx_wolog_order_no ON dispatch_work_order_log(order_no);
67
+
68
+-- ==================== 工单分派记录 ====================
69
+CREATE TABLE IF NOT EXISTS dispatch_work_order_assignment (
70
+    id BIGSERIAL PRIMARY KEY,
71
+    work_order_id BIGINT NOT NULL REFERENCES dispatch_work_order(id),
72
+    order_no VARCHAR(50),
73
+    assigner_id BIGINT,
74
+    assigner_name VARCHAR(50),
75
+    assignee_id BIGINT,
76
+    assignee_name VARCHAR(50),
77
+    role VARCHAR(20) NOT NULL DEFAULT 'PRIMARY',       -- PRIMARY/ASSISTANT
78
+    assignment_note TEXT,
79
+    assignment_type VARCHAR(20) DEFAULT 'MANUAL',      -- MANUAL/AUTO
80
+    status INT DEFAULT 1,                              -- 1:活跃 0:已移除
81
+    created_at TIMESTAMP DEFAULT NOW(),
82
+    updated_at TIMESTAMP DEFAULT NOW()
83
+);
84
+COMMENT ON TABLE dispatch_work_order_assignment IS '工单分派记录(支持多人协作)';
85
+
86
+CREATE INDEX IF NOT EXISTS idx_woassign_wo_id ON dispatch_work_order_assignment(work_order_id);
87
+CREATE INDEX IF NOT EXISTS idx_woassign_assignee ON dispatch_work_order_assignment(assignee_id);

+ 152
- 0
wm-dispatch/src/main/java/com/water/dispatch/controller/WorkOrderController.java Wyświetl plik

@@ -0,0 +1,152 @@
1
+package com.water.dispatch.controller;
2
+
3
+import com.baomidou.mybatisplus.core.metadata.IPage;
4
+import com.water.common.core.result.R;
5
+import com.water.dispatch.entity.WorkOrder;
6
+import com.water.dispatch.entity.WorkOrderAssignment;
7
+import com.water.dispatch.entity.WorkOrderLog;
8
+import com.water.dispatch.entity.dto.*;
9
+import com.water.dispatch.service.WorkOrderLedgerService;
10
+import com.water.dispatch.service.WorkOrderLifecycleService;
11
+import com.water.dispatch.service.WorkOrderStatisticsService;
12
+import io.swagger.v3.oas.annotations.Operation;
13
+import io.swagger.v3.oas.annotations.tags.Tag;
14
+import lombok.RequiredArgsConstructor;
15
+import org.springframework.web.bind.annotation.*;
16
+
17
+import java.util.List;
18
+
19
+/**
20
+ * 综合工单管理 - 创建/分派/处理/完成/统计
21
+ * 状态流转: PENDING(0) → ASSIGNED(1) → IN_PROGRESS(2) → COMPLETED(4) / REJECTED(5) / CANCELLED(6)
22
+ */
23
+@Tag(name = "综合工单管理")
24
+@RestController
25
+@RequestMapping("/api/dispatch/workorder")
26
+@RequiredArgsConstructor
27
+public class WorkOrderController {
28
+
29
+    private final WorkOrderLifecycleService lifecycleService;
30
+    private final WorkOrderLedgerService ledgerService;
31
+    private final WorkOrderStatisticsService statisticsService;
32
+
33
+    // ==================== 工单创建 ====================
34
+
35
+    @Operation(summary = "1. 创建工单")
36
+    @PostMapping
37
+    public R<WorkOrder> create(@RequestBody WorkOrderCreateRequest req) {
38
+        return R.ok(lifecycleService.create(req));
39
+    }
40
+
41
+    // ==================== 工单分派 ====================
42
+
43
+    @Operation(summary = "2. 分派工单(手动/自动 + 多人协作)")
44
+    @PostMapping("/{id}/assign")
45
+    public R<WorkOrder> assign(@PathVariable Long id, @RequestBody WorkOrderAssignRequest req) {
46
+        return R.ok(lifecycleService.assign(id, req));
47
+    }
48
+
49
+    // ==================== 工单处理 ====================
50
+
51
+    @Operation(summary = "3. 开始处理工单")
52
+    @PostMapping("/{id}/start")
53
+    public R<WorkOrder> startProcess(@PathVariable Long id, @RequestBody WorkOrderProcessRequest req) {
54
+        return R.ok(lifecycleService.startProcess(id, req));
55
+    }
56
+
57
+    @Operation(summary = "4. 完成工单")
58
+    @PostMapping("/{id}/complete")
59
+    public R<WorkOrder> complete(@PathVariable Long id, @RequestBody WorkOrderProcessRequest req) {
60
+        return R.ok(lifecycleService.complete(id, req));
61
+    }
62
+
63
+    @Operation(summary = "5. 驳回工单")
64
+    @PostMapping("/{id}/reject")
65
+    public R<WorkOrder> reject(@PathVariable Long id, @RequestBody WorkOrderProcessRequest req) {
66
+        return R.ok(lifecycleService.reject(id, req));
67
+    }
68
+
69
+    @Operation(summary = "6. 取消工单")
70
+    @PostMapping("/{id}/cancel")
71
+    public R<WorkOrder> cancel(@PathVariable Long id, @RequestBody WorkOrderProcessRequest req) {
72
+        return R.ok(lifecycleService.cancel(id, req));
73
+    }
74
+
75
+    // ==================== 查询 ====================
76
+
77
+    @Operation(summary = "7. 获取工单详情(按ID)")
78
+    @GetMapping("/{id}")
79
+    public R<WorkOrder> getById(@PathVariable Long id) {
80
+        return R.ok(lifecycleService.getById(id));
81
+    }
82
+
83
+    @Operation(summary = "8. 根据工单编号获取详情")
84
+    @GetMapping("/no/{orderNo}")
85
+    public R<WorkOrder> getByOrderNo(@PathVariable String orderNo) {
86
+        return R.ok(lifecycleService.getByOrderNo(orderNo));
87
+    }
88
+
89
+    @Operation(summary = "9. 工单分页查询")
90
+    @GetMapping("/page")
91
+    public R<IPage<WorkOrder>> queryPage(WorkOrderQueryRequest req) {
92
+        return R.ok(ledgerService.queryPage(req));
93
+    }
94
+
95
+    @Operation(summary = "10. 工单列表查询")
96
+    @GetMapping("/list")
97
+    public R<List<WorkOrder>> queryList(WorkOrderQueryRequest req) {
98
+        return R.ok(ledgerService.queryList(req));
99
+    }
100
+
101
+    // ==================== 处理记录 & 分派记录 ====================
102
+
103
+    @Operation(summary = "11. 获取工单处理日志")
104
+    @GetMapping("/{id}/logs")
105
+    public R<List<WorkOrderLog>> getLogs(@PathVariable Long id) {
106
+        return R.ok(lifecycleService.getLogs(id));
107
+    }
108
+
109
+    @Operation(summary = "12. 获取工单分派记录")
110
+    @GetMapping("/{id}/assignments")
111
+    public R<List<WorkOrderAssignment>> getAssignments(@PathVariable Long id) {
112
+        return R.ok(lifecycleService.getAssignments(id));
113
+    }
114
+
115
+    // ==================== 统计 ====================
116
+
117
+    @Operation(summary = "13. 工单统计概览(完成率/平均时长/类型分布/人员分布)")
118
+    @GetMapping("/statistics")
119
+    public R<WorkOrderStatVO> statistics() {
120
+        return R.ok(statisticsService.statistics());
121
+    }
122
+
123
+    // ==================== 导出 ====================
124
+
125
+    @Operation(summary = "14. 工单数据导出")
126
+    @GetMapping("/export")
127
+    public R<List<WorkOrder>> exportData(WorkOrderQueryRequest req) {
128
+        return R.ok(ledgerService.exportData(req));
129
+    }
130
+
131
+    // ==================== 批量操作 ====================
132
+
133
+    @Operation(summary = "15. 批量分派工单")
134
+    @PostMapping("/batch-assign")
135
+    public R<List<WorkOrder>> batchAssign(@RequestBody WorkOrderBatchAssignRequest req) {
136
+        List<WorkOrder> results = new java.util.ArrayList<>();
137
+        for (Long id : req.getWorkOrderIds()) {
138
+            results.add(lifecycleService.assign(id, req));
139
+        }
140
+        return R.ok(results);
141
+    }
142
+
143
+    @Operation(summary = "16. 批量取消工单")
144
+    @PostMapping("/batch-cancel")
145
+    public R<List<WorkOrder>> batchCancel(@RequestBody WorkOrderBatchCancelRequest req) {
146
+        List<WorkOrder> results = new java.util.ArrayList<>();
147
+        for (Long id : req.getWorkOrderIds()) {
148
+            results.add(lifecycleService.cancel(id, req));
149
+        }
150
+        return R.ok(results);
151
+    }
152
+}

+ 94
- 8
wm-dispatch/src/main/java/com/water/dispatch/entity/WorkOrder.java Wyświetl plik

@@ -1,12 +1,98 @@
1 1
 package com.water.dispatch.entity;
2
+
2 3
 import com.baomidou.mybatisplus.annotation.*;
3
-import lombok.Data; import java.time.LocalDateTime;
4
-@Data @TableName("dispatch_work_order")
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 工单实体 - 综合工单管理
10
+ * 类型: REPAIR(维修)/INSPECTION(巡检)/COMPLAINT(投诉)/EMERGENCY(应急)
11
+ * 优先级: LOW/MEDIUM/HIGH/URGENT
12
+ * 状态: PENDING(待办)/ASSIGNED(已分派)/IN_PROGRESS(处理中)/COMPLETED(已完成)/REJECTED(已驳回)/CANCELLED(已取消)
13
+ */
14
+@Data
15
+@TableName("dispatch_work_order")
5 16
 public class WorkOrder {
6
-    @TableId(type = IdType.AUTO) private Long id;
7
-    private String orderNo, title, description, type, priority;
8
-    private Integer status; private Long assigneeId, creatorId;
9
-    private LocalDateTime deadline, completedAt;
10
-    @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
11
-    @TableField(fill=FieldFill.INSERT_UPDATE) private LocalDateTime updatedTime;
17
+
18
+    @TableId(type = IdType.AUTO)
19
+    private Long id;
20
+
21
+    /** 工单编号 */
22
+    private String orderNo;
23
+
24
+    /** 工单标题 */
25
+    private String title;
26
+
27
+    /** 工单描述 */
28
+    private String description;
29
+
30
+    /** 工单类型: REPAIR/INSPECTION/COMPLAINT/EMERGENCY */
31
+    private String type;
32
+
33
+    /** 优先级: LOW/MEDIUM/HIGH/URGENT */
34
+    private String priority;
35
+
36
+    /** 状态: PENDING/ASSIGNED/IN_PROGRESS/COMPLETED/REJECTED/CANCELLED */
37
+    private Integer status;
38
+
39
+    /** 主负责人ID */
40
+    private Long assigneeId;
41
+
42
+    /** 主负责人姓名 */
43
+    private String assigneeName;
44
+
45
+    /** 创建人ID */
46
+    private Long creatorId;
47
+
48
+    /** 创建人姓名 */
49
+    private String creatorName;
50
+
51
+    /** 来源 */
52
+    private String source;
53
+
54
+    /** 关联设施ID */
55
+    private Long facilityId;
56
+
57
+    /** 地点描述 */
58
+    private String location;
59
+
60
+    /** 经度 */
61
+    private Double longitude;
62
+
63
+    /** 纬度 */
64
+    private Double latitude;
65
+
66
+    /** 附件URL列表(JSON) */
67
+    private String attachments;
68
+
69
+    /** 截止时间 */
70
+    private LocalDateTime deadline;
71
+
72
+    /** 分派时间 */
73
+    private LocalDateTime assignedAt;
74
+
75
+    /** 开始处理时间 */
76
+    private LocalDateTime startedAt;
77
+
78
+    /** 完成时间 */
79
+    private LocalDateTime completedAt;
80
+
81
+    /** 处理结果 */
82
+    private String result;
83
+
84
+    /** 驳回原因 */
85
+    private String rejectReason;
86
+
87
+    /** 备注 */
88
+    private String remark;
89
+
90
+    @TableLogic
91
+    private Integer deleted;
92
+
93
+    @TableField(fill = FieldFill.INSERT)
94
+    private LocalDateTime createdAt;
95
+
96
+    @TableField(fill = FieldFill.INSERT_UPDATE)
97
+    private LocalDateTime updatedAt;
12 98
 }

+ 53
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/WorkOrderAssignment.java Wyświetl plik

@@ -0,0 +1,53 @@
1
+package com.water.dispatch.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 工单分派记录 - 支持多人协作
10
+ */
11
+@Data
12
+@TableName("dispatch_work_order_assignment")
13
+public class WorkOrderAssignment {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 工单ID */
19
+    private Long workOrderId;
20
+
21
+    /** 工单编号 */
22
+    private String orderNo;
23
+
24
+    /** 分派人ID */
25
+    private Long assignerId;
26
+
27
+    /** 分派人姓名 */
28
+    private String assignerName;
29
+
30
+    /** 被分派人ID */
31
+    private Long assigneeId;
32
+
33
+    /** 被分派人姓名 */
34
+    private String assigneeName;
35
+
36
+    /** 角色: PRIMARY(主负责人)/ASSISTANT(协助人) */
37
+    private String role;
38
+
39
+    /** 分派说明 */
40
+    private String assignmentNote;
41
+
42
+    /** 分派方式: MANUAL(手动)/AUTO(自动) */
43
+    private String assignmentType;
44
+
45
+    /** 状态: ACTIVE(活跃)/REMOVED(已移除) */
46
+    private Integer status;
47
+
48
+    @TableField(fill = FieldFill.INSERT)
49
+    private LocalDateTime createdAt;
50
+
51
+    @TableField(fill = FieldFill.INSERT_UPDATE)
52
+    private LocalDateTime updatedAt;
53
+}

+ 47
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/WorkOrderLog.java Wyświetl plik

@@ -0,0 +1,47 @@
1
+package com.water.dispatch.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 工单处理日志 - 记录工单状态流转和处理过程
10
+ */
11
+@Data
12
+@TableName("dispatch_work_order_log")
13
+public class WorkOrderLog {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 工单ID */
19
+    private Long workOrderId;
20
+
21
+    /** 工单编号 */
22
+    private String orderNo;
23
+
24
+    /** 操作阶段 */
25
+    private String stage;
26
+
27
+    /** 操作前状态 */
28
+    private Integer fromStatus;
29
+
30
+    /** 操作后状态 */
31
+    private Integer toStatus;
32
+
33
+    /** 操作人ID */
34
+    private Long operatorId;
35
+
36
+    /** 操作人姓名 */
37
+    private String operatorName;
38
+
39
+    /** 操作描述 */
40
+    private String actionDesc;
41
+
42
+    /** 附件 */
43
+    private String attachments;
44
+
45
+    @TableField(fill = FieldFill.INSERT)
46
+    private LocalDateTime createdAt;
47
+}

+ 40
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderAssignRequest.java Wyświetl plik

@@ -0,0 +1,40 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+
7
+/**
8
+ * 工单分派请求
9
+ */
10
+@Data
11
+public class WorkOrderAssignRequest {
12
+
13
+    /** 操作人ID */
14
+    private Long assignerId;
15
+
16
+    /** 操作人姓名 */
17
+    private String assignerName;
18
+
19
+    /** 主负责人ID */
20
+    private Long primaryAssigneeId;
21
+
22
+    /** 主负责人姓名 */
23
+    private String primaryAssigneeName;
24
+
25
+    /** 分派说明 */
26
+    private String assignmentNote;
27
+
28
+    /** 分派方式: MANUAL/AUTO */
29
+    private String assignmentType;
30
+
31
+    /** 协助人列表 */
32
+    private List<AssistantInfo> assistants;
33
+
34
+    @Data
35
+    public static class AssistantInfo {
36
+        private Long userId;
37
+        private String userName;
38
+        private String note;
39
+    }
40
+}

+ 17
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderBatchAssignRequest.java Wyświetl plik

@@ -0,0 +1,17 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+import lombok.EqualsAndHashCode;
5
+
6
+import java.util.List;
7
+
8
+/**
9
+ * 批量分派工单请求
10
+ */
11
+@Data
12
+@EqualsAndHashCode(callSuper = true)
13
+public class WorkOrderBatchAssignRequest extends WorkOrderAssignRequest {
14
+
15
+    /** 工单ID列表 */
16
+    private List<Long> workOrderIds;
17
+}

+ 17
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderBatchCancelRequest.java Wyświetl plik

@@ -0,0 +1,17 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+import lombok.EqualsAndHashCode;
5
+
6
+import java.util.List;
7
+
8
+/**
9
+ * 批量取消工单请求
10
+ */
11
+@Data
12
+@EqualsAndHashCode(callSuper = true)
13
+public class WorkOrderBatchCancelRequest extends WorkOrderProcessRequest {
14
+
15
+    /** 工单ID列表 */
16
+    private List<Long> workOrderIds;
17
+}

+ 54
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderCreateRequest.java Wyświetl plik

@@ -0,0 +1,54 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 工单创建请求
9
+ */
10
+@Data
11
+public class WorkOrderCreateRequest {
12
+
13
+    /** 工单标题 */
14
+    private String title;
15
+
16
+    /** 工单描述 */
17
+    private String description;
18
+
19
+    /** 工单类型: REPAIR/INSPECTION/COMPLAINT/EMERGENCY */
20
+    private String type;
21
+
22
+    /** 优先级: LOW/MEDIUM/HIGH/URGENT */
23
+    private String priority;
24
+
25
+    /** 创建人ID */
26
+    private Long creatorId;
27
+
28
+    /** 创建人姓名 */
29
+    private String creatorName;
30
+
31
+    /** 来源 */
32
+    private String source;
33
+
34
+    /** 关联设施ID */
35
+    private Long facilityId;
36
+
37
+    /** 地点描述 */
38
+    private String location;
39
+
40
+    /** 经度 */
41
+    private Double longitude;
42
+
43
+    /** 纬度 */
44
+    private Double latitude;
45
+
46
+    /** 附件URL列表(JSON) */
47
+    private String attachments;
48
+
49
+    /** 截止时间 */
50
+    private LocalDateTime deadline;
51
+
52
+    /** 备注 */
53
+    private String remark;
54
+}

+ 28
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderProcessRequest.java Wyświetl plik

@@ -0,0 +1,28 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 工单处理请求(开始处理/完成/驳回)
7
+ */
8
+@Data
9
+public class WorkOrderProcessRequest {
10
+
11
+    /** 操作人ID */
12
+    private Long operatorId;
13
+
14
+    /** 操作人姓名 */
15
+    private String operatorName;
16
+
17
+    /** 处理结果(完成时) */
18
+    private String result;
19
+
20
+    /** 驳回原因(驳回时) */
21
+    private String rejectReason;
22
+
23
+    /** 附件 */
24
+    private String attachments;
25
+
26
+    /** 备注 */
27
+    private String remark;
28
+}

+ 48
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderQueryRequest.java Wyświetl plik

@@ -0,0 +1,48 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 工单查询请求
9
+ */
10
+@Data
11
+public class WorkOrderQueryRequest {
12
+
13
+    /** 工单编号 */
14
+    private String orderNo;
15
+
16
+    /** 工单标题(模糊) */
17
+    private String title;
18
+
19
+    /** 工单类型 */
20
+    private String type;
21
+
22
+    /** 优先级 */
23
+    private String priority;
24
+
25
+    /** 状态 */
26
+    private Integer status;
27
+
28
+    /** 负责人ID */
29
+    private Long assigneeId;
30
+
31
+    /** 创建人ID */
32
+    private Long creatorId;
33
+
34
+    /** 来源 */
35
+    private String source;
36
+
37
+    /** 查询开始时间 */
38
+    private LocalDateTime startTime;
39
+
40
+    /** 查询结束时间 */
41
+    private LocalDateTime endTime;
42
+
43
+    /** 页码 */
44
+    private Integer pageNum = 1;
45
+
46
+    /** 每页大小 */
47
+    private Integer pageSize = 10;
48
+}

+ 42
- 0
wm-dispatch/src/main/java/com/water/dispatch/entity/dto/WorkOrderStatVO.java Wyświetl plik

@@ -0,0 +1,42 @@
1
+package com.water.dispatch.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.Map;
6
+
7
+/**
8
+ * 工单统计VO
9
+ */
10
+@Data
11
+public class WorkOrderStatVO {
12
+
13
+    /** 总数 */
14
+    private Long total;
15
+
16
+    /** 按状态分布 */
17
+    private Map<Integer, Long> byStatus;
18
+
19
+    /** 按类型分布 */
20
+    private Map<String, Long> byType;
21
+
22
+    /** 按优先级分布 */
23
+    private Map<String, Long> byPriority;
24
+
25
+    /** 完成率(%) */
26
+    private Double completionRate;
27
+
28
+    /** 平均处理时长(小时) */
29
+    private Double avgProcessHours;
30
+
31
+    /** 逾期工单数 */
32
+    private Long overdueCount;
33
+
34
+    /** 今日新建 */
35
+    private Long todayCreated;
36
+
37
+    /** 今日完成 */
38
+    private Long todayCompleted;
39
+
40
+    /** 按人员工单分布: assigneeName → count */
41
+    private Map<String, Long> byAssignee;
42
+}

+ 9
- 0
wm-dispatch/src/main/java/com/water/dispatch/mapper/WorkOrderAssignmentMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.dispatch.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.dispatch.entity.WorkOrderAssignment;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface WorkOrderAssignmentMapper extends BaseMapper<WorkOrderAssignment> {
9
+}

+ 9
- 0
wm-dispatch/src/main/java/com/water/dispatch/mapper/WorkOrderLogMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.dispatch.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.dispatch.entity.WorkOrderLog;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface WorkOrderLogMapper extends BaseMapper<WorkOrderLog> {
9
+}

+ 27
- 1
wm-dispatch/src/main/java/com/water/dispatch/mapper/WorkOrderMapper.java Wyświetl plik

@@ -1,5 +1,31 @@
1 1
 package com.water.dispatch.mapper;
2
+
2 3
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
3 4
 import com.water.dispatch.entity.WorkOrder;
4 5
 import org.apache.ibatis.annotations.Mapper;
5
-@Mapper public interface WorkOrderMapper extends BaseMapper<WorkOrder> {}
6
+import org.apache.ibatis.annotations.Select;
7
+
8
+import java.util.List;
9
+import java.util.Map;
10
+
11
+@Mapper
12
+public interface WorkOrderMapper extends BaseMapper<WorkOrder> {
13
+
14
+    @Select("SELECT status, COUNT(*) as cnt FROM dispatch_work_order WHERE deleted=0 GROUP BY status")
15
+    List<Map<String, Object>> countByStatus();
16
+
17
+    @Select("SELECT type, COUNT(*) as cnt FROM dispatch_work_order WHERE deleted=0 GROUP BY type")
18
+    List<Map<String, Object>> countByType();
19
+
20
+    @Select("SELECT priority, COUNT(*) as cnt FROM dispatch_work_order WHERE deleted=0 GROUP BY priority")
21
+    List<Map<String, Object>> countByPriority();
22
+
23
+    @Select("SELECT COALESCE(assignee_name, '未分派') as assignee, COUNT(*) as cnt FROM dispatch_work_order WHERE deleted=0 GROUP BY assignee_name")
24
+    List<Map<String, Object>> countByAssignee();
25
+
26
+    @Select("SELECT COUNT(*) FROM dispatch_work_order WHERE deleted=0 AND status NOT IN (4,5,6) AND deadline < NOW()")
27
+    long countOverdue();
28
+
29
+    @Select("SELECT AVG(EXTRACT(EPOCH FROM (completed_at - started_at))/3600.0) FROM dispatch_work_order WHERE deleted=0 AND status=4 AND started_at IS NOT NULL AND completed_at IS NOT NULL")
30
+    Double avgProcessHours();
31
+}

+ 83
- 0
wm-dispatch/src/main/java/com/water/dispatch/service/WorkOrderLedgerService.java Wyświetl plik

@@ -0,0 +1,83 @@
1
+package com.water.dispatch.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.dispatch.entity.WorkOrder;
7
+import com.water.dispatch.entity.dto.WorkOrderQueryRequest;
8
+import com.water.dispatch.mapper.WorkOrderMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.util.List;
13
+
14
+/**
15
+ * 工单台账服务 - 多维度查询
16
+ */
17
+@Service
18
+@RequiredArgsConstructor
19
+public class WorkOrderLedgerService {
20
+
21
+    private final WorkOrderMapper workOrderMapper;
22
+
23
+    /**
24
+     * 分页查询
25
+     */
26
+    public IPage<WorkOrder> queryPage(WorkOrderQueryRequest req) {
27
+        Page<WorkOrder> page = new Page<>(req.getPageNum(), req.getPageSize());
28
+        LambdaQueryWrapper<WorkOrder> wrapper = buildWrapper(req);
29
+        wrapper.orderByDesc(WorkOrder::getCreatedAt);
30
+        return workOrderMapper.selectPage(page, wrapper);
31
+    }
32
+
33
+    /**
34
+     * 列表查询(不分页)
35
+     */
36
+    public List<WorkOrder> queryList(WorkOrderQueryRequest req) {
37
+        LambdaQueryWrapper<WorkOrder> wrapper = buildWrapper(req);
38
+        wrapper.orderByDesc(WorkOrder::getCreatedAt);
39
+        return workOrderMapper.selectList(wrapper);
40
+    }
41
+
42
+    /**
43
+     * 导出数据
44
+     */
45
+    public List<WorkOrder> exportData(WorkOrderQueryRequest req) {
46
+        return queryList(req);
47
+    }
48
+
49
+    private LambdaQueryWrapper<WorkOrder> buildWrapper(WorkOrderQueryRequest req) {
50
+        LambdaQueryWrapper<WorkOrder> wrapper = new LambdaQueryWrapper<>();
51
+        if (req.getOrderNo() != null && !req.getOrderNo().isEmpty()) {
52
+            wrapper.like(WorkOrder::getOrderNo, req.getOrderNo());
53
+        }
54
+        if (req.getTitle() != null && !req.getTitle().isEmpty()) {
55
+            wrapper.like(WorkOrder::getTitle, req.getTitle());
56
+        }
57
+        if (req.getType() != null && !req.getType().isEmpty()) {
58
+            wrapper.eq(WorkOrder::getType, req.getType());
59
+        }
60
+        if (req.getPriority() != null && !req.getPriority().isEmpty()) {
61
+            wrapper.eq(WorkOrder::getPriority, req.getPriority());
62
+        }
63
+        if (req.getStatus() != null) {
64
+            wrapper.eq(WorkOrder::getStatus, req.getStatus());
65
+        }
66
+        if (req.getAssigneeId() != null) {
67
+            wrapper.eq(WorkOrder::getAssigneeId, req.getAssigneeId());
68
+        }
69
+        if (req.getCreatorId() != null) {
70
+            wrapper.eq(WorkOrder::getCreatorId, req.getCreatorId());
71
+        }
72
+        if (req.getSource() != null && !req.getSource().isEmpty()) {
73
+            wrapper.eq(WorkOrder::getSource, req.getSource());
74
+        }
75
+        if (req.getStartTime() != null) {
76
+            wrapper.ge(WorkOrder::getCreatedAt, req.getStartTime());
77
+        }
78
+        if (req.getEndTime() != null) {
79
+            wrapper.le(WorkOrder::getCreatedAt, req.getEndTime());
80
+        }
81
+        return wrapper;
82
+    }
83
+}

+ 260
- 0
wm-dispatch/src/main/java/com/water/dispatch/service/WorkOrderLifecycleService.java Wyświetl plik

@@ -0,0 +1,260 @@
1
+package com.water.dispatch.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.dispatch.entity.WorkOrder;
6
+import com.water.dispatch.entity.WorkOrderAssignment;
7
+import com.water.dispatch.entity.WorkOrderLog;
8
+import com.water.dispatch.entity.dto.WorkOrderAssignRequest;
9
+import com.water.dispatch.entity.dto.WorkOrderCreateRequest;
10
+import com.water.dispatch.entity.dto.WorkOrderProcessRequest;
11
+import com.water.dispatch.mapper.WorkOrderAssignmentMapper;
12
+import com.water.dispatch.mapper.WorkOrderLogMapper;
13
+import com.water.dispatch.mapper.WorkOrderMapper;
14
+import lombok.RequiredArgsConstructor;
15
+import org.springframework.stereotype.Service;
16
+import org.springframework.transaction.annotation.Transactional;
17
+
18
+import java.time.LocalDateTime;
19
+import java.time.format.DateTimeFormatter;
20
+import java.util.List;
21
+
22
+/**
23
+ * 工单全生命周期服务
24
+ * 状态流转: PENDING(0) → ASSIGNED(1) → IN_PROGRESS(2) → COMPLETED(4) / REJECTED(5) / CANCELLED(6)
25
+ */
26
+@Service
27
+@RequiredArgsConstructor
28
+public class WorkOrderLifecycleService {
29
+
30
+    /** 状态常量 */
31
+    public static final int STATUS_PENDING = 0;
32
+    public static final int STATUS_ASSIGNED = 1;
33
+    public static final int STATUS_IN_PROGRESS = 2;
34
+    public static final int STATUS_COMPLETED = 4;
35
+    public static final int STATUS_REJECTED = 5;
36
+    public static final int STATUS_CANCELLED = 6;
37
+
38
+    private final WorkOrderMapper workOrderMapper;
39
+    private final WorkOrderLogMapper logMapper;
40
+    private final WorkOrderAssignmentMapper assignmentMapper;
41
+
42
+    // ==================== 创建 ====================
43
+
44
+    @Transactional
45
+    public WorkOrder create(WorkOrderCreateRequest req) {
46
+        WorkOrder wo = new WorkOrder();
47
+        wo.setOrderNo(generateOrderNo());
48
+        wo.setTitle(req.getTitle());
49
+        wo.setDescription(req.getDescription());
50
+        wo.setType(req.getType() != null ? req.getType() : "REPAIR");
51
+        wo.setPriority(req.getPriority() != null ? req.getPriority() : "MEDIUM");
52
+        wo.setStatus(STATUS_PENDING);
53
+        wo.setCreatorId(req.getCreatorId());
54
+        wo.setCreatorName(req.getCreatorName());
55
+        wo.setSource(req.getSource());
56
+        wo.setFacilityId(req.getFacilityId());
57
+        wo.setLocation(req.getLocation());
58
+        wo.setLongitude(req.getLongitude());
59
+        wo.setLatitude(req.getLatitude());
60
+        wo.setAttachments(req.getAttachments());
61
+        wo.setDeadline(req.getDeadline());
62
+        wo.setRemark(req.getRemark());
63
+        workOrderMapper.insert(wo);
64
+
65
+        addLog(wo, "CREATED", null, STATUS_PENDING, req.getCreatorId(), req.getCreatorName(), "创建工单");
66
+        return wo;
67
+    }
68
+
69
+    // ==================== 分派 ====================
70
+
71
+    @Transactional
72
+    public WorkOrder assign(Long workOrderId, WorkOrderAssignRequest req) {
73
+        WorkOrder wo = getOrderOrThrow(workOrderId);
74
+        if (wo.getStatus() != STATUS_PENDING && wo.getStatus() != STATUS_ASSIGNED) {
75
+            throw new BusinessException("当前状态不允许分派: " + wo.getStatus());
76
+        }
77
+
78
+        // 设置主负责人
79
+        wo.setAssigneeId(req.getPrimaryAssigneeId());
80
+        wo.setAssigneeName(req.getPrimaryAssigneeName());
81
+        wo.setStatus(STATUS_ASSIGNED);
82
+        wo.setAssignedAt(LocalDateTime.now());
83
+        workOrderMapper.updateById(wo);
84
+
85
+        // 记录主负责人分派
86
+        addAssignment(wo, req.getAssignerId(), req.getAssignerName(),
87
+                req.getPrimaryAssigneeId(), req.getPrimaryAssigneeName(),
88
+                "PRIMARY", req.getAssignmentNote(),
89
+                req.getAssignmentType() != null ? req.getAssignmentType() : "MANUAL");
90
+
91
+        // 记录协助人分派
92
+        if (req.getAssistants() != null) {
93
+            for (WorkOrderAssignRequest.AssistantInfo assistant : req.getAssistants()) {
94
+                addAssignment(wo, req.getAssignerId(), req.getAssignerName(),
95
+                        assistant.getUserId(), assistant.getUserName(),
96
+                        "ASSISTANT", assistant.getNote(),
97
+                        req.getAssignmentType() != null ? req.getAssignmentType() : "MANUAL");
98
+            }
99
+        }
100
+
101
+        addLog(wo, "ASSIGNED", STATUS_PENDING, STATUS_ASSIGNED,
102
+                req.getAssignerId(), req.getAssignerName(),
103
+                "分派给: " + req.getPrimaryAssigneeName());
104
+        return wo;
105
+    }
106
+
107
+    // ==================== 开始处理 ====================
108
+
109
+    @Transactional
110
+    public WorkOrder startProcess(Long workOrderId, WorkOrderProcessRequest req) {
111
+        WorkOrder wo = getOrderOrThrow(workOrderId);
112
+        if (wo.getStatus() != STATUS_ASSIGNED) {
113
+            throw new BusinessException("当前状态不允许开始处理: " + wo.getStatus());
114
+        }
115
+
116
+        wo.setStatus(STATUS_IN_PROGRESS);
117
+        wo.setStartedAt(LocalDateTime.now());
118
+        workOrderMapper.updateById(wo);
119
+
120
+        addLog(wo, "IN_PROGRESS", STATUS_ASSIGNED, STATUS_IN_PROGRESS,
121
+                req.getOperatorId(), req.getOperatorName(), "开始处理工单");
122
+        return wo;
123
+    }
124
+
125
+    // ==================== 完成 ====================
126
+
127
+    @Transactional
128
+    public WorkOrder complete(Long workOrderId, WorkOrderProcessRequest req) {
129
+        WorkOrder wo = getOrderOrThrow(workOrderId);
130
+        if (wo.getStatus() != STATUS_IN_PROGRESS) {
131
+            throw new BusinessException("当前状态不允许完成: " + wo.getStatus());
132
+        }
133
+
134
+        wo.setStatus(STATUS_COMPLETED);
135
+        wo.setCompletedAt(LocalDateTime.now());
136
+        wo.setResult(req.getResult());
137
+        workOrderMapper.updateById(wo);
138
+
139
+        addLog(wo, "COMPLETED", STATUS_IN_PROGRESS, STATUS_COMPLETED,
140
+                req.getOperatorId(), req.getOperatorName(),
141
+                "完成工单: " + (req.getResult() != null ? req.getResult() : ""));
142
+        return wo;
143
+    }
144
+
145
+    // ==================== 驳回 ====================
146
+
147
+    @Transactional
148
+    public WorkOrder reject(Long workOrderId, WorkOrderProcessRequest req) {
149
+        WorkOrder wo = getOrderOrThrow(workOrderId);
150
+        if (wo.getStatus() == STATUS_COMPLETED || wo.getStatus() == STATUS_CANCELLED || wo.getStatus() == STATUS_REJECTED) {
151
+            throw new BusinessException("当前状态不允许驳回: " + wo.getStatus());
152
+        }
153
+
154
+        int fromStatus = wo.getStatus();
155
+        wo.setStatus(STATUS_REJECTED);
156
+        wo.setRejectReason(req.getRejectReason());
157
+        workOrderMapper.updateById(wo);
158
+
159
+        addLog(wo, "REJECTED", fromStatus, STATUS_REJECTED,
160
+                req.getOperatorId(), req.getOperatorName(),
161
+                "驳回: " + (req.getRejectReason() != null ? req.getRejectReason() : ""));
162
+        return wo;
163
+    }
164
+
165
+    // ==================== 取消 ====================
166
+
167
+    @Transactional
168
+    public WorkOrder cancel(Long workOrderId, WorkOrderProcessRequest req) {
169
+        WorkOrder wo = getOrderOrThrow(workOrderId);
170
+        if (wo.getStatus() == STATUS_COMPLETED || wo.getStatus() == STATUS_CANCELLED) {
171
+            throw new BusinessException("当前状态不允许取消: " + wo.getStatus());
172
+        }
173
+
174
+        int fromStatus = wo.getStatus();
175
+        wo.setStatus(STATUS_CANCELLED);
176
+        workOrderMapper.updateById(wo);
177
+
178
+        addLog(wo, "CANCELLED", fromStatus, STATUS_CANCELLED,
179
+                req.getOperatorId(), req.getOperatorName(),
180
+                "取消工单" + (req.getRemark() != null ? ": " + req.getRemark() : ""));
181
+        return wo;
182
+    }
183
+
184
+    // ==================== 查询 ====================
185
+
186
+    public WorkOrder getById(Long id) {
187
+        return getOrderOrThrow(id);
188
+    }
189
+
190
+    public WorkOrder getByOrderNo(String orderNo) {
191
+        WorkOrder wo = workOrderMapper.selectOne(
192
+                new LambdaQueryWrapper<WorkOrder>()
193
+                        .eq(WorkOrder::getOrderNo, orderNo));
194
+        if (wo == null) {
195
+            throw new BusinessException("工单不存在: " + orderNo);
196
+        }
197
+        return wo;
198
+    }
199
+
200
+    public List<WorkOrderLog> getLogs(Long workOrderId) {
201
+        return logMapper.selectList(
202
+                new LambdaQueryWrapper<WorkOrderLog>()
203
+                        .eq(WorkOrderLog::getWorkOrderId, workOrderId)
204
+                        .orderByAsc(WorkOrderLog::getCreatedAt));
205
+    }
206
+
207
+    public List<WorkOrderAssignment> getAssignments(Long workOrderId) {
208
+        return assignmentMapper.selectList(
209
+                new LambdaQueryWrapper<WorkOrderAssignment>()
210
+                        .eq(WorkOrderAssignment::getWorkOrderId, workOrderId)
211
+                        .eq(WorkOrderAssignment::getStatus, 1)
212
+                        .orderByAsc(WorkOrderAssignment::getCreatedAt));
213
+    }
214
+
215
+    // ==================== Internal ====================
216
+
217
+    private WorkOrder getOrderOrThrow(Long id) {
218
+        WorkOrder wo = workOrderMapper.selectById(id);
219
+        if (wo == null) {
220
+            throw new BusinessException("工单不存在: " + id);
221
+        }
222
+        return wo;
223
+    }
224
+
225
+    private String generateOrderNo() {
226
+        return "WO-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
227
+                + "-" + (int) (Math.random() * 9000 + 1000);
228
+    }
229
+
230
+    private void addLog(WorkOrder wo, String stage, Integer fromStatus, Integer toStatus,
231
+                        Long operatorId, String operatorName, String desc) {
232
+        WorkOrderLog log = new WorkOrderLog();
233
+        log.setWorkOrderId(wo.getId());
234
+        log.setOrderNo(wo.getOrderNo());
235
+        log.setStage(stage);
236
+        log.setFromStatus(fromStatus);
237
+        log.setToStatus(toStatus);
238
+        log.setOperatorId(operatorId);
239
+        log.setOperatorName(operatorName);
240
+        log.setActionDesc(desc);
241
+        logMapper.insert(log);
242
+    }
243
+
244
+    private void addAssignment(WorkOrder wo, Long assignerId, String assignerName,
245
+                               Long assigneeId, String assigneeName,
246
+                               String role, String note, String assignmentType) {
247
+        WorkOrderAssignment a = new WorkOrderAssignment();
248
+        a.setWorkOrderId(wo.getId());
249
+        a.setOrderNo(wo.getOrderNo());
250
+        a.setAssignerId(assignerId);
251
+        a.setAssignerName(assignerName);
252
+        a.setAssigneeId(assigneeId);
253
+        a.setAssigneeName(assigneeName);
254
+        a.setRole(role);
255
+        a.setAssignmentNote(note);
256
+        a.setAssignmentType(assignmentType);
257
+        a.setStatus(1); // ACTIVE
258
+        assignmentMapper.insert(a);
259
+    }
260
+}

+ 84
- 0
wm-dispatch/src/main/java/com/water/dispatch/service/WorkOrderStatisticsService.java Wyświetl plik

@@ -0,0 +1,84 @@
1
+package com.water.dispatch.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.dispatch.entity.WorkOrder;
5
+import com.water.dispatch.entity.dto.WorkOrderStatVO;
6
+import com.water.dispatch.mapper.WorkOrderMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import org.springframework.stereotype.Service;
9
+
10
+import java.time.LocalDate;
11
+import java.time.LocalDateTime;
12
+import java.time.LocalTime;
13
+import java.util.HashMap;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 工单统计服务 - 完成率/平均处理时长/按类型分布/按人员分布
18
+ */
19
+@Service
20
+@RequiredArgsConstructor
21
+public class WorkOrderStatisticsService {
22
+
23
+    private final WorkOrderMapper workOrderMapper;
24
+
25
+    /**
26
+     * 统计概览
27
+     */
28
+    public WorkOrderStatVO statistics() {
29
+        WorkOrderStatVO vo = new WorkOrderStatVO();
30
+
31
+        // 总数
32
+        vo.setTotal(workOrderMapper.selectCount(new LambdaQueryWrapper<>()));
33
+
34
+        // 按状态分布
35
+        Map<Integer, Long> byStatus = new HashMap<>();
36
+        workOrderMapper.countByStatus().forEach(m ->
37
+                byStatus.put(((Number) m.get("status")).intValue(), ((Number) m.get("cnt")).longValue()));
38
+        vo.setByStatus(byStatus);
39
+
40
+        // 按类型分布
41
+        Map<String, Long> byType = new HashMap<>();
42
+        workOrderMapper.countByType().forEach(m ->
43
+                byType.put(String.valueOf(m.get("type")), ((Number) m.get("cnt")).longValue()));
44
+        vo.setByType(byType);
45
+
46
+        // 按优先级分布
47
+        Map<String, Long> byPriority = new HashMap<>();
48
+        workOrderMapper.countByPriority().forEach(m ->
49
+                byPriority.put(String.valueOf(m.get("priority")), ((Number) m.get("cnt")).longValue()));
50
+        vo.setByPriority(byPriority);
51
+
52
+        // 按人员分布
53
+        Map<String, Long> byAssignee = new HashMap<>();
54
+        workOrderMapper.countByAssignee().forEach(m ->
55
+                byAssignee.put(String.valueOf(m.get("assignee")), ((Number) m.get("cnt")).longValue()));
56
+        vo.setByAssignee(byAssignee);
57
+
58
+        // 完成率
59
+        long completed = byStatus.getOrDefault(WorkOrderLifecycleService.STATUS_COMPLETED, 0L);
60
+        vo.setCompletionRate(vo.getTotal() > 0 ? Math.round(completed * 10000.0 / vo.getTotal()) / 100.0 : 0.0);
61
+
62
+        // 平均处理时长
63
+        Double avgHours = workOrderMapper.avgProcessHours();
64
+        vo.setAvgProcessHours(avgHours != null ? Math.round(avgHours * 100.0) / 100.0 : 0.0);
65
+
66
+        // 逾期数
67
+        vo.setOverdueCount(workOrderMapper.countOverdue());
68
+
69
+        // 今日统计
70
+        LocalDateTime todayStart = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
71
+        LocalDateTime todayEnd = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
72
+        vo.setTodayCreated(workOrderMapper.selectCount(
73
+                new LambdaQueryWrapper<WorkOrder>()
74
+                        .ge(WorkOrder::getCreatedAt, todayStart)
75
+                        .le(WorkOrder::getCreatedAt, todayEnd)));
76
+        vo.setTodayCompleted(workOrderMapper.selectCount(
77
+                new LambdaQueryWrapper<WorkOrder>()
78
+                        .eq(WorkOrder::getStatus, WorkOrderLifecycleService.STATUS_COMPLETED)
79
+                        .ge(WorkOrder::getCompletedAt, todayStart)
80
+                        .le(WorkOrder::getCompletedAt, todayEnd)));
81
+
82
+        return vo;
83
+    }
84
+}

+ 347
- 0
wm-dispatch/src/test/java/com/water/dispatch/service/WorkOrderLifecycleServiceTest.java Wyświetl plik

@@ -0,0 +1,347 @@
1
+package com.water.dispatch.service;
2
+
3
+import com.water.common.core.exception.BusinessException;
4
+import com.water.dispatch.entity.WorkOrder;
5
+import com.water.dispatch.entity.WorkOrderAssignment;
6
+import com.water.dispatch.entity.WorkOrderLog;
7
+import com.water.dispatch.entity.dto.WorkOrderAssignRequest;
8
+import com.water.dispatch.entity.dto.WorkOrderCreateRequest;
9
+import com.water.dispatch.entity.dto.WorkOrderProcessRequest;
10
+import com.water.dispatch.mapper.WorkOrderAssignmentMapper;
11
+import com.water.dispatch.mapper.WorkOrderLogMapper;
12
+import com.water.dispatch.mapper.WorkOrderMapper;
13
+import org.junit.jupiter.api.Test;
14
+import org.junit.jupiter.api.extension.ExtendWith;
15
+import org.mockito.ArgumentCaptor;
16
+import org.mockito.InjectMocks;
17
+import org.mockito.Mock;
18
+import org.mockito.junit.jupiter.MockitoExtension;
19
+
20
+import java.util.List;
21
+
22
+import static org.junit.jupiter.api.Assertions.*;
23
+import static org.mockito.ArgumentMatchers.any;
24
+import static org.mockito.Mockito.*;
25
+
26
+@ExtendWith(MockitoExtension.class)
27
+class WorkOrderLifecycleServiceTest {
28
+
29
+    @Mock
30
+    private WorkOrderMapper workOrderMapper;
31
+    @Mock
32
+    private WorkOrderLogMapper logMapper;
33
+    @Mock
34
+    private WorkOrderAssignmentMapper assignmentMapper;
35
+
36
+    @InjectMocks
37
+    private WorkOrderLifecycleService lifecycleService;
38
+
39
+    // ==================== 1. 工单创建测试 ====================
40
+
41
+    @Test
42
+    void testCreateWorkOrder() {
43
+        when(workOrderMapper.insert(any())).thenReturn(1);
44
+        when(logMapper.insert(any())).thenReturn(1);
45
+
46
+        WorkOrderCreateRequest req = new WorkOrderCreateRequest();
47
+        req.setTitle("管道破裂维修");
48
+        req.setDescription("主管道漏水,需紧急维修");
49
+        req.setType("EMERGENCY");
50
+        req.setPriority("URGENT");
51
+        req.setCreatorId(1L);
52
+        req.setCreatorName("张三");
53
+        req.setLocation("某某路100号");
54
+
55
+        WorkOrder result = lifecycleService.create(req);
56
+
57
+        assertNotNull(result.getOrderNo());
58
+        assertTrue(result.getOrderNo().startsWith("WO-"));
59
+        assertEquals(0, result.getStatus()); // PENDING
60
+        assertEquals("EMERGENCY", result.getType());
61
+        assertEquals("URGENT", result.getPriority());
62
+        assertEquals("管道破裂维修", result.getTitle());
63
+        verify(workOrderMapper).insert(any());
64
+        verify(logMapper).insert(any());
65
+    }
66
+
67
+    @Test
68
+    void testCreateWorkOrderDefaultType() {
69
+        when(workOrderMapper.insert(any())).thenReturn(1);
70
+        when(logMapper.insert(any())).thenReturn(1);
71
+
72
+        WorkOrderCreateRequest req = new WorkOrderCreateRequest();
73
+        req.setTitle("测试工单");
74
+        req.setCreatorId(1L);
75
+        req.setCreatorName("张三");
76
+
77
+        WorkOrder result = lifecycleService.create(req);
78
+
79
+        assertEquals("REPAIR", result.getType());
80
+        assertEquals("MEDIUM", result.getPriority());
81
+    }
82
+
83
+    // ==================== 2. 工单分派测试 ====================
84
+
85
+    @Test
86
+    void testAssignWorkOrder() {
87
+        WorkOrder wo = buildWorkOrder(1L, 0);
88
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
89
+        when(workOrderMapper.updateById(any())).thenReturn(1);
90
+        when(assignmentMapper.insert(any())).thenReturn(1);
91
+        when(logMapper.insert(any())).thenReturn(1);
92
+
93
+        WorkOrderAssignRequest req = new WorkOrderAssignRequest();
94
+        req.setAssignerId(10L);
95
+        req.setAssignerName("管理员");
96
+        req.setPrimaryAssigneeId(2L);
97
+        req.setPrimaryAssigneeName("李四");
98
+        req.setAssignmentNote("请尽快处理");
99
+        req.setAssignmentType("MANUAL");
100
+
101
+        WorkOrder result = lifecycleService.assign(1L, req);
102
+
103
+        assertEquals(1, result.getStatus()); // ASSIGNED
104
+        assertEquals(2L, result.getAssigneeId());
105
+        assertEquals("李四", result.getAssigneeName());
106
+        assertNotNull(result.getAssignedAt());
107
+        verify(assignmentMapper).insert(any());
108
+        verify(logMapper).insert(any());
109
+    }
110
+
111
+    @Test
112
+    void testAssignWorkOrderWithAssistants() {
113
+        WorkOrder wo = buildWorkOrder(1L, 0);
114
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
115
+        when(workOrderMapper.updateById(any())).thenReturn(1);
116
+        when(assignmentMapper.insert(any())).thenReturn(1);
117
+        when(logMapper.insert(any())).thenReturn(1);
118
+
119
+        WorkOrderAssignRequest req = new WorkOrderAssignRequest();
120
+        req.setAssignerId(10L);
121
+        req.setAssignerName("管理员");
122
+        req.setPrimaryAssigneeId(2L);
123
+        req.setPrimaryAssigneeName("李四");
124
+
125
+        WorkOrderAssignRequest.AssistantInfo assistant = new WorkOrderAssignRequest.AssistantInfo();
126
+        assistant.setUserId(3L);
127
+        assistant.setUserName("王五");
128
+        assistant.setNote("协助维修");
129
+        req.setAssistants(List.of(assistant));
130
+
131
+        WorkOrder result = lifecycleService.assign(1L, req);
132
+
133
+        assertEquals(1, result.getStatus());
134
+        // 主负责人 + 1协助人 = 2次insert
135
+        ArgumentCaptor<WorkOrderAssignment> captor = ArgumentCaptor.forClass(WorkOrderAssignment.class);
136
+        verify(assignmentMapper, times(2)).insert(captor.capture());
137
+
138
+        List<WorkOrderAssignment> assignments = captor.getAllValues();
139
+        assertEquals("PRIMARY", assignments.get(0).getRole());
140
+        assertEquals("ASSISTANT", assignments.get(1).getRole());
141
+    }
142
+
143
+    @Test
144
+    void testAssignWorkOrderWrongStatus() {
145
+        WorkOrder wo = buildWorkOrder(1L, 2); // IN_PROGRESS
146
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
147
+
148
+        WorkOrderAssignRequest req = new WorkOrderAssignRequest();
149
+        req.setPrimaryAssigneeId(2L);
150
+        req.setPrimaryAssigneeName("李四");
151
+
152
+        assertThrows(BusinessException.class, () -> lifecycleService.assign(1L, req));
153
+    }
154
+
155
+    // ==================== 3. 工单处理测试 ====================
156
+
157
+    @Test
158
+    void testStartProcess() {
159
+        WorkOrder wo = buildWorkOrder(1L, 1); // ASSIGNED
160
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
161
+        when(workOrderMapper.updateById(any())).thenReturn(1);
162
+        when(logMapper.insert(any())).thenReturn(1);
163
+
164
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
165
+        req.setOperatorId(2L);
166
+        req.setOperatorName("李四");
167
+
168
+        WorkOrder result = lifecycleService.startProcess(1L, req);
169
+
170
+        assertEquals(2, result.getStatus()); // IN_PROGRESS
171
+        assertNotNull(result.getStartedAt());
172
+    }
173
+
174
+    @Test
175
+    void testStartProcessWrongStatus() {
176
+        WorkOrder wo = buildWorkOrder(1L, 0); // PENDING
177
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
178
+
179
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
180
+        req.setOperatorId(2L);
181
+        req.setOperatorName("李四");
182
+
183
+        assertThrows(BusinessException.class, () -> lifecycleService.startProcess(1L, req));
184
+    }
185
+
186
+    // ==================== 4. 工单完成测试 ====================
187
+
188
+    @Test
189
+    void testCompleteWorkOrder() {
190
+        WorkOrder wo = buildWorkOrder(1L, 2); // IN_PROGRESS
191
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
192
+        when(workOrderMapper.updateById(any())).thenReturn(1);
193
+        when(logMapper.insert(any())).thenReturn(1);
194
+
195
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
196
+        req.setOperatorId(2L);
197
+        req.setOperatorName("李四");
198
+        req.setResult("管道已修复,测试通过");
199
+
200
+        WorkOrder result = lifecycleService.complete(1L, req);
201
+
202
+        assertEquals(4, result.getStatus()); // COMPLETED
203
+        assertNotNull(result.getCompletedAt());
204
+        assertEquals("管道已修复,测试通过", result.getResult());
205
+
206
+        ArgumentCaptor<WorkOrderLog> logCaptor = ArgumentCaptor.forClass(WorkOrderLog.class);
207
+        verify(logMapper).insert(logCaptor.capture());
208
+        assertEquals("COMPLETED", logCaptor.getValue().getStage());
209
+    }
210
+
211
+    @Test
212
+    void testCompleteWorkOrderWrongStatus() {
213
+        WorkOrder wo = buildWorkOrder(1L, 1); // ASSIGNED
214
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
215
+
216
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
217
+        req.setOperatorId(2L);
218
+        req.setOperatorName("李四");
219
+
220
+        assertThrows(BusinessException.class, () -> lifecycleService.complete(1L, req));
221
+    }
222
+
223
+    // ==================== 5. 工单驳回测试 ====================
224
+
225
+    @Test
226
+    void testRejectWorkOrder() {
227
+        WorkOrder wo = buildWorkOrder(1L, 1); // ASSIGNED
228
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
229
+        when(workOrderMapper.updateById(any())).thenReturn(1);
230
+        when(logMapper.insert(any())).thenReturn(1);
231
+
232
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
233
+        req.setOperatorId(3L);
234
+        req.setOperatorName("王五");
235
+        req.setRejectReason("信息不足,无法处理");
236
+
237
+        WorkOrder result = lifecycleService.reject(1L, req);
238
+
239
+        assertEquals(5, result.getStatus()); // REJECTED
240
+        assertEquals("信息不足,无法处理", result.getRejectReason());
241
+    }
242
+
243
+    @Test
244
+    void testRejectCompletedWorkOrder() {
245
+        WorkOrder wo = buildWorkOrder(1L, 4); // COMPLETED
246
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
247
+
248
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
249
+        assertThrows(BusinessException.class, () -> lifecycleService.reject(1L, req));
250
+    }
251
+
252
+    // ==================== 6. 工单取消测试 ====================
253
+
254
+    @Test
255
+    void testCancelWorkOrder() {
256
+        WorkOrder wo = buildWorkOrder(1L, 0); // PENDING
257
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
258
+        when(workOrderMapper.updateById(any())).thenReturn(1);
259
+        when(logMapper.insert(any())).thenReturn(1);
260
+
261
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
262
+        req.setOperatorId(1L);
263
+        req.setOperatorName("张三");
264
+        req.setRemark("重复报修");
265
+
266
+        WorkOrder result = lifecycleService.cancel(1L, req);
267
+
268
+        assertEquals(6, result.getStatus()); // CANCELLED
269
+    }
270
+
271
+    @Test
272
+    void testCancelAlreadyCompletedWorkOrder() {
273
+        WorkOrder wo = buildWorkOrder(1L, 4); // COMPLETED
274
+        when(workOrderMapper.selectById(1L)).thenReturn(wo);
275
+
276
+        WorkOrderProcessRequest req = new WorkOrderProcessRequest();
277
+        assertThrows(BusinessException.class, () -> lifecycleService.cancel(1L, req));
278
+    }
279
+
280
+    // ==================== 7. 工单不存在测试 ====================
281
+
282
+    @Test
283
+    void testWorkOrderNotFound() {
284
+        when(workOrderMapper.selectById(999L)).thenReturn(null);
285
+        assertThrows(BusinessException.class, () -> lifecycleService.getById(999L));
286
+    }
287
+
288
+    // ==================== 8. 完整生命周期测试 ====================
289
+
290
+    @Test
291
+    void testFullLifecycle() {
292
+        // 创建
293
+        when(workOrderMapper.insert(any())).thenReturn(1);
294
+        when(logMapper.insert(any())).thenReturn(1);
295
+        when(assignmentMapper.insert(any())).thenReturn(1);
296
+
297
+        WorkOrderCreateRequest createReq = new WorkOrderCreateRequest();
298
+        createReq.setTitle("全流程测试工单");
299
+        createReq.setType("REPAIR");
300
+        createReq.setPriority("HIGH");
301
+        createReq.setCreatorId(1L);
302
+        createReq.setCreatorName("张三");
303
+
304
+        WorkOrder wo = lifecycleService.create(createReq);
305
+        wo.setId(100L);
306
+        assertEquals(0, wo.getStatus());
307
+
308
+        // 分派
309
+        when(workOrderMapper.selectById(100L)).thenReturn(wo);
310
+        when(workOrderMapper.updateById(any())).thenReturn(1);
311
+
312
+        WorkOrderAssignRequest assignReq = new WorkOrderAssignRequest();
313
+        assignReq.setAssignerId(10L);
314
+        assignReq.setAssignerName("管理员");
315
+        assignReq.setPrimaryAssigneeId(2L);
316
+        assignReq.setPrimaryAssigneeName("李四");
317
+        lifecycleService.assign(100L, assignReq);
318
+        assertEquals(1, wo.getStatus());
319
+
320
+        // 开始处理
321
+        lifecycleService.startProcess(100L, new WorkOrderProcessRequest() {{
322
+            setOperatorId(2L); setOperatorName("李四");
323
+        }});
324
+        assertEquals(2, wo.getStatus());
325
+
326
+        // 完成
327
+        lifecycleService.complete(100L, new WorkOrderProcessRequest() {{
328
+            setOperatorId(2L); setOperatorName("李四"); setResult("维修完成");
329
+        }});
330
+        assertEquals(4, wo.getStatus());
331
+    }
332
+
333
+    // ==================== Helper ====================
334
+
335
+    private WorkOrder buildWorkOrder(Long id, int status) {
336
+        WorkOrder wo = new WorkOrder();
337
+        wo.setId(id);
338
+        wo.setOrderNo("WO-TEST-001");
339
+        wo.setTitle("测试工单");
340
+        wo.setType("REPAIR");
341
+        wo.setPriority("MEDIUM");
342
+        wo.setStatus(status);
343
+        wo.setCreatorId(1L);
344
+        wo.setCreatorName("张三");
345
+        return wo;
346
+    }
347
+}

+ 102
- 0
wm-dispatch/src/test/java/com/water/dispatch/service/WorkOrderStatisticsServiceTest.java Wyświetl plik

@@ -0,0 +1,102 @@
1
+package com.water.dispatch.service;
2
+
3
+import com.water.dispatch.entity.dto.WorkOrderStatVO;
4
+import com.water.dispatch.mapper.WorkOrderMapper;
5
+import org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.InjectMocks;
8
+import org.mockito.Mock;
9
+import org.mockito.junit.jupiter.MockitoExtension;
10
+
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+import static org.junit.jupiter.api.Assertions.*;
15
+import static org.mockito.ArgumentMatchers.any;
16
+import static org.mockito.Mockito.when;
17
+
18
+@ExtendWith(MockitoExtension.class)
19
+class WorkOrderStatisticsServiceTest {
20
+
21
+    @Mock
22
+    private WorkOrderMapper workOrderMapper;
23
+
24
+    @InjectMocks
25
+    private WorkOrderStatisticsService statisticsService;
26
+
27
+    @Test
28
+    void testStatisticsBasic() {
29
+        when(workOrderMapper.selectCount(any())).thenReturn(100L);
30
+        when(workOrderMapper.countByStatus()).thenReturn(List.of(
31
+                Map.of("status", 0, "cnt", 20L),
32
+                Map.of("status", 1, "cnt", 15L),
33
+                Map.of("status", 2, "cnt", 10L),
34
+                Map.of("status", 4, "cnt", 50L),
35
+                Map.of("status", 5, "cnt", 3L),
36
+                Map.of("status", 6, "cnt", 2L)));
37
+        when(workOrderMapper.countByType()).thenReturn(List.of(
38
+                Map.of("type", "REPAIR", "cnt", 40L),
39
+                Map.of("type", "INSPECTION", "cnt", 30L),
40
+                Map.of("type", "COMPLAINT", "cnt", 20L),
41
+                Map.of("type", "EMERGENCY", "cnt", 10L)));
42
+        when(workOrderMapper.countByPriority()).thenReturn(List.of(
43
+                Map.of("priority", "LOW", "cnt", 20L),
44
+                Map.of("priority", "MEDIUM", "cnt", 40L),
45
+                Map.of("priority", "HIGH", "cnt", 30L),
46
+                Map.of("priority", "URGENT", "cnt", 10L)));
47
+        when(workOrderMapper.countByAssignee()).thenReturn(List.of(
48
+                Map.of("assignee", "李四", "cnt", 25L),
49
+                Map.of("assignee", "王五", "cnt", 20L)));
50
+        when(workOrderMapper.avgProcessHours()).thenReturn(4.5);
51
+        when(workOrderMapper.countOverdue()).thenReturn(3L);
52
+
53
+        WorkOrderStatVO stat = statisticsService.statistics();
54
+
55
+        assertEquals(100L, stat.getTotal());
56
+        assertEquals(50.0, stat.getCompletionRate()); // 50/100 = 50%
57
+        assertEquals(4.5, stat.getAvgProcessHours());
58
+        assertEquals(3L, stat.getOverdueCount());
59
+        assertEquals(4, stat.getByType().size());
60
+        assertEquals(4, stat.getByPriority().size());
61
+        assertEquals(2, stat.getByAssignee().size());
62
+        assertEquals(25L, stat.getByAssignee().get("李四"));
63
+    }
64
+
65
+    @Test
66
+    void testStatisticsEmptyData() {
67
+        when(workOrderMapper.selectCount(any())).thenReturn(0L);
68
+        when(workOrderMapper.countByStatus()).thenReturn(List.of());
69
+        when(workOrderMapper.countByType()).thenReturn(List.of());
70
+        when(workOrderMapper.countByPriority()).thenReturn(List.of());
71
+        when(workOrderMapper.countByAssignee()).thenReturn(List.of());
72
+        when(workOrderMapper.avgProcessHours()).thenReturn(null);
73
+        when(workOrderMapper.countOverdue()).thenReturn(0L);
74
+
75
+        WorkOrderStatVO stat = statisticsService.statistics();
76
+
77
+        assertEquals(0L, stat.getTotal());
78
+        assertEquals(0.0, stat.getCompletionRate());
79
+        assertEquals(0.0, stat.getAvgProcessHours());
80
+        assertEquals(0L, stat.getOverdueCount());
81
+        assertTrue(stat.getByType().isEmpty());
82
+        assertTrue(stat.getByAssignee().isEmpty());
83
+    }
84
+
85
+    @Test
86
+    void testStatisticsCompletionRateRounding() {
87
+        when(workOrderMapper.selectCount(any())).thenReturn(3L);
88
+        when(workOrderMapper.countByStatus()).thenReturn(List.of(
89
+                Map.of("status", 4, "cnt", 1L)));
90
+        when(workOrderMapper.countByType()).thenReturn(List.of());
91
+        when(workOrderMapper.countByPriority()).thenReturn(List.of());
92
+        when(workOrderMapper.countByAssignee()).thenReturn(List.of());
93
+        when(workOrderMapper.avgProcessHours()).thenReturn(1.23456);
94
+        when(workOrderMapper.countOverdue()).thenReturn(0L);
95
+
96
+        WorkOrderStatVO stat = statisticsService.statistics();
97
+
98
+        // 1/3 = 33.33%
99
+        assertEquals(33.33, stat.getCompletionRate());
100
+        assertEquals(1.23, stat.getAvgProcessHours());
101
+    }
102
+}