Browse Source

feat(wm-production): #68 值班管理完整实现

- 后端: 4个实体 + 4个Mapper + 4个Service + 1个Controller
  - DutyScheduleService: 排班CRUD + 自动排班(轮转算法)
  - DutyRecordService: 上下班打卡 + 状态管理 + 统计
  - HandoverService: 交接班记录 + 双方签字确认
  - DutyLogService: 值班日志(时间线) + 处理事项 + 统计
  - DutyController: /api/production/duty 统一入口
- 前端: Vue3 + TypeScript + Element Plus
  - ScheduleView.vue: 日历排班视图(拖拽排班+自动排班)
  - DutyPanel.vue: 值班面板(打卡+状态+联系方式+统计)
  - HandoverView.vue: 交接班记录(列表+新建+签字+详情)
  - DutyLogView.vue: 值班日志(时间线+统计+处理)
  - duty.ts: 完整API封装
- SQL DDL: V_duty.sql (4张表+索引)
- 单元测试: 4个Service测试类(30+测试用例)
bot_dev2 5 days ago
parent
commit
279d584d2c
24 changed files with 2879 additions and 0 deletions
  1. 134
    0
      frontend/src/api/duty.ts
  2. 4
    0
      frontend/src/router/index.ts
  3. 280
    0
      frontend/src/views/duty/DutyLogView.vue
  4. 263
    0
      frontend/src/views/duty/DutyPanel.vue
  5. 242
    0
      frontend/src/views/duty/HandoverView.vue
  6. 264
    0
      frontend/src/views/duty/ScheduleView.vue
  7. 280
    0
      wm-production/src/main/java/com/water/production/controller/DutyController.java
  8. 28
    0
      wm-production/src/main/java/com/water/production/entity/DutyLog.java
  9. 27
    0
      wm-production/src/main/java/com/water/production/entity/DutyRecord.java
  10. 24
    0
      wm-production/src/main/java/com/water/production/entity/DutySchedule.java
  11. 26
    0
      wm-production/src/main/java/com/water/production/entity/HandoverRecord.java
  12. 31
    0
      wm-production/src/main/java/com/water/production/mapper/DutyLogMapper.java
  13. 30
    0
      wm-production/src/main/java/com/water/production/mapper/DutyRecordMapper.java
  14. 26
    0
      wm-production/src/main/java/com/water/production/mapper/DutyScheduleMapper.java
  15. 34
    0
      wm-production/src/main/java/com/water/production/mapper/HandoverRecordMapper.java
  16. 125
    0
      wm-production/src/main/java/com/water/production/service/DutyLogService.java
  17. 179
    0
      wm-production/src/main/java/com/water/production/service/DutyRecordService.java
  18. 152
    0
      wm-production/src/main/java/com/water/production/service/DutyScheduleService.java
  19. 122
    0
      wm-production/src/main/java/com/water/production/service/HandoverService.java
  20. 84
    0
      wm-production/src/main/resources/db/V_duty.sql
  21. 144
    0
      wm-production/src/test/java/com/water/production/service/DutyLogServiceTest.java
  22. 135
    0
      wm-production/src/test/java/com/water/production/service/DutyRecordServiceTest.java
  23. 129
    0
      wm-production/src/test/java/com/water/production/service/DutyScheduleServiceTest.java
  24. 116
    0
      wm-production/src/test/java/com/water/production/service/HandoverServiceTest.java

+ 134
- 0
frontend/src/api/duty.ts View File

1
+import request from './request'
2
+
3
+// ==================== 排班管理 ====================
4
+export function getMonthlySchedule(year: number, month: number) {
5
+  return request.get('/production/duty/schedule/monthly', { params: { year, month } })
6
+}
7
+
8
+export function getScheduleRange(start: string, end: string) {
9
+  return request.get('/production/duty/schedule/range', { params: { start, end } })
10
+}
11
+
12
+export function getTodaySchedule() {
13
+  return request.get('/production/duty/schedule/today')
14
+}
15
+
16
+export function createSchedule(data: any) {
17
+  return request.post('/production/duty/schedule', data)
18
+}
19
+
20
+export function batchCreateSchedule(data: any[]) {
21
+  return request.post('/production/duty/schedule/batch', data)
22
+}
23
+
24
+export function autoSchedule(data: { userIds: number[], startDate: string, endDate: string, shiftType?: string }) {
25
+  return request.post('/production/duty/schedule/auto', data)
26
+}
27
+
28
+export function updateSchedule(id: number, data: any) {
29
+  return request.put(`/production/duty/schedule/${id}`, data)
30
+}
31
+
32
+export function cancelSchedule(id: number) {
33
+  return request.delete(`/production/duty/schedule/${id}`)
34
+}
35
+
36
+// ==================== 上下班打卡 ====================
37
+export function getTodayRecords() {
38
+  return request.get('/production/duty/record/today')
39
+}
40
+
41
+export function getRecordsByDate(date: string) {
42
+  return request.get(`/production/duty/record/date/${date}`)
43
+}
44
+
45
+export function getUserRecords(userId: number, start: string, end: string) {
46
+  return request.get(`/production/duty/record/user/${userId}`, { params: { start, end } })
47
+}
48
+
49
+export function startDuty(data: { userId: number, location?: string }) {
50
+  return request.post('/production/duty/record/start', data)
51
+}
52
+
53
+export function endDuty(data: { userId: number, location?: string, handoverRemark?: string }) {
54
+  return request.post('/production/duty/record/end', data)
55
+}
56
+
57
+export function getCurrentDutyStatus(userId: number) {
58
+  return request.get(`/production/duty/record/status/${userId}`)
59
+}
60
+
61
+export function getDutyStats(userId: number, year: number, month: number) {
62
+  return request.get(`/production/duty/record/stats/${userId}`, { params: { year, month } })
63
+}
64
+
65
+// ==================== 交接班管理 ====================
66
+export function createHandover(data: any) {
67
+  return request.post('/production/duty/handover', data)
68
+}
69
+
70
+export function getHandoversByDate(date: string) {
71
+  return request.get(`/production/duty/handover/date/${date}`)
72
+}
73
+
74
+export function getHandoversByRange(start: string, end: string) {
75
+  return request.get('/production/duty/handover/range', { params: { start, end } })
76
+}
77
+
78
+export function getHandoverDetail(id: number) {
79
+  return request.get(`/production/duty/handover/${id}`)
80
+}
81
+
82
+export function updateHandover(id: number, data: any) {
83
+  return request.put(`/production/duty/handover/${id}`, data)
84
+}
85
+
86
+export function signFrom(id: number) {
87
+  return request.post(`/production/duty/handover/${id}/sign-from`)
88
+}
89
+
90
+export function signTo(id: number) {
91
+  return request.post(`/production/duty/handover/${id}/sign-to`)
92
+}
93
+
94
+export function checkHandoverStatus(date: string) {
95
+  return request.get(`/production/duty/handover/status/${date}`)
96
+}
97
+
98
+// ==================== 值班日志 ====================
99
+export function createDutyLog(data: any) {
100
+  return request.post('/production/duty/log', data)
101
+}
102
+
103
+export function getLogsByDate(date: string) {
104
+  return request.get(`/production/duty/log/date/${date}`)
105
+}
106
+
107
+export function getLogsByRange(start: string, end: string) {
108
+  return request.get('/production/duty/log/range', { params: { start, end } })
109
+}
110
+
111
+export function getLogDetail(id: number) {
112
+  return request.get(`/production/duty/log/${id}`)
113
+}
114
+
115
+export function updateLog(id: number, data: any) {
116
+  return request.put(`/production/duty/log/${id}`, data)
117
+}
118
+
119
+export function deleteLog(id: number) {
120
+  return request.delete(`/production/duty/log/${id}`)
121
+}
122
+
123
+export function handleLog(id: number, data: { handlerId: number, result: string }) {
124
+  return request.post(`/production/duty/log/${id}/handle`, data)
125
+}
126
+
127
+export function getLogStats(date: string) {
128
+  return request.get(`/production/duty/log/stats/${date}`)
129
+}
130
+
131
+// ==================== 联系方式面板 ====================
132
+export function getDutyContacts() {
133
+  return request.get('/production/duty/contacts')
134
+}

+ 4
- 0
frontend/src/router/index.ts View File

11
       { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
11
       { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
12
       { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
12
       { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
14
+      { path: 'duty/schedule', name: 'dutySchedule', component: () => import('@/views/duty/ScheduleView.vue') },
15
+      { path: 'duty/panel', name: 'dutyPanel', component: () => import('@/views/duty/DutyPanel.vue') },
16
+      { path: 'duty/handover', name: 'dutyHandover', component: () => import('@/views/duty/HandoverView.vue') },
17
+      { path: 'duty/log', name: 'dutyLog', component: () => import('@/views/duty/DutyLogView.vue') },
14
     ]
18
     ]
15
   },
19
   },
16
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }
20
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 280
- 0
frontend/src/views/duty/DutyLogView.vue View File

1
+<template>
2
+  <div class="duty-log-view">
3
+    <div class="header">
4
+      <h2>值班日志</h2>
5
+      <div class="actions">
6
+        <el-date-picker v-model="selectedDate" type="date" placeholder="选择日期"
7
+                        value-format="YYYY-MM-DD" @change="loadLogs" />
8
+        <el-select v-model="filterType" placeholder="日志类型" clearable style="width:120px" @change="loadLogs">
9
+          <el-option label="巡查" value="patrol" />
10
+          <el-option label="检查" value="inspection" />
11
+          <el-option label="事件" value="event" />
12
+          <el-option label="维修" value="maintenance" />
13
+          <el-option label="其他" value="other" />
14
+        </el-select>
15
+        <el-button type="primary" @click="showCreateDialog">新建日志</el-button>
16
+      </div>
17
+    </div>
18
+
19
+    <!-- 统计卡片 -->
20
+    <el-row :gutter="12" style="margin-bottom: 16px" v-if="logStats">
21
+      <el-col :span="6">
22
+        <el-card shadow="hover" class="stat-card">
23
+          <div class="stat-value">{{ logStats.total }}</div>
24
+          <div class="stat-title">总记录数</div>
25
+        </el-card>
26
+      </el-col>
27
+      <el-col :span="6">
28
+        <el-card shadow="hover" class="stat-card">
29
+          <div class="stat-value warning">{{ logStats.unhandled }}</div>
30
+          <div class="stat-title">待处理</div>
31
+        </el-card>
32
+      </el-col>
33
+      <el-col :span="12">
34
+        <el-card shadow="hover">
35
+          <div class="type-bars">
36
+            <div v-for="(count, type) in logStats.byType" :key="type" class="type-bar">
37
+              <span class="type-label">{{ getTypeLabel(type as string) }}</span>
38
+              <el-progress :percentage="Math.round((count as number) / Math.max(logStats.total, 1) * 100)"
39
+                          :stroke-width="12" :text-inside="true" :format="() => String(count)" />
40
+            </div>
41
+          </div>
42
+        </el-card>
43
+      </el-col>
44
+    </el-row>
45
+
46
+    <el-row :gutter="16">
47
+      <!-- 时间线 -->
48
+      <el-col :span="14">
49
+        <el-card shadow="hover">
50
+          <el-timeline>
51
+            <el-timeline-item v-for="log in filteredLogs" :key="log.id"
52
+                              :timestamp="formatTime(log.log_time)"
53
+                              :type="getSeverityType(log.severity)"
54
+                              :hollow="log.handled"
55
+                              placement="top">
56
+              <el-card shadow="never" class="log-card" :class="{ handled: log.handled }">
57
+                <div class="log-header">
58
+                  <el-tag size="small" :type="getTypeTagType(log.log_type)">{{ getTypeLabel(log.log_type) }}</el-tag>
59
+                  <span class="log-title">{{ log.title }}</span>
60
+                  <el-tag v-if="log.severity === 'critical'" type="danger" size="small" effect="dark">紧急</el-tag>
61
+                  <el-tag v-else-if="log.severity === 'warning'" type="warning" size="small">注意</el-tag>
62
+                </div>
63
+                <div class="log-body">
64
+                  <p>{{ log.content || '无详细内容' }}</p>
65
+                  <div v-if="log.location" class="log-location">📍 {{ log.location }}</div>
66
+                  <div v-if="log.real_name" class="log-author">👤 {{ log.real_name }}</div>
67
+                </div>
68
+                <div class="log-actions" v-if="!log.handled">
69
+                  <el-button text type="primary" size="small" @click="handleLogAction(log)">处理</el-button>
70
+                  <el-button text type="warning" size="small" @click="editLog(log)">编辑</el-button>
71
+                  <el-button text type="danger" size="small" @click="handleDeleteLog(log.id)">删除</el-button>
72
+                </div>
73
+                <div v-else class="log-handled-info">
74
+                  ✅ 已处理 {{ log.handle_result ? '— ' + log.handle_result : '' }}
75
+                </div>
76
+              </el-card>
77
+            </el-timeline-item>
78
+          </el-timeline>
79
+          <el-empty v-if="!filteredLogs.length" description="暂无日志记录" />
80
+        </el-card>
81
+      </el-col>
82
+
83
+      <!-- 右侧详情/表单 -->
84
+      <el-col :span="10">
85
+        <el-card shadow="hover">
86
+          <template #header>{{ formMode === 'create' ? '新建日志' : '编辑日志' }}</template>
87
+          <el-form :model="logForm" label-width="80px" v-if="showForm">
88
+            <el-form-item label="日志类型">
89
+              <el-select v-model="logForm.logType">
90
+                <el-option label="巡查" value="patrol" />
91
+                <el-option label="检查" value="inspection" />
92
+                <el-option label="事件" value="event" />
93
+                <el-option label="维修" value="maintenance" />
94
+                <el-option label="其他" value="other" />
95
+              </el-select>
96
+            </el-form-item>
97
+            <el-form-item label="标题">
98
+              <el-input v-model="logForm.title" placeholder="日志标题" />
99
+            </el-form-item>
100
+            <el-form-item label="内容">
101
+              <el-input v-model="logForm.content" type="textarea" :rows="4" placeholder="详细内容..." />
102
+            </el-form-item>
103
+            <el-form-item label="位置">
104
+              <el-input v-model="logForm.location" placeholder="记录位置" />
105
+            </el-form-item>
106
+            <el-form-item label="严重程度">
107
+              <el-radio-group v-model="logForm.severity">
108
+                <el-radio-button value="normal">一般</el-radio-button>
109
+                <el-radio-button value="warning">注意</el-radio-button>
110
+                <el-radio-button value="critical">紧急</el-radio-button>
111
+              </el-radio-group>
112
+            </el-form-item>
113
+            <el-form-item>
114
+              <el-button type="primary" @click="handleSubmitLog">
115
+                {{ formMode === 'create' ? '提交' : '保存' }}
116
+              </el-button>
117
+              <el-button @click="showForm = false">取消</el-button>
118
+            </el-form-item>
119
+          </el-form>
120
+          <el-empty v-else description="点击「新建日志」开始记录" />
121
+        </el-card>
122
+      </el-col>
123
+    </el-row>
124
+
125
+    <!-- 处理对话框 -->
126
+    <el-dialog v-model="handleDialogVisible" title="处理日志事项" width="400px">
127
+      <el-form label-width="80px">
128
+        <el-form-item label="处理结果">
129
+          <el-input v-model="handleForm.result" type="textarea" :rows="3" placeholder="处理说明..." />
130
+        </el-form-item>
131
+      </el-form>
132
+      <template #footer>
133
+        <el-button @click="handleDialogVisible = false">取消</el-button>
134
+        <el-button type="primary" @click="submitHandle">确认处理</el-button>
135
+      </template>
136
+    </el-dialog>
137
+  </div>
138
+</template>
139
+
140
+<script setup lang="ts">
141
+import { ref, computed, onMounted } from 'vue'
142
+import { ElMessage, ElMessageBox } from 'element-plus'
143
+import { createDutyLog, getLogsByDate, getLogStats, updateLog, deleteLog, handleLog } from '@/api/duty'
144
+
145
+const selectedDate = ref(new Date().toISOString().slice(0, 10))
146
+const filterType = ref('')
147
+const logList = ref<any[]>([])
148
+const logStats = ref<any>(null)
149
+const showForm = ref(false)
150
+const formMode = ref<'create' | 'edit'>('create')
151
+const handleDialogVisible = ref(false)
152
+const handleForm = ref({ id: 0, result: '' })
153
+const currentUserId = 1 // TODO: 从登录状态获取
154
+
155
+const logForm = ref({
156
+  id: 0,
157
+  logType: 'patrol',
158
+  title: '',
159
+  content: '',
160
+  location: '',
161
+  severity: 'normal'
162
+})
163
+
164
+const filteredLogs = computed(() => {
165
+  if (!filterType.value) return logList.value
166
+  return logList.value.filter(l => l.log_type === filterType.value)
167
+})
168
+
169
+function getTypeLabel(type: string) {
170
+  const map: any = { patrol: '巡查', inspection: '检查', event: '事件', maintenance: '维修', other: '其他' }
171
+  return map[type] || type
172
+}
173
+
174
+function getTypeTagType(type: string) {
175
+  const map: any = { patrol: '', inspection: 'success', event: 'warning', maintenance: 'info', other: 'info' }
176
+  return map[type] || ''
177
+}
178
+
179
+function getSeverityType(severity: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
180
+  const map: any = { normal: 'primary', warning: 'warning', critical: 'danger' }
181
+  return map[severity] || 'info'
182
+}
183
+
184
+function formatTime(t: string) {
185
+  return t ? new Date(t).toLocaleString('zh-CN') : '-'
186
+}
187
+
188
+async function loadLogs() {
189
+  try {
190
+    const date = selectedDate.value || new Date().toISOString().slice(0, 10)
191
+    const res = await getLogsByDate(date)
192
+    logList.value = res.data || []
193
+
194
+    const statsRes = await getLogStats(date)
195
+    logStats.value = statsRes.data
196
+  } catch (e) { console.error(e) }
197
+}
198
+
199
+function showCreateDialog() {
200
+  formMode.value = 'create'
201
+  logForm.value = { id: 0, logType: 'patrol', title: '', content: '', location: '', severity: 'normal' }
202
+  showForm.value = true
203
+}
204
+
205
+function editLog(log: any) {
206
+  formMode.value = 'edit'
207
+  logForm.value = {
208
+    id: log.id,
209
+    logType: log.log_type,
210
+    title: log.title,
211
+    content: log.content || '',
212
+    location: log.location || '',
213
+    severity: log.severity || 'normal'
214
+  }
215
+  showForm.value = true
216
+}
217
+
218
+async function handleSubmitLog() {
219
+  if (!logForm.value.title) {
220
+    ElMessage.warning('请填写标题')
221
+    return
222
+  }
223
+  try {
224
+    if (formMode.value === 'create') {
225
+      await createDutyLog({ ...logForm.value, userId: currentUserId, dutyDate: selectedDate.value })
226
+    } else {
227
+      await updateLog(logForm.value.id, logForm.value)
228
+    }
229
+    ElMessage.success(formMode.value === 'create' ? '日志创建成功' : '日志更新成功')
230
+    showForm.value = false
231
+    loadLogs()
232
+  } catch (e) { console.error(e) }
233
+}
234
+
235
+async function handleDeleteLog(id: number) {
236
+  try {
237
+    await ElMessageBox.confirm('确定删除该日志?', '提示', { type: 'warning' })
238
+    await deleteLog(id)
239
+    ElMessage.success('已删除')
240
+    loadLogs()
241
+  } catch {}
242
+}
243
+
244
+function handleLogAction(log: any) {
245
+  handleForm.value = { id: log.id, result: '' }
246
+  handleDialogVisible.value = true
247
+}
248
+
249
+async function submitHandle() {
250
+  try {
251
+    await handleLog(handleForm.value.id, { handlerId: currentUserId, result: handleForm.value.result })
252
+    ElMessage.success('处理完成')
253
+    handleDialogVisible.value = false
254
+    loadLogs()
255
+  } catch (e) { console.error(e) }
256
+}
257
+
258
+onMounted(() => loadLogs())
259
+</script>
260
+
261
+<style scoped>
262
+.duty-log-view { padding: 20px; }
263
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
264
+.actions { display: flex; gap: 8px; align-items: center; }
265
+.stat-card { text-align: center; }
266
+.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
267
+.stat-value.warning { color: #e6a23c; }
268
+.stat-title { font-size: 13px; color: #909399; margin-top: 4px; }
269
+.type-bars { display: flex; flex-direction: column; gap: 8px; }
270
+.type-bar { display: flex; align-items: center; gap: 8px; }
271
+.type-label { width: 40px; font-size: 13px; text-align: right; }
272
+.log-card { margin-bottom: 4px; }
273
+.log-card.handled { opacity: 0.7; }
274
+.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
275
+.log-title { font-weight: bold; font-size: 15px; }
276
+.log-body p { margin: 4px 0; color: #606266; line-height: 1.6; }
277
+.log-location, .log-author { font-size: 12px; color: #909399; }
278
+.log-actions { margin-top: 8px; }
279
+.log-handled-info { margin-top: 8px; font-size: 13px; color: #67c23a; }
280
+</style>

+ 263
- 0
frontend/src/views/duty/DutyPanel.vue View File

1
+<template>
2
+  <div class="duty-panel">
3
+    <h2>值班面板</h2>
4
+    <el-row :gutter="16">
5
+      <!-- 我的值班状态 -->
6
+      <el-col :span="8">
7
+        <el-card shadow="hover">
8
+          <template #header>
9
+            <div class="card-header">
10
+              <span>我的值班状态</span>
11
+              <el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
12
+            </div>
13
+          </template>
14
+          <div class="status-info">
15
+            <div class="time-display">
16
+              <div class="time-label">当前时间</div>
17
+              <div class="time-value">{{ currentTime }}</div>
18
+            </div>
19
+            <div v-if="myStatus.onDutyAt" class="time-display">
20
+              <div class="time-label">上班时间</div>
21
+              <div class="time-value">{{ formatTime(myStatus.onDutyAt) }}</div>
22
+            </div>
23
+            <div v-if="myStatus.offDutyAt" class="time-display">
24
+              <div class="time-label">下班时间</div>
25
+              <div class="time-value">{{ formatTime(myStatus.offDutyAt) }}</div>
26
+            </div>
27
+            <div class="action-buttons">
28
+              <el-button type="success" size="large" :disabled="myStatus.status === 'on_duty'"
29
+                         @click="handleStartDuty" :loading="actionLoading">
30
+                🟢 上班打卡
31
+              </el-button>
32
+              <el-button type="danger" size="large" :disabled="myStatus.status !== 'on_duty'"
33
+                         @click="showEndDialog = true" :loading="actionLoading">
34
+                🔴 下班打卡
35
+              </el-button>
36
+            </div>
37
+          </div>
38
+        </el-card>
39
+      </el-col>
40
+
41
+      <!-- 今日值班列表 -->
42
+      <el-col :span="10">
43
+        <el-card shadow="hover">
44
+          <template #header>
45
+            <div class="card-header">
46
+              <span>今日值班人员</span>
47
+              <el-button text type="primary" @click="loadTodayData">刷新</el-button>
48
+            </div>
49
+          </template>
50
+          <el-table :data="todayRecords" stripe size="small">
51
+            <el-table-column prop="real_name" label="姓名" width="80" />
52
+            <el-table-column prop="shift_type" label="班次" width="70">
53
+              <template #default="{ row }">
54
+                <el-tag size="small" :type="getShiftType(row.shift_type)">{{ getShiftLabel(row.shift_type) }}</el-tag>
55
+              </template>
56
+            </el-table-column>
57
+            <el-table-column prop="status" label="状态" width="80">
58
+              <template #default="{ row }">
59
+                <el-tag size="small" :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
60
+              </template>
61
+            </el-table-column>
62
+            <el-table-column prop="on_duty_at" label="上班" width="80">
63
+              <template #default="{ row }">{{ row.on_duty_at ? formatTime(row.on_duty_at).slice(11, 16) : '-' }}</template>
64
+            </el-table-column>
65
+            <el-table-column prop="off_duty_at" label="下班" width="80">
66
+              <template #default="{ row }">{{ row.off_duty_at ? formatTime(row.off_duty_at).slice(11, 16) : '-' }}</template>
67
+            </el-table-column>
68
+          </el-table>
69
+        </el-card>
70
+      </el-col>
71
+
72
+      <!-- 联系方式面板 -->
73
+      <el-col :span="6">
74
+        <el-card shadow="hover">
75
+          <template #header>
76
+            <span>📞 值班联系方式</span>
77
+          </template>
78
+          <div class="contact-list">
79
+            <div v-for="contact in contacts" :key="contact.userId" class="contact-item">
80
+              <div class="contact-name">{{ contact.name }}</div>
81
+              <div class="contact-info">
82
+                <el-tag size="small">{{ getShiftLabel(contact.shiftType) }}班</el-tag>
83
+                <a :href="`tel:${contact.phone}`" class="phone-link">📱 {{ contact.phone }}</a>
84
+              </div>
85
+              <div v-if="contact.area" class="contact-area">{{ contact.area }}</div>
86
+            </div>
87
+            <el-empty v-if="!contacts.length" description="今日无排班" :image-size="60" />
88
+          </div>
89
+        </el-card>
90
+      </el-col>
91
+    </el-row>
92
+
93
+    <!-- 值班统计 -->
94
+    <el-row :gutter="16" style="margin-top: 16px">
95
+      <el-col :span="6" v-for="stat in statCards" :key="stat.title">
96
+        <el-card shadow="hover">
97
+          <div class="stat-card">
98
+            <div class="stat-value">{{ stat.value }}</div>
99
+            <div class="stat-title">{{ stat.title }}</div>
100
+          </div>
101
+        </el-card>
102
+      </el-col>
103
+    </el-row>
104
+
105
+    <!-- 下班对话框 -->
106
+    <el-dialog v-model="showEndDialog" title="下班打卡" width="400px">
107
+      <el-form label-width="80px">
108
+        <el-form-item label="交接备注">
109
+          <el-input v-model="endForm.handoverRemark" type="textarea" :rows="3"
110
+                    placeholder="请填写交接事项..." />
111
+        </el-form-item>
112
+        <el-form-item label="位置">
113
+          <el-input v-model="endForm.location" placeholder="打卡位置" />
114
+        </el-form-item>
115
+      </el-form>
116
+      <template #footer>
117
+        <el-button @click="showEndDialog = false">取消</el-button>
118
+        <el-button type="primary" @click="handleEndDuty">确认下班</el-button>
119
+      </template>
120
+    </el-dialog>
121
+  </div>
122
+</template>
123
+
124
+<script setup lang="ts">
125
+import { ref, computed, onMounted, onUnmounted } from 'vue'
126
+import { ElMessage } from 'element-plus'
127
+import { getTodayRecords, startDuty, endDuty, getCurrentDutyStatus, getDutyContacts, getDutyStats } from '@/api/duty'
128
+
129
+const currentUserId = 1 // TODO: 从登录状态获取
130
+const currentTime = ref('')
131
+const myStatus = ref<any>({})
132
+const todayRecords = ref<any[]>([])
133
+const contacts = ref<any[]>([])
134
+const actionLoading = ref(false)
135
+const showEndDialog = ref(false)
136
+const endForm = ref({ handoverRemark: '', location: '' })
137
+let timer: number
138
+
139
+const statusLabel = computed(() => {
140
+  const map: any = { scheduled: '待上班', on_duty: '值班中', off_duty: '已下班', no_schedule: '无排班' }
141
+  return map[myStatus.value.status] || '未知'
142
+})
143
+
144
+const statusTagType = computed(() => {
145
+  const map: any = { scheduled: 'info', on_duty: 'success', off_duty: '', no_schedule: 'warning' }
146
+  return map[myStatus.value.status] || 'info'
147
+})
148
+
149
+const statCards = computed(() => {
150
+  const stats = myStatus.value.stats || {}
151
+  return [
152
+    { title: '本月值班天数', value: stats.totalDays || 0 },
153
+    { title: '准时上班', value: stats.onTimeCount || 0 },
154
+    { title: '已完成', value: stats.completedCount || 0 },
155
+    { title: '缺勤', value: stats.absentCount || 0 },
156
+  ]
157
+})
158
+
159
+function getShiftLabel(type: string) {
160
+  return { day: '白班', night: '夜班', full: '全天' }[type] || type
161
+}
162
+
163
+function getShiftType(type: string): '' | 'success' | 'warning' {
164
+  return ({ day: '', night: 'warning', full: 'success' } as any)[type] || ''
165
+}
166
+
167
+function getStatusType(status: string) {
168
+  const map: any = { scheduled: 'info', on_duty: 'success', off_duty: '', absent: 'danger' }
169
+  return map[status] || 'info'
170
+}
171
+
172
+function getStatusLabel(status: string) {
173
+  const map: any = { scheduled: '待上班', on_duty: '值班中', off_duty: '已下班', absent: '缺勤' }
174
+  return map[status] || status
175
+}
176
+
177
+function formatTime(t: string) {
178
+  if (!t) return '-'
179
+  return new Date(t).toLocaleString('zh-CN')
180
+}
181
+
182
+function updateClock() {
183
+  currentTime.value = new Date().toLocaleString('zh-CN')
184
+}
185
+
186
+async function loadMyStatus() {
187
+  try {
188
+    const res = await getCurrentDutyStatus(currentUserId)
189
+    myStatus.value = res.data || {}
190
+    // 加载统计
191
+    const now = new Date()
192
+    const statsRes = await getDutyStats(currentUserId, now.getFullYear(), now.getMonth() + 1)
193
+    myStatus.value.stats = statsRes.data
194
+  } catch (e) { console.error(e) }
195
+}
196
+
197
+async function loadTodayData() {
198
+  try {
199
+    const res = await getTodayRecords()
200
+    todayRecords.value = res.data || []
201
+  } catch (e) { console.error(e) }
202
+}
203
+
204
+async function loadContacts() {
205
+  try {
206
+    const res = await getDutyContacts()
207
+    contacts.value = res.data || []
208
+  } catch (e) { console.error(e) }
209
+}
210
+
211
+async function handleStartDuty() {
212
+  actionLoading.value = true
213
+  try {
214
+    await startDuty({ userId: currentUserId, location: '办公楼' })
215
+    ElMessage.success('上班打卡成功')
216
+    await loadMyStatus()
217
+    await loadTodayData()
218
+  } catch (e) { console.error(e) }
219
+  finally { actionLoading.value = false }
220
+}
221
+
222
+async function handleEndDuty() {
223
+  actionLoading.value = true
224
+  try {
225
+    await endDuty({ userId: currentUserId, ...endForm.value })
226
+    ElMessage.success('下班打卡成功')
227
+    showEndDialog.value = false
228
+    endForm.value = { handoverRemark: '', location: '' }
229
+    await loadMyStatus()
230
+    await loadTodayData()
231
+  } catch (e) { console.error(e) }
232
+  finally { actionLoading.value = false }
233
+}
234
+
235
+onMounted(() => {
236
+  updateClock()
237
+  timer = window.setInterval(updateClock, 1000)
238
+  loadMyStatus()
239
+  loadTodayData()
240
+  loadContacts()
241
+})
242
+
243
+onUnmounted(() => clearInterval(timer))
244
+</script>
245
+
246
+<style scoped>
247
+.duty-panel { padding: 20px; }
248
+.card-header { display: flex; justify-content: space-between; align-items: center; }
249
+.status-info { text-align: center; }
250
+.time-display { margin: 12px 0; }
251
+.time-label { font-size: 12px; color: #909399; }
252
+.time-value { font-size: 24px; font-weight: bold; color: #303133; }
253
+.action-buttons { display: flex; gap: 12px; justify-content: center; margin-top: 20px; }
254
+.contact-list { display: flex; flex-direction: column; gap: 12px; }
255
+.contact-item { padding: 8px; border-bottom: 1px solid #f0f0f0; }
256
+.contact-name { font-weight: bold; font-size: 14px; }
257
+.contact-info { display: flex; gap: 8px; align-items: center; margin-top: 4px; }
258
+.phone-link { color: #409eff; text-decoration: none; font-size: 13px; }
259
+.contact-area { font-size: 12px; color: #909399; margin-top: 2px; }
260
+.stat-card { text-align: center; padding: 8px; }
261
+.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
262
+.stat-title { font-size: 13px; color: #909399; margin-top: 4px; }
263
+</style>

+ 242
- 0
frontend/src/views/duty/HandoverView.vue View File

1
+<template>
2
+  <div class="handover-view">
3
+    <div class="header">
4
+      <h2>交接班记录</h2>
5
+      <div class="actions">
6
+        <el-date-picker v-model="selectedDate" type="date" placeholder="选择日期"
7
+                        value-format="YYYY-MM-DD" @change="loadHandovers" />
8
+        <el-button type="primary" @click="showCreateDialog">新建交接</el-button>
9
+      </div>
10
+    </div>
11
+
12
+    <!-- 交接状态概览 -->
13
+    <el-alert v-if="handoverStatus" :type="handoverStatus.allSigned ? 'success' : 'warning'"
14
+              :closable="false" style="margin-bottom: 16px">
15
+      <template #title>
16
+        {{ selectedDate || '今日' }} 交接状态:共 {{ handoverStatus.totalRecords }} 条记录
17
+        {{ handoverStatus.allSigned ? '✅ 全部签字完成' : '⚠️ 尚有未签字项' }}
18
+      </template>
19
+    </el-alert>
20
+
21
+    <!-- 交接记录列表 -->
22
+    <el-table :data="handoverList" stripe border style="width: 100%">
23
+      <el-table-column prop="from_user_name" label="交班人" width="100" />
24
+      <el-table-column prop="to_user_name" label="接班人" width="100" />
25
+      <el-table-column prop="shift_type" label="班次" width="80">
26
+        <template #default="{ row }">
27
+          <el-tag size="small">{{ getShiftLabel(row.shift_type) }}</el-tag>
28
+        </template>
29
+      </el-table-column>
30
+      <el-table-column prop="handover_time" label="交接时间" width="160">
31
+        <template #default="{ row }">{{ formatTime(row.handover_time) }}</template>
32
+      </el-table-column>
33
+      <el-table-column prop="content" label="交接内容" min-width="200" show-overflow-tooltip />
34
+      <el-table-column label="签字状态" width="140">
35
+        <template #default="{ row }">
36
+          <el-tag size="small" :type="row.from_sign ? 'success' : 'danger'" style="margin-right:4px">
37
+            交班 {{ row.from_sign ? '✓' : '✗' }}
38
+          </el-tag>
39
+          <el-tag size="small" :type="row.to_sign ? 'success' : 'danger'">
40
+            接班 {{ row.to_sign ? '✓' : '✗' }}
41
+          </el-tag>
42
+        </template>
43
+      </el-table-column>
44
+      <el-table-column label="操作" width="180" fixed="right">
45
+        <template #default="{ row }">
46
+          <el-button text type="primary" size="small" @click="viewDetail(row)">查看</el-button>
47
+          <el-button v-if="!row.from_sign" text type="warning" size="small" @click="handleSignFrom(row.id)">交班签字</el-button>
48
+          <el-button v-if="!row.to_sign" text type="success" size="small" @click="handleSignTo(row.id)">接班签字</el-button>
49
+        </template>
50
+      </el-table-column>
51
+    </el-table>
52
+
53
+    <!-- 新建交接对话框 -->
54
+    <el-dialog v-model="createDialogVisible" title="新建交接班记录" width="600px">
55
+      <el-form :model="createForm" label-width="100px">
56
+        <el-form-item label="交班人">
57
+          <el-select v-model="createForm.fromUserId" placeholder="选择交班人">
58
+            <el-option v-for="u in userList" :key="u.id" :label="u.name" :value="u.id" />
59
+          </el-select>
60
+        </el-form-item>
61
+        <el-form-item label="接班人">
62
+          <el-select v-model="createForm.toUserId" placeholder="选择接班人">
63
+            <el-option v-for="u in userList" :key="u.id" :label="u.name" :value="u.id" />
64
+          </el-select>
65
+        </el-form-item>
66
+        <el-form-item label="交接日期">
67
+          <el-date-picker v-model="createForm.dutyDate" type="date" value-format="YYYY-MM-DD" />
68
+        </el-form-item>
69
+        <el-form-item label="班次">
70
+          <el-radio-group v-model="createForm.shiftType">
71
+            <el-radio-button value="day">白班</el-radio-button>
72
+            <el-radio-button value="night">夜班</el-radio-button>
73
+            <el-radio-button value="full">全天</el-radio-button>
74
+          </el-radio-group>
75
+        </el-form-item>
76
+        <el-form-item label="交接内容">
77
+          <el-input v-model="createForm.content" type="textarea" :rows="4" placeholder="交接事项详细说明..." />
78
+        </el-form-item>
79
+        <el-form-item label="异常事项">
80
+          <el-input v-model="createForm.abnormalItems" type="textarea" :rows="2"
81
+                    placeholder='JSON格式,如: [{"item":"XX设备故障","level":"warning"}]' />
82
+        </el-form-item>
83
+        <el-form-item label="待处理事项">
84
+          <el-input v-model="createForm.pendingItems" type="textarea" :rows="2"
85
+                    placeholder='JSON格式,如: [{"item":"待维修阀门","assignee":"张三"}]' />
86
+        </el-form-item>
87
+        <el-form-item label="设备状态">
88
+          <el-input v-model="createForm.equipmentStatus" type="textarea" :rows="2"
89
+                    placeholder='JSON格式,如: {"pump1":"正常","pump2":"故障"}' />
90
+        </el-form-item>
91
+      </el-form>
92
+      <template #footer>
93
+        <el-button @click="createDialogVisible = false">取消</el-button>
94
+        <el-button type="primary" @click="handleCreate">提交</el-button>
95
+      </template>
96
+    </el-dialog>
97
+
98
+    <!-- 详情对话框 -->
99
+    <el-dialog v-model="detailDialogVisible" title="交接班详情" width="600px">
100
+      <el-descriptions v-if="detailData" :column="2" border>
101
+        <el-descriptions-item label="交班人">{{ detailData.fromUserName || detailData.from_user_name }}</el-descriptions-item>
102
+        <el-descriptions-item label="接班人">{{ detailData.toUserName || detailData.to_user_name }}</el-descriptions-item>
103
+        <el-descriptions-item label="交接日期">{{ detailData.dutyDate || detailData.duty_date }}</el-descriptions-item>
104
+        <el-descriptions-item label="班次">{{ getShiftLabel(detailData.shiftType || detailData.shift_type) }}</el-descriptions-item>
105
+        <el-descriptions-item label="交接时间" :span="2">{{ formatTime(detailData.handoverTime || detailData.handover_time) }}</el-descriptions-item>
106
+        <el-descriptions-item label="交接内容" :span="2">{{ detailData.content || '无' }}</el-descriptions-item>
107
+        <el-descriptions-item label="异常事项" :span="2">
108
+          <pre style="white-space:pre-wrap;margin:0">{{ formatJson(detailData.abnormalItems || detailData.abnormal_items) }}</pre>
109
+        </el-descriptions-item>
110
+        <el-descriptions-item label="待处理事项" :span="2">
111
+          <pre style="white-space:pre-wrap;margin:0">{{ formatJson(detailData.pendingItems || detailData.pending_items) }}</pre>
112
+        </el-descriptions-item>
113
+        <el-descriptions-item label="设备状态" :span="2">
114
+          <pre style="white-space:pre-wrap;margin:0">{{ formatJson(detailData.equipmentStatus || detailData.equipment_status) }}</pre>
115
+        </el-descriptions-item>
116
+        <el-descriptions-item label="交班人签字">
117
+          <el-tag :type="(detailData.fromSign || detailData.from_sign) ? 'success' : 'danger'">
118
+            {{ (detailData.fromSign || detailData.from_sign) ? '已签字 ✓' : '未签字' }}
119
+          </el-tag>
120
+        </el-descriptions-item>
121
+        <el-descriptions-item label="接班人签字">
122
+          <el-tag :type="(detailData.toSign || detailData.to_sign) ? 'success' : 'danger'">
123
+            {{ (detailData.toSign || detailData.to_sign) ? '已签字 ✓' : '未签字' }}
124
+          </el-tag>
125
+        </el-descriptions-item>
126
+      </el-descriptions>
127
+    </el-dialog>
128
+  </div>
129
+</template>
130
+
131
+<script setup lang="ts">
132
+import { ref, onMounted } from 'vue'
133
+import { ElMessage, ElMessageBox } from 'element-plus'
134
+import {
135
+  getHandoversByDate, createHandover, getHandoverDetail,
136
+  signFrom, signTo, checkHandoverStatus
137
+} from '@/api/duty'
138
+
139
+const selectedDate = ref(new Date().toISOString().slice(0, 10))
140
+const handoverList = ref<any[]>([])
141
+const handoverStatus = ref<any>(null)
142
+const createDialogVisible = ref(false)
143
+const detailDialogVisible = ref(false)
144
+const detailData = ref<any>(null)
145
+
146
+const userList = ref([
147
+  { id: 1, name: '张三' }, { id: 2, name: '李四' },
148
+  { id: 3, name: '王五' }, { id: 4, name: '赵六' },
149
+])
150
+
151
+const createForm = ref({
152
+  fromUserId: null as number | null,
153
+  toUserId: null as number | null,
154
+  dutyDate: '',
155
+  shiftType: 'day',
156
+  content: '',
157
+  abnormalItems: '',
158
+  pendingItems: '',
159
+  equipmentStatus: ''
160
+})
161
+
162
+function getShiftLabel(type: string) {
163
+  return { day: '白班', night: '夜班', full: '全天' }[type] || type
164
+}
165
+
166
+function formatTime(t: string) {
167
+  return t ? new Date(t).toLocaleString('zh-CN') : '-'
168
+}
169
+
170
+function formatJson(str: string) {
171
+  if (!str) return '无'
172
+  try { return JSON.stringify(JSON.parse(str), null, 2) }
173
+  catch { return str }
174
+}
175
+
176
+async function loadHandovers() {
177
+  try {
178
+    const date = selectedDate.value || new Date().toISOString().slice(0, 10)
179
+    const res = await getHandoversByDate(date)
180
+    handoverList.value = res.data || []
181
+
182
+    const statusRes = await checkHandoverStatus(date)
183
+    handoverStatus.value = statusRes.data
184
+  } catch (e) { console.error(e) }
185
+}
186
+
187
+function showCreateDialog() {
188
+  createForm.value = {
189
+    fromUserId: null, toUserId: null,
190
+    dutyDate: selectedDate.value || new Date().toISOString().slice(0, 10),
191
+    shiftType: 'day', content: '', abnormalItems: '', pendingItems: '', equipmentStatus: ''
192
+  }
193
+  createDialogVisible.value = true
194
+}
195
+
196
+async function handleCreate() {
197
+  if (!createForm.value.fromUserId || !createForm.value.toUserId) {
198
+    ElMessage.warning('请选择交班人和接班人')
199
+    return
200
+  }
201
+  try {
202
+    await createHandover(createForm.value)
203
+    ElMessage.success('交接记录创建成功')
204
+    createDialogVisible.value = false
205
+    loadHandovers()
206
+  } catch (e) { console.error(e) }
207
+}
208
+
209
+async function viewDetail(row: any) {
210
+  try {
211
+    const res = await getHandoverDetail(row.id)
212
+    detailData.value = res.data
213
+    detailDialogVisible.value = true
214
+  } catch (e) { console.error(e) }
215
+}
216
+
217
+async function handleSignFrom(id: number) {
218
+  try {
219
+    await ElMessageBox.confirm('确认以交班人身份签字?', '交班签字')
220
+    await signFrom(id)
221
+    ElMessage.success('交班签字完成')
222
+    loadHandovers()
223
+  } catch {}
224
+}
225
+
226
+async function handleSignTo(id: number) {
227
+  try {
228
+    await ElMessageBox.confirm('确认以接班人身份签字?', '接班签字')
229
+    await signTo(id)
230
+    ElMessage.success('接班签字完成')
231
+    loadHandovers()
232
+  } catch {}
233
+}
234
+
235
+onMounted(() => loadHandovers())
236
+</script>
237
+
238
+<style scoped>
239
+.handover-view { padding: 20px; }
240
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
241
+.actions { display: flex; gap: 8px; align-items: center; }
242
+</style>

+ 264
- 0
frontend/src/views/duty/ScheduleView.vue View File

1
+<template>
2
+  <div class="schedule-view">
3
+    <div class="header">
4
+      <h2>排班管理</h2>
5
+      <div class="actions">
6
+        <el-date-picker v-model="currentDate" type="month" placeholder="选择月份"
7
+                        format="YYYY年MM月" value-format="YYYY-MM" @change="loadSchedule" />
8
+        <el-button type="primary" @click="showCreateDialog">新建排班</el-button>
9
+        <el-button type="success" @click="showAutoDialog">自动排班</el-button>
10
+      </div>
11
+    </div>
12
+
13
+    <!-- 日历视图 -->
14
+    <div class="calendar">
15
+      <div class="calendar-header">
16
+        <el-button text @click="prevMonth">&lt;</el-button>
17
+        <span class="month-title">{{ year }}年{{ month }}月</span>
18
+        <el-button text @click="nextMonth">&gt;</el-button>
19
+      </div>
20
+      <div class="calendar-grid">
21
+        <div class="weekday" v-for="day in weekdays" :key="day">{{ day }}</div>
22
+        <div class="day-cell" v-for="(cell, idx) in calendarCells" :key="idx"
23
+             :class="{ 'other-month': !cell.currentMonth, 'today': cell.isToday }"
24
+             @click="cell.currentMonth && onDayClick(cell.date)">
25
+          <div class="day-number">{{ cell.day }}</div>
26
+          <div class="duty-list">
27
+            <el-tag v-for="person in cell.persons" :key="person.userId"
28
+                    :type="getShiftTagType(person.shiftType)" size="small" class="duty-tag"
29
+                    closable @close="handleCancelSchedule(person.id)">
30
+              {{ person.realName }} {{ getShiftLabel(person.shiftType) }}
31
+            </el-tag>
32
+          </div>
33
+        </div>
34
+      </div>
35
+    </div>
36
+
37
+    <!-- 新建排班对话框 -->
38
+    <el-dialog v-model="createDialogVisible" title="新建排班" width="480px">
39
+      <el-form :model="createForm" label-width="80px">
40
+        <el-form-item label="值班人员">
41
+          <el-select v-model="createForm.userId" placeholder="选择人员" multiple>
42
+            <el-option v-for="u in userList" :key="u.id" :label="u.name" :value="u.id" />
43
+          </el-select>
44
+        </el-form-item>
45
+        <el-form-item label="值班日期">
46
+          <el-date-picker v-model="createForm.dutyDate" type="date" placeholder="选择日期"
47
+                          value-format="YYYY-MM-DD" />
48
+        </el-form-item>
49
+        <el-form-item label="班次">
50
+          <el-radio-group v-model="createForm.shiftType">
51
+            <el-radio-button value="day">白班</el-radio-button>
52
+            <el-radio-button value="night">夜班</el-radio-button>
53
+            <el-radio-button value="full">全天</el-radio-button>
54
+          </el-radio-group>
55
+        </el-form-item>
56
+        <el-form-item label="区域">
57
+          <el-input v-model="createForm.area" placeholder="值班区域" />
58
+        </el-form-item>
59
+        <el-form-item label="备注">
60
+          <el-input v-model="createForm.remark" type="textarea" :rows="2" />
61
+        </el-form-item>
62
+      </el-form>
63
+      <template #footer>
64
+        <el-button @click="createDialogVisible = false">取消</el-button>
65
+        <el-button type="primary" @click="handleCreate">确定</el-button>
66
+      </template>
67
+    </el-dialog>
68
+
69
+    <!-- 自动排班对话框 -->
70
+    <el-dialog v-model="autoDialogVisible" title="自动排班" width="480px">
71
+      <el-form :model="autoForm" label-width="80px">
72
+        <el-form-item label="值班人员">
73
+          <el-select v-model="autoForm.userIds" placeholder="选择人员" multiple>
74
+            <el-option v-for="u in userList" :key="u.id" :label="u.name" :value="u.id" />
75
+          </el-select>
76
+        </el-form-item>
77
+        <el-form-item label="开始日期">
78
+          <el-date-picker v-model="autoForm.startDate" type="date" value-format="YYYY-MM-DD" />
79
+        </el-form-item>
80
+        <el-form-item label="结束日期">
81
+          <el-date-picker v-model="autoForm.endDate" type="date" value-format="YYYY-MM-DD" />
82
+        </el-form-item>
83
+        <el-form-item label="班次">
84
+          <el-radio-group v-model="autoForm.shiftType">
85
+            <el-radio-button value="day">白班</el-radio-button>
86
+            <el-radio-button value="night">夜班</el-radio-button>
87
+            <el-radio-button value="full">全天</el-radio-button>
88
+          </el-radio-group>
89
+        </el-form-item>
90
+      </el-form>
91
+      <template #footer>
92
+        <el-button @click="autoDialogVisible = false">取消</el-button>
93
+        <el-button type="primary" @click="handleAutoSchedule">生成排班</el-button>
94
+      </template>
95
+    </el-dialog>
96
+  </div>
97
+</template>
98
+
99
+<script setup lang="ts">
100
+import { ref, computed, onMounted } from 'vue'
101
+import { ElMessage, ElMessageBox } from 'element-plus'
102
+import { getMonthlySchedule, createSchedule, autoSchedule, cancelSchedule } from '@/api/duty'
103
+
104
+const weekdays = ['日', '一', '二', '三', '四', '五', '六']
105
+const currentDate = ref(new Date().toISOString().slice(0, 7))
106
+const scheduleData = ref<any[]>([])
107
+const createDialogVisible = ref(false)
108
+const autoDialogVisible = ref(false)
109
+
110
+const userList = ref([
111
+  { id: 1, name: '张三' }, { id: 2, name: '李四' },
112
+  { id: 3, name: '王五' }, { id: 4, name: '赵六' },
113
+])
114
+
115
+const createForm = ref({
116
+  userId: [] as number[],
117
+  dutyDate: '',
118
+  shiftType: 'day',
119
+  area: '',
120
+  remark: ''
121
+})
122
+
123
+const autoForm = ref({
124
+  userIds: [] as number[],
125
+  startDate: '',
126
+  endDate: '',
127
+  shiftType: 'day'
128
+})
129
+
130
+const year = computed(() => parseInt(currentDate.value.split('-')[0]))
131
+const month = computed(() => parseInt(currentDate.value.split('-')[1]))
132
+
133
+const calendarCells = computed(() => {
134
+  const firstDay = new Date(year.value, month.value - 1, 1)
135
+  const lastDay = new Date(year.value, month.value, 0)
136
+  const startDay = firstDay.getDay()
137
+  const cells: any[] = []
138
+  const today = new Date().toISOString().slice(0, 10)
139
+
140
+  // 上月填充
141
+  const prevLastDay = new Date(year.value, month.value - 1, 0).getDate()
142
+  for (let i = startDay - 1; i >= 0; i--) {
143
+    cells.push({ day: prevLastDay - i, date: '', currentMonth: false, persons: [] })
144
+  }
145
+
146
+  // 当月
147
+  for (let d = 1; d <= lastDay.getDate(); d++) {
148
+    const dateStr = `${year.value}-${String(month.value).padStart(2, '0')}-${String(d).padStart(2, '0')}`
149
+    const persons = scheduleData.value.filter(s => s.duty_date === dateStr || s.dutyDate === dateStr)
150
+    cells.push({ day: d, date: dateStr, currentMonth: true, isToday: dateStr === today, persons })
151
+  }
152
+
153
+  // 下月填充
154
+  const remaining = 42 - cells.length
155
+  for (let d = 1; d <= remaining; d++) {
156
+    cells.push({ day: d, date: '', currentMonth: false, persons: [] })
157
+  }
158
+
159
+  return cells
160
+})
161
+
162
+function getShiftLabel(type: string) {
163
+  return { day: '白', night: '夜', full: '全' }[type] || type
164
+}
165
+
166
+function getShiftTagType(type: string): '' | 'success' | 'warning' | 'danger' {
167
+  return ({ day: '', night: 'warning', full: 'success' } as any)[type] || ''
168
+}
169
+
170
+async function loadSchedule() {
171
+  try {
172
+    const res = await getMonthlySchedule(year.value, month.value)
173
+    scheduleData.value = res.data || []
174
+  } catch (e) { console.error(e) }
175
+}
176
+
177
+function prevMonth() {
178
+  const d = new Date(year.value, month.value - 2, 1)
179
+  currentDate.value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
180
+  loadSchedule()
181
+}
182
+
183
+function nextMonth() {
184
+  const d = new Date(year.value, month.value, 1)
185
+  currentDate.value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
186
+  loadSchedule()
187
+}
188
+
189
+function onDayClick(date: string) {
190
+  createForm.value.dutyDate = date
191
+  createDialogVisible.value = true
192
+}
193
+
194
+function showCreateDialog() {
195
+  createForm.value = { userId: [], dutyDate: '', shiftType: 'day', area: '', remark: '' }
196
+  createDialogVisible.value = true
197
+}
198
+
199
+function showAutoDialog() {
200
+  autoForm.value = { userIds: [], startDate: '', endDate: '', shiftType: 'day' }
201
+  autoDialogVisible.value = true
202
+}
203
+
204
+async function handleCreate() {
205
+  if (!createForm.value.userId.length || !createForm.value.dutyDate) {
206
+    ElMessage.warning('请选择人员和日期')
207
+    return
208
+  }
209
+  try {
210
+    const schedules = createForm.value.userId.map(uid => ({
211
+      userId: uid,
212
+      dutyDate: createForm.value.dutyDate,
213
+      shiftType: createForm.value.shiftType,
214
+      area: createForm.value.area,
215
+      remark: createForm.value.remark
216
+    }))
217
+    await createSchedule(schedules.length === 1 ? schedules[0] : schedules)
218
+    ElMessage.success('排班创建成功')
219
+    createDialogVisible.value = false
220
+    loadSchedule()
221
+  } catch (e) { console.error(e) }
222
+}
223
+
224
+async function handleAutoSchedule() {
225
+  if (!autoForm.value.userIds.length || !autoForm.value.startDate || !autoForm.value.endDate) {
226
+    ElMessage.warning('请填写完整信息')
227
+    return
228
+  }
229
+  try {
230
+    await autoSchedule(autoForm.value)
231
+    ElMessage.success('自动排班完成')
232
+    autoDialogVisible.value = false
233
+    loadSchedule()
234
+  } catch (e) { console.error(e) }
235
+}
236
+
237
+async function handleCancelSchedule(id: number) {
238
+  try {
239
+    await ElMessageBox.confirm('确定取消该排班?', '提示', { type: 'warning' })
240
+    await cancelSchedule(id)
241
+    ElMessage.success('已取消')
242
+    loadSchedule()
243
+  } catch {}
244
+}
245
+
246
+onMounted(() => loadSchedule())
247
+</script>
248
+
249
+<style scoped>
250
+.schedule-view { padding: 20px; }
251
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
252
+.actions { display: flex; gap: 8px; align-items: center; }
253
+.calendar-header { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; }
254
+.month-title { font-size: 18px; font-weight: bold; }
255
+.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: #e4e7ed; border-radius: 4px; overflow: hidden; }
256
+.weekday { background: #f5f7fa; text-align: center; padding: 8px; font-weight: bold; font-size: 14px; }
257
+.day-cell { background: #fff; min-height: 100px; padding: 4px; cursor: pointer; transition: background 0.2s; }
258
+.day-cell:hover { background: #f0f9ff; }
259
+.day-cell.other-month { background: #fafafa; color: #c0c4cc; }
260
+.day-cell.today { background: #ecf5ff; }
261
+.day-number { font-size: 14px; margin-bottom: 4px; }
262
+.duty-list { display: flex; flex-direction: column; gap: 2px; }
263
+.duty-tag { font-size: 12px; }
264
+</style>

+ 280
- 0
wm-production/src/main/java/com/water/production/controller/DutyController.java View File

1
+package com.water.production.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.production.entity.DutyLog;
5
+import com.water.production.entity.DutySchedule;
6
+import com.water.production.entity.HandoverRecord;
7
+import com.water.production.service.*;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.format.annotation.DateTimeFormat;
12
+import org.springframework.web.bind.annotation.*;
13
+
14
+import java.time.LocalDate;
15
+import java.util.*;
16
+
17
+@Tag(name = "值班管理")
18
+@RestController
19
+@RequestMapping("/api/production/duty")
20
+@RequiredArgsConstructor
21
+public class DutyController {
22
+
23
+    private final DutyScheduleService scheduleService;
24
+    private final DutyRecordService recordService;
25
+    private final HandoverService handoverService;
26
+    private final DutyLogService logService;
27
+
28
+    // ==================== 排班管理 ====================
29
+
30
+    @Operation(summary = "获取月度排班表")
31
+    @GetMapping("/schedule/monthly")
32
+    public R<List<Map<String, Object>>> getMonthlySchedule(
33
+            @RequestParam int year, @RequestParam int month) {
34
+        return R.ok(scheduleService.getMonthlySchedule(year, month));
35
+    }
36
+
37
+    @Operation(summary = "获取日期范围排班")
38
+    @GetMapping("/schedule/range")
39
+    public R<List<Map<String, Object>>> getScheduleRange(
40
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
41
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end) {
42
+        return R.ok(scheduleService.getScheduleRange(start, end));
43
+    }
44
+
45
+    @Operation(summary = "获取今日排班")
46
+    @GetMapping("/schedule/today")
47
+    public R<List<Map<String, Object>>> getTodaySchedule() {
48
+        return R.ok(scheduleService.getTodaySchedule());
49
+    }
50
+
51
+    @Operation(summary = "创建排班")
52
+    @PostMapping("/schedule")
53
+    public R<DutySchedule> createSchedule(@RequestBody DutySchedule schedule) {
54
+        return R.ok(scheduleService.createSchedule(schedule));
55
+    }
56
+
57
+    @Operation(summary = "批量创建排班")
58
+    @PostMapping("/schedule/batch")
59
+    public R<List<DutySchedule>> batchCreateSchedule(@RequestBody List<DutySchedule> schedules) {
60
+        return R.ok(scheduleService.batchCreateSchedule(schedules));
61
+    }
62
+
63
+    @Operation(summary = "自动排班")
64
+    @PostMapping("/schedule/auto")
65
+    public R<List<DutySchedule>> autoSchedule(@RequestBody Map<String, Object> req) {
66
+        @SuppressWarnings("unchecked")
67
+        List<Long> userIds = ((List<Number>) req.get("userIds")).stream()
68
+                .map(Number::longValue).toList();
69
+        LocalDate startDate = LocalDate.parse((String) req.get("startDate"));
70
+        LocalDate endDate = LocalDate.parse((String) req.get("endDate"));
71
+        String shiftType = (String) req.getOrDefault("shiftType", "day");
72
+        return R.ok(scheduleService.autoSchedule(userIds, startDate, endDate, shiftType));
73
+    }
74
+
75
+    @Operation(summary = "更新排班")
76
+    @PutMapping("/schedule/{id}")
77
+    public R<DutySchedule> updateSchedule(@PathVariable Long id, @RequestBody DutySchedule schedule) {
78
+        schedule.setId(id);
79
+        return R.ok(scheduleService.updateSchedule(schedule));
80
+    }
81
+
82
+    @Operation(summary = "取消排班")
83
+    @DeleteMapping("/schedule/{id}")
84
+    public R<String> cancelSchedule(@PathVariable Long id) {
85
+        scheduleService.cancelSchedule(id);
86
+        return R.ok("排班已取消");
87
+    }
88
+
89
+    // ==================== 上下班打卡 ====================
90
+
91
+    @Operation(summary = "获取今日值班记录")
92
+    @GetMapping("/record/today")
93
+    public R<List<Map<String, Object>>> getTodayRecords() {
94
+        return R.ok(recordService.getTodayRecords());
95
+    }
96
+
97
+    @Operation(summary = "获取指定日期值班记录")
98
+    @GetMapping("/record/date/{date}")
99
+    public R<List<Map<String, Object>>> getRecordsByDate(
100
+            @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
101
+        return R.ok(recordService.getRecordsByDate(date));
102
+    }
103
+
104
+    @Operation(summary = "获取用户值班记录")
105
+    @GetMapping("/record/user/{userId}")
106
+    public R<List<Map<String, Object>>> getUserRecords(
107
+            @PathVariable Long userId,
108
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
109
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end) {
110
+        return R.ok(recordService.getUserRecords(userId, start, end));
111
+    }
112
+
113
+    @Operation(summary = "上班打卡")
114
+    @PostMapping("/record/start")
115
+    public R<Map<String, Object>> startDuty(@RequestBody Map<String, Object> req) {
116
+        Long userId = ((Number) req.get("userId")).longValue();
117
+        String location = (String) req.getOrDefault("location", "");
118
+        return R.ok(recordService.startDuty(userId, location));
119
+    }
120
+
121
+    @Operation(summary = "下班打卡")
122
+    @PostMapping("/record/end")
123
+    public R<Map<String, Object>> endDuty(@RequestBody Map<String, Object> req) {
124
+        Long userId = ((Number) req.get("userId")).longValue();
125
+        String location = (String) req.getOrDefault("location", "");
126
+        String remark = (String) req.getOrDefault("handoverRemark", "");
127
+        return R.ok(recordService.endDuty(userId, location, remark));
128
+    }
129
+
130
+    @Operation(summary = "获取当前值班状态")
131
+    @GetMapping("/record/status/{userId}")
132
+    public R<Map<String, Object>> getCurrentStatus(@PathVariable Long userId) {
133
+        return R.ok(recordService.getCurrentDutyStatus(userId));
134
+    }
135
+
136
+    @Operation(summary = "获取值班统计")
137
+    @GetMapping("/record/stats/{userId}")
138
+    public R<Map<String, Object>> getDutyStats(
139
+            @PathVariable Long userId,
140
+            @RequestParam int year, @RequestParam int month) {
141
+        return R.ok(recordService.getDutyStats(userId, year, month));
142
+    }
143
+
144
+    // ==================== 交接班管理 ====================
145
+
146
+    @Operation(summary = "创建交接班记录")
147
+    @PostMapping("/handover")
148
+    public R<HandoverRecord> createHandover(@RequestBody HandoverRecord record) {
149
+        return R.ok(handoverService.createHandover(record));
150
+    }
151
+
152
+    @Operation(summary = "获取指定日期交接班记录")
153
+    @GetMapping("/handover/date/{date}")
154
+    public R<List<Map<String, Object>>> getHandoversByDate(
155
+            @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
156
+        return R.ok(handoverService.getByDate(date));
157
+    }
158
+
159
+    @Operation(summary = "获取日期范围交接班记录")
160
+    @GetMapping("/handover/range")
161
+    public R<List<Map<String, Object>>> getHandoversByRange(
162
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
163
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end) {
164
+        return R.ok(handoverService.getByDateRange(start, end));
165
+    }
166
+
167
+    @Operation(summary = "交接班详情")
168
+    @GetMapping("/handover/{id}")
169
+    public R<HandoverRecord> getHandoverDetail(@PathVariable Long id) {
170
+        return R.ok(handoverService.getDetail(id));
171
+    }
172
+
173
+    @Operation(summary = "更新交接班内容")
174
+    @PutMapping("/handover/{id}")
175
+    public R<HandoverRecord> updateHandover(@PathVariable Long id, @RequestBody HandoverRecord record) {
176
+        record.setId(id);
177
+        return R.ok(handoverService.updateHandover(record));
178
+    }
179
+
180
+    @Operation(summary = "交班人签字")
181
+    @PostMapping("/handover/{id}/sign-from")
182
+    public R<String> signFrom(@PathVariable Long id) {
183
+        handoverService.signFrom(id);
184
+        return R.ok("交班人已签字");
185
+    }
186
+
187
+    @Operation(summary = "接班人签字")
188
+    @PostMapping("/handover/{id}/sign-to")
189
+    public R<String> signTo(@PathVariable Long id) {
190
+        handoverService.signTo(id);
191
+        return R.ok("接班人已签字");
192
+    }
193
+
194
+    @Operation(summary = "检查交接状态")
195
+    @GetMapping("/handover/status/{date}")
196
+    public R<Map<String, Object>> checkHandoverStatus(
197
+            @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
198
+        return R.ok(handoverService.checkHandoverStatus(date));
199
+    }
200
+
201
+    // ==================== 值班日志 ====================
202
+
203
+    @Operation(summary = "创建值班日志")
204
+    @PostMapping("/log")
205
+    public R<DutyLog> createLog(@RequestBody DutyLog dutyLog) {
206
+        return R.ok(logService.createLog(dutyLog));
207
+    }
208
+
209
+    @Operation(summary = "获取指定日期日志(时间线)")
210
+    @GetMapping("/log/date/{date}")
211
+    public R<List<Map<String, Object>>> getLogsByDate(
212
+            @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
213
+        return R.ok(logService.getByDate(date));
214
+    }
215
+
216
+    @Operation(summary = "获取日期范围日志")
217
+    @GetMapping("/log/range")
218
+    public R<List<Map<String, Object>>> getLogsByRange(
219
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
220
+            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end) {
221
+        return R.ok(logService.getByDateRange(start, end));
222
+    }
223
+
224
+    @Operation(summary = "日志详情")
225
+    @GetMapping("/log/{id}")
226
+    public R<DutyLog> getLogDetail(@PathVariable Long id) {
227
+        return R.ok(logService.getDetail(id));
228
+    }
229
+
230
+    @Operation(summary = "更新日志")
231
+    @PutMapping("/log/{id}")
232
+    public R<DutyLog> updateLog(@PathVariable Long id, @RequestBody DutyLog dutyLog) {
233
+        dutyLog.setId(id);
234
+        return R.ok(logService.updateLog(dutyLog));
235
+    }
236
+
237
+    @Operation(summary = "删除日志")
238
+    @DeleteMapping("/log/{id}")
239
+    public R<String> deleteLog(@PathVariable Long id) {
240
+        logService.deleteLog(id);
241
+        return R.ok("日志已删除");
242
+    }
243
+
244
+    @Operation(summary = "处理日志事项")
245
+    @PostMapping("/log/{id}/handle")
246
+    public R<String> handleLog(@PathVariable Long id, @RequestBody Map<String, Object> req) {
247
+        Long handlerId = ((Number) req.get("handlerId")).longValue();
248
+        String result = (String) req.getOrDefault("result", "");
249
+        logService.handleLog(id, handlerId, result);
250
+        return R.ok("已处理");
251
+    }
252
+
253
+    @Operation(summary = "日志统计")
254
+    @GetMapping("/log/stats/{date}")
255
+    public R<Map<String, Object>> getLogStats(
256
+            @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
257
+        return R.ok(logService.getLogStats(date));
258
+    }
259
+
260
+    // ==================== 联系方式面板 ====================
261
+
262
+    @Operation(summary = "获取值班联系方式面板")
263
+    @GetMapping("/contacts")
264
+    public R<List<Map<String, Object>>> getContacts() {
265
+        // 获取今日值班人员及联系方式
266
+        List<Map<String, Object>> todaySchedule = scheduleService.getTodaySchedule();
267
+        List<Map<String, Object>> contacts = new ArrayList<>();
268
+        for (Map<String, Object> s : todaySchedule) {
269
+            Map<String, Object> contact = new LinkedHashMap<>();
270
+            contact.put("userId", s.get("user_id"));
271
+            contact.put("name", s.get("real_name"));
272
+            contact.put("phone", s.get("phone"));
273
+            contact.put("shiftType", s.get("shift_type"));
274
+            contact.put("area", s.get("area"));
275
+            contact.put("status", s.get("status"));
276
+            contacts.add(contact);
277
+        }
278
+        return R.ok(contacts);
279
+    }
280
+}

+ 28
- 0
wm-production/src/main/java/com/water/production/entity/DutyLog.java View File

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDate;
6
+import java.time.LocalDateTime;
7
+
8
+@Data
9
+@TableName("duty_log")
10
+public class DutyLog {
11
+    @TableId(type = IdType.AUTO)
12
+    private Long id;
13
+    private Long userId;
14
+    private LocalDate dutyDate;
15
+    private LocalDateTime logTime;
16
+    private String logType; // patrol/inspection/event/maintenance/other
17
+    private String title;
18
+    private String content;
19
+    private String location;
20
+    private String images; // JSON
21
+    private String severity; // normal/warning/critical
22
+    private Boolean handled;
23
+    private Long handlerId;
24
+    private LocalDateTime handleTime;
25
+    private String handleResult;
26
+    @TableField(fill = FieldFill.INSERT)
27
+    private LocalDateTime createdTime;
28
+}

+ 27
- 0
wm-production/src/main/java/com/water/production/entity/DutyRecord.java View File

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDate;
6
+import java.time.LocalDateTime;
7
+
8
+@Data
9
+@TableName("duty_record")
10
+public class DutyRecord {
11
+    @TableId(type = IdType.AUTO)
12
+    private Long id;
13
+    private Long scheduleId;
14
+    private Long userId;
15
+    private LocalDate dutyDate;
16
+    private String shiftType;
17
+    private String status; // scheduled/on_duty/off_duty/absent
18
+    private LocalDateTime onDutyAt;
19
+    private LocalDateTime offDutyAt;
20
+    private String onDutyLocation;
21
+    private String offDutyLocation;
22
+    private String handoverRemark;
23
+    @TableField(fill = FieldFill.INSERT)
24
+    private LocalDateTime createdTime;
25
+    @TableField(fill = FieldFill.INSERT_UPDATE)
26
+    private LocalDateTime updatedTime;
27
+}

+ 24
- 0
wm-production/src/main/java/com/water/production/entity/DutySchedule.java View File

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDate;
6
+import java.time.LocalDateTime;
7
+
8
+@Data
9
+@TableName("duty_schedule")
10
+public class DutySchedule {
11
+    @TableId(type = IdType.AUTO)
12
+    private Long id;
13
+    private Long userId;
14
+    private LocalDate dutyDate;
15
+    private String shiftType; // day/night/full
16
+    private String area;
17
+    private Integer status; // 0=已取消 1=已排班 2=已完成
18
+    private String remark;
19
+    private Long createdBy;
20
+    @TableField(fill = FieldFill.INSERT)
21
+    private LocalDateTime createdTime;
22
+    @TableField(fill = FieldFill.INSERT_UPDATE)
23
+    private LocalDateTime updatedTime;
24
+}

+ 26
- 0
wm-production/src/main/java/com/water/production/entity/HandoverRecord.java View File

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDate;
6
+import java.time.LocalDateTime;
7
+
8
+@Data
9
+@TableName("handover_record")
10
+public class HandoverRecord {
11
+    @TableId(type = IdType.AUTO)
12
+    private Long id;
13
+    private Long fromUserId;
14
+    private Long toUserId;
15
+    private LocalDate dutyDate;
16
+    private String shiftType;
17
+    private LocalDateTime handoverTime;
18
+    private String content;
19
+    private String abnormalItems;  // JSON
20
+    private String pendingItems;   // JSON
21
+    private String equipmentStatus; // JSON
22
+    private Boolean fromSign;
23
+    private Boolean toSign;
24
+    @TableField(fill = FieldFill.INSERT)
25
+    private LocalDateTime createdTime;
26
+}

+ 31
- 0
wm-production/src/main/java/com/water/production/mapper/DutyLogMapper.java View File

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DutyLog;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.time.LocalDate;
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+@Mapper
15
+public interface DutyLogMapper extends BaseMapper<DutyLog> {
16
+
17
+    @Select("SELECT dl.*, u.real_name FROM duty_log dl " +
18
+            "LEFT JOIN sys_user u ON dl.user_id = u.id " +
19
+            "WHERE dl.duty_date = #{date} ORDER BY dl.log_time DESC")
20
+    List<Map<String, Object>> selectByDate(@Param("date") LocalDate date);
21
+
22
+    @Select("SELECT dl.*, u.real_name FROM duty_log dl " +
23
+            "LEFT JOIN sys_user u ON dl.user_id = u.id " +
24
+            "WHERE dl.duty_date BETWEEN #{start} AND #{end} " +
25
+            "ORDER BY dl.log_time DESC")
26
+    List<Map<String, Object>> selectByDateRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
27
+
28
+    @Update("UPDATE duty_log SET handled = true, handler_id = #{handlerId}, " +
29
+            "handle_time = NOW(), handle_result = #{result} WHERE id = #{id}")
30
+    int markHandled(@Param("id") Long id, @Param("handlerId") Long handlerId, @Param("result") String result);
31
+}

+ 30
- 0
wm-production/src/main/java/com/water/production/mapper/DutyRecordMapper.java View File

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DutyRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.time.LocalDate;
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Mapper
14
+public interface DutyRecordMapper extends BaseMapper<DutyRecord> {
15
+
16
+    @Select("SELECT dr.*, u.real_name, u.phone, ds.shift_type, ds.area " +
17
+            "FROM duty_record dr " +
18
+            "LEFT JOIN sys_user u ON dr.user_id = u.id " +
19
+            "LEFT JOIN duty_schedule ds ON dr.schedule_id = ds.id " +
20
+            "WHERE dr.duty_date = #{date} ORDER BY dr.on_duty_at")
21
+    List<Map<String, Object>> selectByDate(@Param("date") LocalDate date);
22
+
23
+    @Select("SELECT dr.*, u.real_name, u.phone FROM duty_record dr " +
24
+            "LEFT JOIN sys_user u ON dr.user_id = u.id " +
25
+            "WHERE dr.user_id = #{userId} AND dr.duty_date BETWEEN #{start} AND #{end} " +
26
+            "ORDER BY dr.duty_date DESC")
27
+    List<Map<String, Object>> selectUserRecords(@Param("userId") Long userId,
28
+                                                 @Param("start") LocalDate start,
29
+                                                 @Param("end") LocalDate end);
30
+}

+ 26
- 0
wm-production/src/main/java/com/water/production/mapper/DutyScheduleMapper.java View File

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DutySchedule;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.time.LocalDate;
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Mapper
14
+public interface DutyScheduleMapper extends BaseMapper<DutySchedule> {
15
+
16
+    @Select("SELECT ds.*, u.real_name, u.phone FROM duty_schedule ds " +
17
+            "LEFT JOIN sys_user u ON ds.user_id = u.id " +
18
+            "WHERE ds.duty_date BETWEEN #{start} AND #{end} " +
19
+            "ORDER BY ds.duty_date, ds.shift_type")
20
+    List<Map<String, Object>> selectScheduleRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
21
+
22
+    @Select("SELECT ds.*, u.real_name, u.phone FROM duty_schedule ds " +
23
+            "LEFT JOIN sys_user u ON ds.user_id = u.id " +
24
+            "WHERE ds.duty_date = #{date} AND ds.status = 1")
25
+    List<Map<String, Object>> selectTodaySchedule(@Param("date") LocalDate date);
26
+}

+ 34
- 0
wm-production/src/main/java/com/water/production/mapper/HandoverRecordMapper.java View File

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.HandoverRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.time.LocalDate;
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Mapper
14
+public interface HandoverRecordMapper extends BaseMapper<HandoverRecord> {
15
+
16
+    @Select("SELECT hr.*, " +
17
+            "f.real_name AS from_user_name, f.phone AS from_user_phone, " +
18
+            "t.real_name AS to_user_name, t.phone AS to_user_phone " +
19
+            "FROM handover_record hr " +
20
+            "LEFT JOIN sys_user f ON hr.from_user_id = f.id " +
21
+            "LEFT JOIN sys_user t ON hr.to_user_id = t.id " +
22
+            "WHERE hr.duty_date = #{date} ORDER BY hr.handover_time DESC")
23
+    List<Map<String, Object>> selectByDate(@Param("date") LocalDate date);
24
+
25
+    @Select("SELECT hr.*, " +
26
+            "f.real_name AS from_user_name, " +
27
+            "t.real_name AS to_user_name " +
28
+            "FROM handover_record hr " +
29
+            "LEFT JOIN sys_user f ON hr.from_user_id = f.id " +
30
+            "LEFT JOIN sys_user t ON hr.to_user_id = t.id " +
31
+            "WHERE hr.duty_date BETWEEN #{start} AND #{end} " +
32
+            "ORDER BY hr.handover_time DESC")
33
+    List<Map<String, Object>> selectByDateRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
34
+}

+ 125
- 0
wm-production/src/main/java/com/water/production/service/DutyLogService.java View File

1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.DutyLog;
4
+import com.water.production.mapper.DutyLogMapper;
5
+import lombok.RequiredArgsConstructor;
6
+import lombok.extern.slf4j.Slf4j;
7
+import org.springframework.stereotype.Service;
8
+import org.springframework.transaction.annotation.Transactional;
9
+
10
+import java.time.LocalDate;
11
+import java.time.LocalDateTime;
12
+import java.util.*;
13
+
14
+@Slf4j
15
+@Service
16
+@RequiredArgsConstructor
17
+public class DutyLogService {
18
+
19
+    private final DutyLogMapper dutyLogMapper;
20
+
21
+    /**
22
+     * 创建值班日志
23
+     */
24
+    @Transactional
25
+    public DutyLog createLog(DutyLog dutyLog) {
26
+        if (dutyLog.getLogTime() == null) {
27
+            dutyLog.setLogTime(LocalDateTime.now());
28
+        }
29
+        if (dutyLog.getDutyDate() == null) {
30
+            dutyLog.setDutyDate(LocalDate.now());
31
+        }
32
+        dutyLog.setHandled(false);
33
+        dutyLog.setCreatedTime(null);
34
+        dutyLogMapper.insert(dutyLog);
35
+        log.info("Duty log created: user={} type={} title={}",
36
+                dutyLog.getUserId(), dutyLog.getLogType(), dutyLog.getTitle());
37
+        return dutyLog;
38
+    }
39
+
40
+    /**
41
+     * 获取指定日期的值班日志(时间线)
42
+     */
43
+    public List<Map<String, Object>> getByDate(LocalDate date) {
44
+        return dutyLogMapper.selectByDate(date);
45
+    }
46
+
47
+    /**
48
+     * 获取日期范围的值班日志
49
+     */
50
+    public List<Map<String, Object>> getByDateRange(LocalDate start, LocalDate end) {
51
+        return dutyLogMapper.selectByDateRange(start, end);
52
+    }
53
+
54
+    /**
55
+     * 获取日志详情
56
+     */
57
+    public DutyLog getDetail(Long id) {
58
+        DutyLog dutyLog = dutyLogMapper.selectById(id);
59
+        if (dutyLog == null) {
60
+            throw new IllegalArgumentException("值班日志不存在");
61
+        }
62
+        return dutyLog;
63
+    }
64
+
65
+    /**
66
+     * 处理日志事项
67
+     */
68
+    @Transactional
69
+    public void handleLog(Long id, Long handlerId, String result) {
70
+        int rows = dutyLogMapper.markHandled(id, handlerId, result);
71
+        if (rows == 0) {
72
+            throw new IllegalArgumentException("值班日志不存在");
73
+        }
74
+        log.info("Duty log handled: id={} handler={}", id, handlerId);
75
+    }
76
+
77
+    /**
78
+     * 更新日志
79
+     */
80
+    @Transactional
81
+    public DutyLog updateLog(DutyLog update) {
82
+        DutyLog existing = dutyLogMapper.selectById(update.getId());
83
+        if (existing == null) {
84
+            throw new IllegalArgumentException("值班日志不存在");
85
+        }
86
+        if (update.getTitle() != null) existing.setTitle(update.getTitle());
87
+        if (update.getContent() != null) existing.setContent(update.getContent());
88
+        if (update.getLogType() != null) existing.setLogType(update.getLogType());
89
+        if (update.getLocation() != null) existing.setLocation(update.getLocation());
90
+        if (update.getImages() != null) existing.setImages(update.getImages());
91
+        if (update.getSeverity() != null) existing.setSeverity(update.getSeverity());
92
+        dutyLogMapper.updateById(existing);
93
+        return existing;
94
+    }
95
+
96
+    /**
97
+     * 删除日志
98
+     */
99
+    @Transactional
100
+    public void deleteLog(Long id) {
101
+        dutyLogMapper.deleteById(id);
102
+        log.info("Duty log deleted: id={}", id);
103
+    }
104
+
105
+    /**
106
+     * 获取日志统计
107
+     */
108
+    public Map<String, Object> getLogStats(LocalDate date) {
109
+        List<Map<String, Object>> logs = dutyLogMapper.selectByDate(date);
110
+        Map<String, Object> stats = new LinkedHashMap<>();
111
+        stats.put("total", logs.size());
112
+        stats.put("byType", logs.stream()
113
+                .collect(java.util.stream.Collectors.groupingBy(
114
+                        l -> String.valueOf(l.getOrDefault("log_type", "other")),
115
+                        java.util.stream.Collectors.counting())));
116
+        stats.put("bySeverity", logs.stream()
117
+                .collect(java.util.stream.Collectors.groupingBy(
118
+                        l -> String.valueOf(l.getOrDefault("severity", "normal")),
119
+                        java.util.stream.Collectors.counting())));
120
+        stats.put("unhandled", logs.stream()
121
+                .filter(l -> !Boolean.TRUE.equals(l.get("handled")))
122
+                .count());
123
+        return stats;
124
+    }
125
+}

+ 179
- 0
wm-production/src/main/java/com/water/production/service/DutyRecordService.java View File

1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.DutyRecord;
5
+import com.water.production.entity.DutySchedule;
6
+import com.water.production.mapper.DutyRecordMapper;
7
+import com.water.production.mapper.DutyScheduleMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+
13
+import java.time.LocalDate;
14
+import java.time.LocalDateTime;
15
+import java.util.*;
16
+
17
+@Slf4j
18
+@Service
19
+@RequiredArgsConstructor
20
+public class DutyRecordService {
21
+
22
+    private final DutyRecordMapper recordMapper;
23
+    private final DutyScheduleMapper scheduleMapper;
24
+
25
+    /**
26
+     * 获取今日值班列表
27
+     */
28
+    public List<Map<String, Object>> getTodayRecords() {
29
+        return recordMapper.selectByDate(LocalDate.now());
30
+    }
31
+
32
+    /**
33
+     * 获取指定日期的值班记录
34
+     */
35
+    public List<Map<String, Object>> getRecordsByDate(LocalDate date) {
36
+        return recordMapper.selectByDate(date);
37
+    }
38
+
39
+    /**
40
+     * 获取用户指定日期范围的值班记录
41
+     */
42
+    public List<Map<String, Object>> getUserRecords(Long userId, LocalDate start, LocalDate end) {
43
+        return recordMapper.selectUserRecords(userId, start, end);
44
+    }
45
+
46
+    /**
47
+     * 上班打卡
48
+     */
49
+    @Transactional
50
+    public Map<String, Object> startDuty(Long userId, String location) {
51
+        LocalDate today = LocalDate.now();
52
+
53
+        // 查找今日排班
54
+        LambdaQueryWrapper<DutyRecord> wrapper = new LambdaQueryWrapper<>();
55
+        wrapper.eq(DutyRecord::getUserId, userId)
56
+               .eq(DutyRecord::getDutyDate, today)
57
+               .eq(DutyRecord::getStatus, "scheduled");
58
+        DutyRecord record = recordMapper.selectOne(wrapper);
59
+
60
+        if (record == null) {
61
+            // 没有排班也允许打卡(临时值班)
62
+            record = new DutyRecord();
63
+            record.setUserId(userId);
64
+            record.setDutyDate(today);
65
+            record.setShiftType("day");
66
+            record.setStatus("on_duty");
67
+            record.setOnDutyAt(LocalDateTime.now());
68
+            record.setOnDutyLocation(location);
69
+            record.setCreatedTime(null);
70
+            recordMapper.insert(record);
71
+        } else {
72
+            record.setStatus("on_duty");
73
+            record.setOnDutyAt(LocalDateTime.now());
74
+            record.setOnDutyLocation(location);
75
+            recordMapper.updateById(record);
76
+        }
77
+
78
+        log.info("User {} started duty at {}", userId, record.getOnDutyAt());
79
+        Map<String, Object> result = new LinkedHashMap<>();
80
+        result.put("recordId", record.getId());
81
+        result.put("status", "on_duty");
82
+        result.put("onDutyAt", record.getOnDutyAt());
83
+        result.put("message", "上班打卡成功");
84
+        return result;
85
+    }
86
+
87
+    /**
88
+     * 下班打卡
89
+     */
90
+    @Transactional
91
+    public Map<String, Object> endDuty(Long userId, String location, String handoverRemark) {
92
+        LocalDate today = LocalDate.now();
93
+
94
+        LambdaQueryWrapper<DutyRecord> wrapper = new LambdaQueryWrapper<>();
95
+        wrapper.eq(DutyRecord::getUserId, userId)
96
+               .eq(DutyRecord::getDutyDate, today)
97
+               .eq(DutyRecord::getStatus, "on_duty");
98
+        DutyRecord record = recordMapper.selectOne(wrapper);
99
+
100
+        if (record == null) {
101
+            throw new IllegalStateException("当前无上班记录,无法下班打卡");
102
+        }
103
+
104
+        record.setStatus("off_duty");
105
+        record.setOffDutyAt(LocalDateTime.now());
106
+        record.setOffDutyLocation(location);
107
+        record.setHandoverRemark(handoverRemark);
108
+        recordMapper.updateById(record);
109
+
110
+        // 更新排班状态为已完成
111
+        if (record.getScheduleId() != null) {
112
+            DutySchedule schedule = scheduleMapper.selectById(record.getScheduleId());
113
+            if (schedule != null) {
114
+                schedule.setStatus(2);
115
+                scheduleMapper.updateById(schedule);
116
+            }
117
+        }
118
+
119
+        log.info("User {} ended duty at {}", userId, record.getOffDutyAt());
120
+        Map<String, Object> result = new LinkedHashMap<>();
121
+        result.put("recordId", record.getId());
122
+        result.put("status", "off_duty");
123
+        result.put("offDutyAt", record.getOffDutyAt());
124
+        result.put("workDurationMinutes",
125
+                java.time.Duration.between(record.getOnDutyAt(), record.getOffDutyAt()).toMinutes());
126
+        result.put("message", "下班打卡成功");
127
+        return result;
128
+    }
129
+
130
+    /**
131
+     * 获取值班统计
132
+     */
133
+    public Map<String, Object> getDutyStats(Long userId, int year, int month) {
134
+        LocalDate start = LocalDate.of(year, month, 1);
135
+        LocalDate end = start.plusMonths(1).minusDays(1);
136
+
137
+        List<Map<String, Object>> records = recordMapper.selectUserRecords(userId, start, end);
138
+
139
+        long totalDays = records.size();
140
+        long onTimeCount = records.stream()
141
+                .filter(r -> r.get("on_duty_at") != null)
142
+                .count();
143
+        long completedCount = records.stream()
144
+                .filter(r -> "off_duty".equals(r.get("status")))
145
+                .count();
146
+
147
+        Map<String, Object> stats = new LinkedHashMap<>();
148
+        stats.put("totalDays", totalDays);
149
+        stats.put("onTimeCount", onTimeCount);
150
+        stats.put("completedCount", completedCount);
151
+        stats.put("absentCount", totalDays - onTimeCount);
152
+        stats.put("month", year + "-" + String.format("%02d", month));
153
+        return stats;
154
+    }
155
+
156
+    /**
157
+     * 获取当前值班状态
158
+     */
159
+    public Map<String, Object> getCurrentDutyStatus(Long userId) {
160
+        LocalDate today = LocalDate.now();
161
+        LambdaQueryWrapper<DutyRecord> wrapper = new LambdaQueryWrapper<>();
162
+        wrapper.eq(DutyRecord::getUserId, userId)
163
+               .eq(DutyRecord::getDutyDate, today);
164
+        DutyRecord record = recordMapper.selectOne(wrapper);
165
+
166
+        Map<String, Object> result = new LinkedHashMap<>();
167
+        if (record == null) {
168
+            result.put("status", "no_schedule");
169
+            result.put("message", "今日无排班");
170
+        } else {
171
+            result.put("status", record.getStatus());
172
+            result.put("recordId", record.getId());
173
+            result.put("onDutyAt", record.getOnDutyAt());
174
+            result.put("offDutyAt", record.getOffDutyAt());
175
+            result.put("shiftType", record.getShiftType());
176
+        }
177
+        return result;
178
+    }
179
+}

+ 152
- 0
wm-production/src/main/java/com/water/production/service/DutyScheduleService.java View File

1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.DutyRecord;
5
+import com.water.production.entity.DutySchedule;
6
+import com.water.production.mapper.DutyRecordMapper;
7
+import com.water.production.mapper.DutyScheduleMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+
13
+import java.time.LocalDate;
14
+import java.util.*;
15
+
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class DutyScheduleService {
20
+
21
+    private final DutyScheduleMapper scheduleMapper;
22
+    private final DutyRecordMapper recordMapper;
23
+
24
+    /**
25
+     * 获取指定月份的排班表
26
+     */
27
+    public List<Map<String, Object>> getMonthlySchedule(int year, int month) {
28
+        LocalDate start = LocalDate.of(year, month, 1);
29
+        LocalDate end = start.plusMonths(1).minusDays(1);
30
+        return scheduleMapper.selectScheduleRange(start, end);
31
+    }
32
+
33
+    /**
34
+     * 获取指定日期范围的排班表
35
+     */
36
+    public List<Map<String, Object>> getScheduleRange(LocalDate start, LocalDate end) {
37
+        return scheduleMapper.selectScheduleRange(start, end);
38
+    }
39
+
40
+    /**
41
+     * 创建排班
42
+     */
43
+    @Transactional
44
+    public DutySchedule createSchedule(DutySchedule schedule) {
45
+        schedule.setStatus(1);
46
+        schedule.setCreatedTime(null);
47
+        scheduleMapper.insert(schedule);
48
+
49
+        // 同时创建值班记录
50
+        DutyRecord record = new DutyRecord();
51
+        record.setScheduleId(schedule.getId());
52
+        record.setUserId(schedule.getUserId());
53
+        record.setDutyDate(schedule.getDutyDate());
54
+        record.setShiftType(schedule.getShiftType());
55
+        record.setStatus("scheduled");
56
+        record.setCreatedTime(null);
57
+        recordMapper.insert(record);
58
+
59
+        log.info("Created schedule: user={} date={} shift={}",
60
+                schedule.getUserId(), schedule.getDutyDate(), schedule.getShiftType());
61
+        return schedule;
62
+    }
63
+
64
+    /**
65
+     * 批量排班
66
+     */
67
+    @Transactional
68
+    public List<DutySchedule> batchCreateSchedule(List<DutySchedule> schedules) {
69
+        List<DutySchedule> created = new ArrayList<>();
70
+        for (DutySchedule s : schedules) {
71
+            created.add(createSchedule(s));
72
+        }
73
+        return created;
74
+    }
75
+
76
+    /**
77
+     * 自动排班 — 简单轮转算法
78
+     * @param userIds 值班人员列表
79
+     * @param startDate 开始日期
80
+     * @param endDate 结束日期
81
+     * @param shiftType 班次类型
82
+     */
83
+    @Transactional
84
+    public List<DutySchedule> autoSchedule(List<Long> userIds, LocalDate startDate, LocalDate endDate, String shiftType) {
85
+        if (userIds == null || userIds.isEmpty()) {
86
+            throw new IllegalArgumentException("值班人员列表不能为空");
87
+        }
88
+        List<DutySchedule> result = new ArrayList<>();
89
+        LocalDate current = startDate;
90
+        int idx = 0;
91
+        while (!current.isAfter(endDate)) {
92
+            DutySchedule schedule = new DutySchedule();
93
+            schedule.setUserId(userIds.get(idx % userIds.size()));
94
+            schedule.setDutyDate(current);
95
+            schedule.setShiftType(shiftType != null ? shiftType : "day");
96
+            schedule.setCreatedBy(0L); // system
97
+            result.add(createSchedule(schedule));
98
+            current = current.plusDays(1);
99
+            idx++;
100
+        }
101
+        log.info("Auto-scheduled {} days for {} users", result.size(), userIds.size());
102
+        return result;
103
+    }
104
+
105
+    /**
106
+     * 取消排班
107
+     */
108
+    @Transactional
109
+    public void cancelSchedule(Long scheduleId) {
110
+        DutySchedule schedule = scheduleMapper.selectById(scheduleId);
111
+        if (schedule == null) {
112
+            throw new IllegalArgumentException("排班记录不存在");
113
+        }
114
+        schedule.setStatus(0);
115
+        scheduleMapper.updateById(schedule);
116
+
117
+        // 更新对应的值班记录
118
+        LambdaQueryWrapper<DutyRecord> wrapper = new LambdaQueryWrapper<>();
119
+        wrapper.eq(DutyRecord::getScheduleId, scheduleId);
120
+        DutyRecord record = recordMapper.selectOne(wrapper);
121
+        if (record != null) {
122
+            record.setStatus("absent");
123
+            recordMapper.updateById(record);
124
+        }
125
+        log.info("Cancelled schedule: id={}", scheduleId);
126
+    }
127
+
128
+    /**
129
+     * 更新排班
130
+     */
131
+    @Transactional
132
+    public DutySchedule updateSchedule(DutySchedule schedule) {
133
+        DutySchedule existing = scheduleMapper.selectById(schedule.getId());
134
+        if (existing == null) {
135
+            throw new IllegalArgumentException("排班记录不存在");
136
+        }
137
+        // 只允许修改部分字段
138
+        if (schedule.getUserId() != null) existing.setUserId(schedule.getUserId());
139
+        if (schedule.getShiftType() != null) existing.setShiftType(schedule.getShiftType());
140
+        if (schedule.getArea() != null) existing.setArea(schedule.getArea());
141
+        if (schedule.getRemark() != null) existing.setRemark(schedule.getRemark());
142
+        scheduleMapper.updateById(existing);
143
+        return existing;
144
+    }
145
+
146
+    /**
147
+     * 获取今日排班
148
+     */
149
+    public List<Map<String, Object>> getTodaySchedule() {
150
+        return scheduleMapper.selectTodaySchedule(LocalDate.now());
151
+    }
152
+}

+ 122
- 0
wm-production/src/main/java/com/water/production/service/HandoverService.java View File

1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.HandoverRecord;
5
+import com.water.production.mapper.HandoverRecordMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.stereotype.Service;
9
+import org.springframework.transaction.annotation.Transactional;
10
+
11
+import java.time.LocalDate;
12
+import java.util.*;
13
+
14
+@Slf4j
15
+@Service
16
+@RequiredArgsConstructor
17
+public class HandoverService {
18
+
19
+    private final HandoverRecordMapper handoverMapper;
20
+
21
+    /**
22
+     * 创建交接班记录
23
+     */
24
+    @Transactional
25
+    public HandoverRecord createHandover(HandoverRecord record) {
26
+        record.setHandoverTime(java.time.LocalDateTime.now());
27
+        record.setFromSign(false);
28
+        record.setToSign(false);
29
+        record.setCreatedTime(null);
30
+        handoverMapper.insert(record);
31
+        log.info("Handover created: from={} to={} date={}",
32
+                record.getFromUserId(), record.getToUserId(), record.getDutyDate());
33
+        return record;
34
+    }
35
+
36
+    /**
37
+     * 交班人签字确认
38
+     */
39
+    @Transactional
40
+    public void signFrom(Long handoverId) {
41
+        HandoverRecord record = handoverMapper.selectById(handoverId);
42
+        if (record == null) {
43
+            throw new IllegalArgumentException("交接班记录不存在");
44
+        }
45
+        record.setFromSign(true);
46
+        handoverMapper.updateById(record);
47
+        log.info("Handover signed by from-user: id={}", handoverId);
48
+    }
49
+
50
+    /**
51
+     * 接班人签字确认
52
+     */
53
+    @Transactional
54
+    public void signTo(Long handoverId) {
55
+        HandoverRecord record = handoverMapper.selectById(handoverId);
56
+        if (record == null) {
57
+            throw new IllegalArgumentException("交接班记录不存在");
58
+        }
59
+        record.setToSign(true);
60
+        handoverMapper.updateById(record);
61
+        log.info("Handover signed by to-user: id={}", handoverId);
62
+    }
63
+
64
+    /**
65
+     * 获取指定日期的交接班记录
66
+     */
67
+    public List<Map<String, Object>> getByDate(LocalDate date) {
68
+        return handoverMapper.selectByDate(date);
69
+    }
70
+
71
+    /**
72
+     * 获取日期范围的交接班记录
73
+     */
74
+    public List<Map<String, Object>> getByDateRange(LocalDate start, LocalDate end) {
75
+        return handoverMapper.selectByDateRange(start, end);
76
+    }
77
+
78
+    /**
79
+     * 获取交接班详情
80
+     */
81
+    public HandoverRecord getDetail(Long id) {
82
+        HandoverRecord record = handoverMapper.selectById(id);
83
+        if (record == null) {
84
+            throw new IllegalArgumentException("交接班记录不存在");
85
+        }
86
+        return record;
87
+    }
88
+
89
+    /**
90
+     * 更新交接班内容
91
+     */
92
+    @Transactional
93
+    public HandoverRecord updateHandover(HandoverRecord update) {
94
+        HandoverRecord existing = handoverMapper.selectById(update.getId());
95
+        if (existing == null) {
96
+            throw new IllegalArgumentException("交接班记录不存在");
97
+        }
98
+        if (update.getContent() != null) existing.setContent(update.getContent());
99
+        if (update.getAbnormalItems() != null) existing.setAbnormalItems(update.getAbnormalItems());
100
+        if (update.getPendingItems() != null) existing.setPendingItems(update.getPendingItems());
101
+        if (update.getEquipmentStatus() != null) existing.setEquipmentStatus(update.getEquipmentStatus());
102
+        handoverMapper.updateById(existing);
103
+        return existing;
104
+    }
105
+
106
+    /**
107
+     * 检查指定日期是否已完成交接
108
+     */
109
+    public Map<String, Object> checkHandoverStatus(LocalDate date) {
110
+        LambdaQueryWrapper<HandoverRecord> wrapper = new LambdaQueryWrapper<>();
111
+        wrapper.eq(HandoverRecord::getDutyDate, date);
112
+        List<HandoverRecord> records = handoverMapper.selectList(wrapper);
113
+
114
+        Map<String, Object> result = new LinkedHashMap<>();
115
+        result.put("date", date.toString());
116
+        result.put("totalRecords", records.size());
117
+        result.put("allSigned", records.stream().allMatch(r ->
118
+                Boolean.TRUE.equals(r.getFromSign()) && Boolean.TRUE.equals(r.getToSign())));
119
+        result.put("records", records);
120
+        return result;
121
+    }
122
+}

+ 84
- 0
wm-production/src/main/resources/db/V_duty.sql View File

1
+-- 值班管理模块 DDL
2
+
3
+-- 排班表
4
+CREATE TABLE IF NOT EXISTS duty_schedule (
5
+    id BIGSERIAL PRIMARY KEY,
6
+    user_id BIGINT NOT NULL,
7
+    duty_date DATE NOT NULL,
8
+    shift_type VARCHAR(20) NOT NULL DEFAULT 'day', -- day/night/full
9
+    area VARCHAR(100),
10
+    status INT DEFAULT 1, -- 0=已取消 1=已排班 2=已完成
11
+    remark VARCHAR(500),
12
+    created_by BIGINT,
13
+    created_time TIMESTAMP DEFAULT NOW(),
14
+    updated_time TIMESTAMP DEFAULT NOW(),
15
+    UNIQUE(user_id, duty_date, shift_type)
16
+);
17
+
18
+CREATE INDEX idx_duty_schedule_date ON duty_schedule(duty_date);
19
+CREATE INDEX idx_duty_schedule_user ON duty_schedule(user_id);
20
+
21
+-- 值班记录(上下班打卡)
22
+CREATE TABLE IF NOT EXISTS duty_record (
23
+    id BIGSERIAL PRIMARY KEY,
24
+    schedule_id BIGINT REFERENCES duty_schedule(id),
25
+    user_id BIGINT NOT NULL,
26
+    duty_date DATE NOT NULL,
27
+    shift_type VARCHAR(20) NOT NULL,
28
+    status VARCHAR(20) DEFAULT 'scheduled', -- scheduled/on_duty/off_duty/absent
29
+    on_duty_at TIMESTAMP,
30
+    off_duty_at TIMESTAMP,
31
+    on_duty_location VARCHAR(200),
32
+    off_duty_location VARCHAR(200),
33
+    handover_remark TEXT,
34
+    created_time TIMESTAMP DEFAULT NOW(),
35
+    updated_time TIMESTAMP DEFAULT NOW()
36
+);
37
+
38
+CREATE INDEX idx_duty_record_date ON duty_record(duty_date);
39
+CREATE INDEX idx_duty_record_user ON duty_record(user_id);
40
+CREATE INDEX idx_duty_record_status ON duty_record(status);
41
+
42
+-- 交接班记录
43
+CREATE TABLE IF NOT EXISTS handover_record (
44
+    id BIGSERIAL PRIMARY KEY,
45
+    from_user_id BIGINT NOT NULL,
46
+    to_user_id BIGINT NOT NULL,
47
+    duty_date DATE NOT NULL,
48
+    shift_type VARCHAR(20) NOT NULL,
49
+    handover_time TIMESTAMP DEFAULT NOW(),
50
+    content TEXT,
51
+    abnormal_items TEXT, -- JSON: 异常事项列表
52
+    pending_items TEXT,  -- JSON: 待处理事项
53
+    equipment_status TEXT, -- JSON: 设备状态
54
+    from_sign BOOLEAN DEFAULT FALSE,
55
+    to_sign BOOLEAN DEFAULT FALSE,
56
+    created_time TIMESTAMP DEFAULT NOW()
57
+);
58
+
59
+CREATE INDEX idx_handover_date ON handover_record(duty_date);
60
+CREATE INDEX idx_handover_from ON handover_record(from_user_id);
61
+CREATE INDEX idx_handover_to ON handover_record(to_user_id);
62
+
63
+-- 值班日志
64
+CREATE TABLE IF NOT EXISTS duty_log (
65
+    id BIGSERIAL PRIMARY KEY,
66
+    user_id BIGINT NOT NULL,
67
+    duty_date DATE NOT NULL,
68
+    log_time TIMESTAMP DEFAULT NOW(),
69
+    log_type VARCHAR(30) NOT NULL, -- patrol/inspection/event/maintenance/other
70
+    title VARCHAR(200) NOT NULL,
71
+    content TEXT,
72
+    location VARCHAR(200),
73
+    images TEXT, -- JSON: 图片路径列表
74
+    severity VARCHAR(10), -- normal/warning/critical
75
+    handled BOOLEAN DEFAULT FALSE,
76
+    handler_id BIGINT,
77
+    handle_time TIMESTAMP,
78
+    handle_result TEXT,
79
+    created_time TIMESTAMP DEFAULT NOW()
80
+);
81
+
82
+CREATE INDEX idx_duty_log_date ON duty_log(duty_date);
83
+CREATE INDEX idx_duty_log_user ON duty_log(user_id);
84
+CREATE INDEX idx_duty_log_type ON duty_log(log_type);

+ 144
- 0
wm-production/src/test/java/com/water/production/service/DutyLogServiceTest.java View File

1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.DutyLog;
4
+import com.water.production.mapper.DutyLogMapper;
5
+import org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.ArgumentCaptor;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import java.time.LocalDate;
13
+import java.time.LocalDateTime;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+import static org.junit.jupiter.api.Assertions.*;
18
+import static org.mockito.ArgumentMatchers.any;
19
+import static org.mockito.ArgumentMatchers.eq;
20
+import static org.mockito.Mockito.*;
21
+
22
+@ExtendWith(MockitoExtension.class)
23
+class DutyLogServiceTest {
24
+
25
+    @Mock
26
+    private DutyLogMapper dutyLogMapper;
27
+
28
+    @InjectMocks
29
+    private DutyLogService logService;
30
+
31
+    @Test
32
+    void testCreateLog() {
33
+        when(dutyLogMapper.insert(any())).thenReturn(1);
34
+
35
+        DutyLog log = new DutyLog();
36
+        log.setUserId(1L);
37
+        log.setLogType("patrol");
38
+        log.setTitle("上午巡查");
39
+        log.setContent("设备运行正常");
40
+        log.setSeverity("normal");
41
+
42
+        DutyLog result = logService.createLog(log);
43
+
44
+        assertFalse(result.getHandled());
45
+        assertNotNull(result.getLogTime());
46
+        assertNotNull(result.getDutyDate());
47
+        verify(dutyLogMapper).insert(any());
48
+    }
49
+
50
+    @Test
51
+    void testCreateLogWithCustomTime() {
52
+        when(dutyLogMapper.insert(any())).thenReturn(1);
53
+
54
+        DutyLog log = new DutyLog();
55
+        log.setUserId(1L);
56
+        log.setLogType("event");
57
+        log.setTitle("管道泄漏");
58
+        log.setLogTime(LocalDateTime.of(2026, 6, 14, 10, 30));
59
+        log.setDutyDate(LocalDate.of(2026, 6, 14));
60
+
61
+        DutyLog result = logService.createLog(log);
62
+
63
+        assertEquals(LocalDateTime.of(2026, 6, 14, 10, 30), result.getLogTime());
64
+        assertEquals(LocalDate.of(2026, 6, 14), result.getDutyDate());
65
+    }
66
+
67
+    @Test
68
+    void testHandleLog() {
69
+        when(dutyLogMapper.markHandled(eq(1L), eq(1L), eq("已修复"))).thenReturn(1);
70
+
71
+        logService.handleLog(1L, 1L, "已修复");
72
+
73
+        verify(dutyLogMapper).markHandled(1L, 1L, "已修复");
74
+    }
75
+
76
+    @Test
77
+    void testHandleLogNotFound() {
78
+        when(dutyLogMapper.markHandled(eq(999L), any(), any())).thenReturn(0);
79
+
80
+        assertThrows(IllegalArgumentException.class, () ->
81
+                logService.handleLog(999L, 1L, "已修复"));
82
+    }
83
+
84
+    @Test
85
+    void testGetDetail() {
86
+        DutyLog log = new DutyLog();
87
+        log.setId(1L);
88
+        log.setTitle("巡查记录");
89
+        when(dutyLogMapper.selectById(1L)).thenReturn(log);
90
+
91
+        DutyLog result = logService.getDetail(1L);
92
+        assertEquals("巡查记录", result.getTitle());
93
+    }
94
+
95
+    @Test
96
+    void testGetDetailNotFound() {
97
+        when(dutyLogMapper.selectById(999L)).thenReturn(null);
98
+        assertThrows(IllegalArgumentException.class, () -> logService.getDetail(999L));
99
+    }
100
+
101
+    @Test
102
+    void testUpdateLog() {
103
+        DutyLog existing = new DutyLog();
104
+        existing.setId(1L);
105
+        existing.setTitle("原始标题");
106
+        when(dutyLogMapper.selectById(1L)).thenReturn(existing);
107
+        when(dutyLogMapper.updateById(any())).thenReturn(1);
108
+
109
+        DutyLog update = new DutyLog();
110
+        update.setId(1L);
111
+        update.setTitle("更新标题");
112
+        update.setSeverity("critical");
113
+
114
+        DutyLog result = logService.updateLog(update);
115
+        assertEquals("更新标题", result.getTitle());
116
+        assertEquals("critical", result.getSeverity());
117
+    }
118
+
119
+    @Test
120
+    void testDeleteLog() {
121
+        when(dutyLogMapper.deleteById(1L)).thenReturn(1);
122
+        logService.deleteLog(1L);
123
+        verify(dutyLogMapper).deleteById(1L);
124
+    }
125
+
126
+    @Test
127
+    void testGetLogStats() {
128
+        List<Map<String, Object>> logs = List.of(
129
+                Map.of("log_type", "patrol", "severity", "normal", "handled", true),
130
+                Map.of("log_type", "patrol", "severity", "normal", "handled", false),
131
+                Map.of("log_type", "event", "severity", "critical", "handled", false)
132
+        );
133
+        when(dutyLogMapper.selectByDate(any())).thenReturn(logs);
134
+
135
+        Map<String, Object> stats = logService.getLogStats(LocalDate.now());
136
+
137
+        assertEquals(3, stats.get("total"));
138
+        assertEquals(2L, stats.get("unhandled"));
139
+        @SuppressWarnings("unchecked")
140
+        Map<String, Long> byType = (Map<String, Long>) stats.get("byType");
141
+        assertEquals(2L, byType.get("patrol"));
142
+        assertEquals(1L, byType.get("event"));
143
+    }
144
+}

+ 135
- 0
wm-production/src/test/java/com/water/production/service/DutyRecordServiceTest.java View File

1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.DutyRecord;
4
+import com.water.production.entity.DutySchedule;
5
+import com.water.production.mapper.DutyRecordMapper;
6
+import com.water.production.mapper.DutyScheduleMapper;
7
+import org.junit.jupiter.api.Test;
8
+import org.junit.jupiter.api.extension.ExtendWith;
9
+import org.mockito.ArgumentCaptor;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.time.LocalDate;
15
+import java.time.LocalDateTime;
16
+import java.util.List;
17
+import java.util.Map;
18
+
19
+import static org.junit.jupiter.api.Assertions.*;
20
+import static org.mockito.ArgumentMatchers.any;
21
+import static org.mockito.Mockito.*;
22
+
23
+@ExtendWith(MockitoExtension.class)
24
+class DutyRecordServiceTest {
25
+
26
+    @Mock
27
+    private DutyRecordMapper recordMapper;
28
+
29
+    @Mock
30
+    private DutyScheduleMapper scheduleMapper;
31
+
32
+    @InjectMocks
33
+    private DutyRecordService recordService;
34
+
35
+    @Test
36
+    void testStartDutyWithExistingRecord() {
37
+        DutyRecord record = new DutyRecord();
38
+        record.setId(1L);
39
+        record.setUserId(1L);
40
+        record.setDutyDate(LocalDate.now());
41
+        record.setStatus("scheduled");
42
+
43
+        when(recordMapper.selectOne(any())).thenReturn(record);
44
+        when(recordMapper.updateById(any())).thenReturn(1);
45
+
46
+        Map<String, Object> result = recordService.startDuty(1L, "办公楼");
47
+
48
+        assertEquals("on_duty", result.get("status"));
49
+        assertNotNull(result.get("onDutyAt"));
50
+        assertEquals("上班打卡成功", result.get("message"));
51
+
52
+        ArgumentCaptor<DutyRecord> captor = ArgumentCaptor.forClass(DutyRecord.class);
53
+        verify(recordMapper).updateById(captor.capture());
54
+        assertEquals("on_duty", captor.getValue().getStatus());
55
+        assertEquals("办公楼", captor.getValue().getOnDutyLocation());
56
+    }
57
+
58
+    @Test
59
+    void testStartDutyWithoutSchedule() {
60
+        when(recordMapper.selectOne(any())).thenReturn(null);
61
+        when(recordMapper.insert(any())).thenReturn(1);
62
+
63
+        Map<String, Object> result = recordService.startDuty(1L, "办公楼");
64
+
65
+        assertEquals("on_duty", result.get("status"));
66
+        verify(recordMapper).insert(any());
67
+    }
68
+
69
+    @Test
70
+    void testEndDuty() {
71
+        DutyRecord record = new DutyRecord();
72
+        record.setId(1L);
73
+        record.setUserId(1L);
74
+        record.setDutyDate(LocalDate.now());
75
+        record.setStatus("on_duty");
76
+        record.setOnDutyAt(LocalDateTime.now().minusHours(8));
77
+        record.setScheduleId(10L);
78
+
79
+        when(recordMapper.selectOne(any())).thenReturn(record);
80
+        when(recordMapper.updateById(any())).thenReturn(1);
81
+        when(scheduleMapper.selectById(10L)).thenReturn(new DutySchedule());
82
+        when(scheduleMapper.updateById(any())).thenReturn(1);
83
+
84
+        Map<String, Object> result = recordService.endDuty(1L, "办公楼", "设备正常");
85
+
86
+        assertEquals("off_duty", result.get("status"));
87
+        assertNotNull(result.get("offDutyAt"));
88
+        assertNotNull(result.get("workDurationMinutes"));
89
+        assertEquals("下班打卡成功", result.get("message"));
90
+    }
91
+
92
+    @Test
93
+    void testEndDutyWithoutStartRecord() {
94
+        when(recordMapper.selectOne(any())).thenReturn(null);
95
+        assertThrows(IllegalStateException.class, () ->
96
+                recordService.endDuty(1L, "办公楼", ""));
97
+    }
98
+
99
+    @Test
100
+    void testGetCurrentDutyStatus() {
101
+        DutyRecord record = new DutyRecord();
102
+        record.setStatus("on_duty");
103
+        record.setOnDutyAt(LocalDateTime.of(2026, 6, 14, 8, 0));
104
+
105
+        when(recordMapper.selectOne(any())).thenReturn(record);
106
+
107
+        Map<String, Object> result = recordService.getCurrentDutyStatus(1L);
108
+        assertEquals("on_duty", result.get("status"));
109
+    }
110
+
111
+    @Test
112
+    void testGetCurrentDutyStatusNoSchedule() {
113
+        when(recordMapper.selectOne(any())).thenReturn(null);
114
+
115
+        Map<String, Object> result = recordService.getCurrentDutyStatus(1L);
116
+        assertEquals("no_schedule", result.get("status"));
117
+    }
118
+
119
+    @Test
120
+    void testGetDutyStats() {
121
+        List<Map<String, Object>> records = List.of(
122
+                Map.of("status", "off_duty", "on_duty_at", LocalDateTime.of(2026, 6, 1, 8, 0)),
123
+                Map.of("status", "off_duty", "on_duty_at", LocalDateTime.of(2026, 6, 2, 8, 0)),
124
+                Map.of("status", "scheduled")
125
+        );
126
+        when(recordMapper.selectUserRecords(any(), any(), any())).thenReturn(records);
127
+
128
+        Map<String, Object> stats = recordService.getDutyStats(1L, 2026, 6);
129
+
130
+        assertEquals(3L, stats.get("totalDays"));
131
+        assertEquals(2L, stats.get("onTimeCount"));
132
+        assertEquals(2L, stats.get("completedCount"));
133
+        assertEquals(1L, stats.get("absentCount"));
134
+    }
135
+}

+ 129
- 0
wm-production/src/test/java/com/water/production/service/DutyScheduleServiceTest.java View File

1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.DutySchedule;
4
+import com.water.production.mapper.DutyRecordMapper;
5
+import com.water.production.mapper.DutyScheduleMapper;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.ArgumentCaptor;
9
+import org.mockito.InjectMocks;
10
+import org.mockito.Mock;
11
+import org.mockito.junit.jupiter.MockitoExtension;
12
+
13
+import java.time.LocalDate;
14
+import java.util.*;
15
+
16
+import static org.junit.jupiter.api.Assertions.*;
17
+import static org.mockito.ArgumentMatchers.any;
18
+import static org.mockito.Mockito.*;
19
+
20
+@ExtendWith(MockitoExtension.class)
21
+class DutyScheduleServiceTest {
22
+
23
+    @Mock
24
+    private DutyScheduleMapper scheduleMapper;
25
+
26
+    @Mock
27
+    private DutyRecordMapper recordMapper;
28
+
29
+    @InjectMocks
30
+    private DutyScheduleService scheduleService;
31
+
32
+    @Test
33
+    void testGetMonthlySchedule() {
34
+        List<Map<String, Object>> mockData = List.of(
35
+                Map.of("id", 1L, "user_id", 1L, "duty_date", LocalDate.of(2026, 6, 1)),
36
+                Map.of("id", 2L, "user_id", 2L, "duty_date", LocalDate.of(2026, 6, 2))
37
+        );
38
+        when(scheduleMapper.selectScheduleRange(any(), any())).thenReturn(mockData);
39
+
40
+        List<Map<String, Object>> result = scheduleService.getMonthlySchedule(2026, 6);
41
+        assertEquals(2, result.size());
42
+        verify(scheduleMapper).selectScheduleRange(
43
+                LocalDate.of(2026, 6, 1),
44
+                LocalDate.of(2026, 6, 30));
45
+    }
46
+
47
+    @Test
48
+    void testCreateSchedule() {
49
+        when(scheduleMapper.insert(any())).thenReturn(1);
50
+        when(recordMapper.insert(any())).thenReturn(1);
51
+
52
+        DutySchedule schedule = new DutySchedule();
53
+        schedule.setUserId(1L);
54
+        schedule.setDutyDate(LocalDate.of(2026, 6, 15));
55
+        schedule.setShiftType("day");
56
+
57
+        DutySchedule result = scheduleService.createSchedule(schedule);
58
+
59
+        assertEquals(1, result.getStatus());
60
+        verify(scheduleMapper).insert(any());
61
+        verify(recordMapper).insert(any());
62
+    }
63
+
64
+    @Test
65
+    void testAutoSchedule() {
66
+        when(scheduleMapper.insert(any())).thenReturn(1);
67
+        when(recordMapper.insert(any())).thenReturn(1);
68
+
69
+        List<Long> userIds = List.of(1L, 2L, 3L);
70
+        LocalDate start = LocalDate.of(2026, 6, 1);
71
+        LocalDate end = LocalDate.of(2026, 6, 5);
72
+
73
+        List<DutySchedule> result = scheduleService.autoSchedule(userIds, start, end, "day");
74
+
75
+        assertEquals(5, result.size());
76
+        // 验证轮转:第1天user1,第2天user2,第3天user3,第4天user1,第5天user2
77
+        assertEquals(1L, result.get(0).getUserId());
78
+        assertEquals(2L, result.get(1).getUserId());
79
+        assertEquals(3L, result.get(2).getUserId());
80
+        assertEquals(1L, result.get(3).getUserId());
81
+        assertEquals(2L, result.get(4).getUserId());
82
+    }
83
+
84
+    @Test
85
+    void testAutoScheduleEmptyUsers() {
86
+        assertThrows(IllegalArgumentException.class, () ->
87
+                scheduleService.autoSchedule(List.of(), LocalDate.now(), LocalDate.now().plusDays(5), "day"));
88
+    }
89
+
90
+    @Test
91
+    void testCancelSchedule() {
92
+        DutySchedule schedule = new DutySchedule();
93
+        schedule.setId(1L);
94
+        schedule.setStatus(1);
95
+        when(scheduleMapper.selectById(1L)).thenReturn(schedule);
96
+        when(scheduleMapper.updateById(any())).thenReturn(1);
97
+
98
+        scheduleService.cancelSchedule(1L);
99
+
100
+        ArgumentCaptor<DutySchedule> captor = ArgumentCaptor.forClass(DutySchedule.class);
101
+        verify(scheduleMapper).updateById(captor.capture());
102
+        assertEquals(0, captor.getValue().getStatus());
103
+    }
104
+
105
+    @Test
106
+    void testCancelNonExistentSchedule() {
107
+        when(scheduleMapper.selectById(999L)).thenReturn(null);
108
+        assertThrows(IllegalArgumentException.class, () -> scheduleService.cancelSchedule(999L));
109
+    }
110
+
111
+    @Test
112
+    void testUpdateSchedule() {
113
+        DutySchedule existing = new DutySchedule();
114
+        existing.setId(1L);
115
+        existing.setUserId(1L);
116
+        existing.setShiftType("day");
117
+        when(scheduleMapper.selectById(1L)).thenReturn(existing);
118
+        when(scheduleMapper.updateById(any())).thenReturn(1);
119
+
120
+        DutySchedule update = new DutySchedule();
121
+        update.setId(1L);
122
+        update.setShiftType("night");
123
+        update.setArea("新区");
124
+
125
+        DutySchedule result = scheduleService.updateSchedule(update);
126
+        assertEquals("night", result.getShiftType());
127
+        assertEquals("新区", result.getArea());
128
+    }
129
+}

+ 116
- 0
wm-production/src/test/java/com/water/production/service/HandoverServiceTest.java View File

1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.HandoverRecord;
4
+import com.water.production.mapper.HandoverRecordMapper;
5
+import org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.ArgumentCaptor;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import java.time.LocalDate;
13
+import java.util.Map;
14
+
15
+import static org.junit.jupiter.api.Assertions.*;
16
+import static org.mockito.ArgumentMatchers.any;
17
+import static org.mockito.Mockito.*;
18
+
19
+@ExtendWith(MockitoExtension.class)
20
+class HandoverServiceTest {
21
+
22
+    @Mock
23
+    private HandoverRecordMapper handoverMapper;
24
+
25
+    @InjectMocks
26
+    private HandoverService handoverService;
27
+
28
+    @Test
29
+    void testCreateHandover() {
30
+        when(handoverMapper.insert(any())).thenReturn(1);
31
+
32
+        HandoverRecord record = new HandoverRecord();
33
+        record.setFromUserId(1L);
34
+        record.setToUserId(2L);
35
+        record.setDutyDate(LocalDate.now());
36
+        record.setShiftType("day");
37
+        record.setContent("设备运行正常");
38
+
39
+        HandoverRecord result = handoverService.createHandover(record);
40
+
41
+        assertFalse(result.getFromSign());
42
+        assertFalse(result.getToSign());
43
+        assertNotNull(result.getHandoverTime());
44
+        verify(handoverMapper).insert(any());
45
+    }
46
+
47
+    @Test
48
+    void testSignFrom() {
49
+        HandoverRecord record = new HandoverRecord();
50
+        record.setId(1L);
51
+        record.setFromSign(false);
52
+        when(handoverMapper.selectById(1L)).thenReturn(record);
53
+        when(handoverMapper.updateById(any())).thenReturn(1);
54
+
55
+        handoverService.signFrom(1L);
56
+
57
+        ArgumentCaptor<HandoverRecord> captor = ArgumentCaptor.forClass(HandoverRecord.class);
58
+        verify(handoverMapper).updateById(captor.capture());
59
+        assertTrue(captor.getValue().getFromSign());
60
+    }
61
+
62
+    @Test
63
+    void testSignTo() {
64
+        HandoverRecord record = new HandoverRecord();
65
+        record.setId(1L);
66
+        record.setToSign(false);
67
+        when(handoverMapper.selectById(1L)).thenReturn(record);
68
+        when(handoverMapper.updateById(any())).thenReturn(1);
69
+
70
+        handoverService.signTo(1L);
71
+
72
+        ArgumentCaptor<HandoverRecord> captor = ArgumentCaptor.forClass(HandoverRecord.class);
73
+        verify(handoverMapper).updateById(captor.capture());
74
+        assertTrue(captor.getValue().getToSign());
75
+    }
76
+
77
+    @Test
78
+    void testSignNonExistentRecord() {
79
+        when(handoverMapper.selectById(999L)).thenReturn(null);
80
+        assertThrows(IllegalArgumentException.class, () -> handoverService.signFrom(999L));
81
+        assertThrows(IllegalArgumentException.class, () -> handoverService.signTo(999L));
82
+    }
83
+
84
+    @Test
85
+    void testGetDetail() {
86
+        HandoverRecord record = new HandoverRecord();
87
+        record.setId(1L);
88
+        record.setContent("交接内容");
89
+        when(handoverMapper.selectById(1L)).thenReturn(record);
90
+
91
+        HandoverRecord result = handoverService.getDetail(1L);
92
+        assertEquals("交接内容", result.getContent());
93
+    }
94
+
95
+    @Test
96
+    void testGetDetailNotFound() {
97
+        when(handoverMapper.selectById(999L)).thenReturn(null);
98
+        assertThrows(IllegalArgumentException.class, () -> handoverService.getDetail(999L));
99
+    }
100
+
101
+    @Test
102
+    void testUpdateHandover() {
103
+        HandoverRecord existing = new HandoverRecord();
104
+        existing.setId(1L);
105
+        existing.setContent("原始内容");
106
+        when(handoverMapper.selectById(1L)).thenReturn(existing);
107
+        when(handoverMapper.updateById(any())).thenReturn(1);
108
+
109
+        HandoverRecord update = new HandoverRecord();
110
+        update.setId(1L);
111
+        update.setContent("更新内容");
112
+
113
+        HandoverRecord result = handoverService.updateHandover(update);
114
+        assertEquals("更新内容", result.getContent());
115
+    }
116
+}