yxh
7 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
package cn.lihu.jh.module.ecg.service.room;
 
import java.util.*;
import javax.annotation.Resource;
 
import cn.lihu.jh.framework.common.exception.ErrorCode;
import cn.lihu.jh.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.module.ecg.utils.IpUtils;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.MonitorInfoVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSeatRespVO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomProfile;
import cn.lihu.jh.module.ecg.enums.RoomCallingScreenEnum;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
import cn.lihu.jh.module.system.api.oauth2.OAuth2TokenApi;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
 
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomPageReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSaveReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSelectBedRespVO;
import cn.lihu.jh.module.ecg.dal.mysql.room.RoomMapper;
import cn.lihu.jh.module.ecg.enums.BedStatusEnum;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import lombok.extern.slf4j.Slf4j;
 
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.ROOM_INVALID_STATUS;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.ROOM_NOT_EXISTS;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.ROOM_NOT_OWNER;
 
/**
 * 诊室和诊疗床 Service 实现类
 *
 * @author 芋道源码
 */
@Service
@Validated
@Slf4j
public class RoomServiceImpl implements RoomService {
 
    @Resource
    private RoomMapper roomMapper;
 
    @Resource
    private OAuth2TokenApi oAuth2TokenApi;
 
    @Resource
    QueueService queueService;
 
    /** 【主/围观】按患者反查工位做主人校验时需要读 queue 行 */
    @Resource
    private cn.lihu.jh.module.ecg.dal.mysql.queue.QueueMapper queueMapper;
 
    @Override
    public Integer createRoom(RoomSaveReqVO createReqVO) {
        // 插入
        RoomDO room = BeanUtils.toBean(createReqVO, RoomDO.class);
        roomMapper.insert(room);
        // 返回
        return room.getId();
    }
 
    @Override
    public void updateRoom(RoomSaveReqVO updateReqVO) {
        // 校验存在
        validateRoomExists(updateReqVO.getId());
        // 更新
        RoomDO updateObj = BeanUtils.toBean(updateReqVO, RoomDO.class);
        roomMapper.updateById(updateObj);
    }
 
    @Override
    public void deleteRoom(Integer id) {
        // 校验存在
        validateRoomExists(id);
        // 删除
        roomMapper.deleteById(id);
    }
 
    private void validateRoomExists(Integer id) {
        if (roomMapper.selectById(id) == null) {
            throw exception(ROOM_NOT_EXISTS);
        }
    }
 
    @Override
    public RoomDO getRoom(Integer id) {
        return roomMapper.selectById(id);
    }
 
    @Override
    public RoomProfile getRoomByIP(String ip, Long roomId) {
        List<RoomDO> roomDOList = null;
        if (null != roomId) {
            roomDOList = roomMapper.selectList(RoomDO::getRoomId, roomId);
        } else {
            roomDOList = roomMapper.queueByIp(ip);
        }
 
        RoomProfile roomProfile = new RoomProfile();
        roomProfile.setBedNum( 0 );
        roomProfile.setCallingScreenType( RoomCallingScreenEnum.NONE.getCallingScreenType() );
 
        for (RoomDO roomDO : roomDOList) {
            roomProfile.setRoomId(roomDO.getRoomId());
            roomProfile.setRoomName(roomDO.getRoomName());
            roomProfile.setBedNum( roomProfile.getBedNum() + 1);
 
            // 0 检查 1 领用 2 装机
 
            // 计算 诊间屏类型
            if (roomProfile.getCallingScreenType() == RoomCallingScreenEnum.NONE.getCallingScreenType()) {
                if ( roomDO.getOpType() == 0 ) {
                    roomProfile.setCallingScreenType(RoomCallingScreenEnum.CHECK_ONLY.getCallingScreenType());
                } else if ( roomDO.getOpType() == 1 ) {
                    roomProfile.setCallingScreenType( RoomCallingScreenEnum.RECEIVE_ONLY.getCallingScreenType());
                } else if ( roomDO.getOpType() == 2 ) {
                    roomProfile.setCallingScreenType( RoomCallingScreenEnum.INSTALL_ONLY.getCallingScreenType());
                }
            } else if (roomProfile.getCallingScreenType() == RoomCallingScreenEnum.CHECK_ONLY.getCallingScreenType()) {
                if ( roomDO.getOpType() == 0 || roomDO.getOpType() == 1 ) {
                    // do nothing
                } else if ( roomDO.getOpType() == 2 ) {
                    roomProfile.setCallingScreenType( RoomCallingScreenEnum.CHECK_INSTALL.getCallingScreenType());
                }
            } else if (roomProfile.getCallingScreenType() == RoomCallingScreenEnum.RECEIVE_ONLY.getCallingScreenType()) {
                if ( roomDO.getOpType() == 0 || roomDO.getOpType() == 1 ) {
                    // do nothing
                } else if ( roomDO.getOpType() == 2 ) {
                    roomProfile.setCallingScreenType( RoomCallingScreenEnum.RECEIVE_INSTALL.getCallingScreenType());
                }
            } else if (roomProfile.getCallingScreenType() == RoomCallingScreenEnum.INSTALL_ONLY.getCallingScreenType()) {
                if ( roomDO.getOpType() == 0 ) {
                    roomProfile.setCallingScreenType( RoomCallingScreenEnum.CHECK_INSTALL.getCallingScreenType());
                } else if ( roomDO.getOpType() == 1 ) {
                    roomProfile.setCallingScreenType( RoomCallingScreenEnum.RECEIVE_INSTALL.getCallingScreenType());
                } else if ( roomDO.getOpType() == 2 ) {
                    // do nothing
                }
            } else if (roomProfile.getCallingScreenType() == RoomCallingScreenEnum.RECEIVE_INSTALL.getCallingScreenType()) {
                // do nothing
            } else if (roomProfile.getCallingScreenType() == RoomCallingScreenEnum.CHECK_INSTALL.getCallingScreenType()) {
                // do nothing
            }
        }
 
        return roomProfile;
    }
 
    @Override
    public PageResult<RoomDO> getRoomPage(RoomPageReqVO pageReqVO) {
        return roomMapper.selectPage(pageReqVO);
    }
 
    /**
     * 「选择工位模式」- 按本机IP匹配工位。
     * <p>排序(决定自动入座选哪一个):① 本人已占用(刷新/重进直接恢复)→ ② 空闲且工位号最小
     * → ③ 被他人占用(前端会提示"该IP对应工位已被XX占用",让人工处理)。</p>
     */
    @Override
    public List<RoomSelectBedRespVO.RoomSelectBedItem> matchBedsByIp(String ip, Long docId) {
        String normalized = IpUtils.normalize(ip);
        if (!IpUtils.isBindableIp(normalized)) {
            return Collections.emptyList();
        }
        List<RoomDO> list = roomMapper.queueByIp(normalized);
        if (list == null || list.isEmpty()) {
            return Collections.emptyList();
        }
        List<RoomSelectBedRespVO.RoomSelectBedItem> items = new ArrayList<>(list.size());
        for (RoomDO roomDO : list) {
            RoomSelectBedRespVO.RoomSelectBedItem item = new RoomSelectBedRespVO.RoomSelectBedItem();
            item.setId(roomDO.getId());
            item.setRoomId(roomDO.getRoomId());
            item.setRoomName(roomDO.getRoomName());
            item.setBedNo(roomDO.getBedNo());
            item.setIp(roomDO.getIp());
            item.setDocId(roomDO.getDocId());
            item.setDocName(roomDO.getDocName());
            item.setOpType(roomDO.getOpType());
            item.setCheckTypes(roomDO.getCheckTypes());
            item.setStatus(roomDO.getStatus());
            item.setOccupied(roomDO.getDocId() != null);
            item.setMine(docId != null && docId.equals(roomDO.getDocId()));
            items.add(item);
        }
        // 排序:本人已占用(0) → 空闲(1) → 被他人占用(2);同档按工位号
        items.sort(Comparator
                .comparingInt((RoomSelectBedRespVO.RoomSelectBedItem i) -> {
                    if (Boolean.TRUE.equals(i.getMine())) return 0;
                    if (!Boolean.TRUE.equals(i.getOccupied())) return 1;
                    return 2;
                })
                .thenComparing(RoomSelectBedRespVO.RoomSelectBedItem::getBedNo,
                        Comparator.nullsLast(Comparator.naturalOrder())));
        return items;
    }
 
    @Override
    public Integer bindIpToBed(Integer id, String ip) {
        // 回环/未指定/非法地址绑上去没有意义(127.x、::1、0.0.0.0),还会让后续匹配失效
        if (!IpUtils.isBindableIp(ip)) {
            return 0;
        }
        Integer rows = roomMapper.updateIpById(id, IpUtils.normalize(ip));
        log.info("[bindIpToBed][把本机IP绑定到工位] id={} ip={} 影响行数={}", id, ip, rows);
        return rows;
    }
 
    @Override
    public List<RoomDO> listIpBindings(String ip, Integer excludeId) {
        if (!IpUtils.isBindableIp(ip)) {
            return Collections.emptyList();
        }
        List<RoomDO> list = roomMapper.listByIpExcludeId(IpUtils.normalize(ip), excludeId);
        return list == null ? Collections.emptyList() : list;
    }
 
    @Override
    public RoomSeatRespVO autoSeatByIp(String ip, Long docId, String docName) {
        RoomSeatRespVO resp = new RoomSeatRespVO();
        String clientIp = IpUtils.normalize(ip);
        resp.setClientIp(clientIp);
 
        // ① 本机IP不可用(回环 / IPv6 / 未指定 / 非法 / **服务器自身地址**)→ 不能作为工位标识
        if (!IpUtils.isBindableIp(clientIp)) {
            // 区分"识别到的是服务器自身地址"与其它不可用情况:前者现场最常见(在服务器上/远程桌面里访问),
            // 提示语要能直接指向解决办法,而不是让排查的人以为是代码坏了。
            resp.setReason(IpUtils.isLocalAddress(clientIp) ? "ip_is_server" : "ip_invalid");
            log.info("[autoSeatByIp] 本机IP不可用,不自动入座. clientIp={} docId={} 是服务器自身地址={}",
                    clientIp, docId, IpUtils.isLocalAddress(clientIp));
            return resp;
        }
 
        // 已按「本人已占用 → 空闲 → 被他人占用」排序
        List<RoomSelectBedRespVO.RoomSelectBedItem> beds = matchBedsByIp(clientIp, docId);
        if (beds.isEmpty()) {
            // 该 IP 没有对应任何工位:工位没录 IP(现场多为 0.0.0.0),或工位已关闭
            resp.setReason("nomatch");
            log.info("[autoSeatByIp] 该IP没有匹配到工位(工位未录IP / 已关闭). clientIp={} docId={}", clientIp, docId);
            return resp;
        }
 
        // ② 已经是我的工位 → 幂等恢复(刷新/重进/上次没正常离座都走这条)
        RoomSelectBedRespVO.RoomSelectBedItem mine = beds.stream()
                .filter(b -> Boolean.TRUE.equals(b.getMine())).findFirst().orElse(null);
        if (null != mine) {
            return doSeat(mine.getId(), mine.getRoomId(), mine.getBedNo(), docId, docName, resp, "mine");
        }
 
        // ③ 空闲工位:**恰好一个**才自动入座
        List<RoomSelectBedRespVO.RoomSelectBedItem> free = beds.stream()
                .filter(b -> !Boolean.TRUE.equals(b.getOccupied())).toList();
        if (free.size() == 1) {
            return doSeat(free.get(0).getId(), free.get(0).getRoomId(), free.get(0).getBedNo(),
                    docId, docName, resp, "free");
        }
        if (free.size() > 1) {
            // 这台机器对应多个空闲工位 → 坐错的风险大于便利,交人工点选
            resp.setReason("multi");
            log.info("[autoSeatByIp] 该IP对应 {} 个空闲工位,无法确定是哪一个,交人工选择. clientIp={}", free.size(), clientIp);
            return resp;
        }
 
        // ④ 全被他人占用 → **不自动挤掉**,但把候选工位与占用人带回去,
        //    供前端弹「该工位由 xxx 占用,是否接管?」(本需求:接班医生自助接管)。
        RoomSelectBedRespVO.RoomSelectBedItem occupied = beds.get(0);
        resp.setReason("occupied");
        resp.setOwnerName(occupied.getDocName());
        // 【本需求】补齐候选工位信息:前端凭这些字段调 /clinic/room/take-over-bed
        resp.setId(occupied.getId());
        resp.setRoomId(occupied.getRoomId());
        resp.setRoomName(occupied.getRoomName());
        resp.setBedNo(occupied.getBedNo());
        resp.setIp(occupied.getIp());
        resp.setDocId(occupied.getDocId());
        resp.setDocName(occupied.getDocName());
        resp.setCheckTypes(occupied.getCheckTypes());
        resp.setOpType(occupied.getOpType());
        log.info("[autoSeatByIp] 该IP对应的工位已被他人占用,等待前端确认是否接管. clientIp={} 工位={}/{} 占用人={}({})",
                clientIp, occupied.getRoomId(), occupied.getBedNo(), occupied.getDocName(), occupied.getDocId());
        return resp;
    }
 
    /** 执行入座并回填结果(内部方法,供 autoSeatByIp 各分支复用) */
    private RoomSeatRespVO doSeat(Integer bedId, Long roomId, String bedNo, Long docId, String docName,
                                  RoomSeatRespVO resp, String branch) {
        // 传 bedId:入座优先按主键定位,避免 bed_no 与库内值有肉眼难辨差异时更新 0 行
        ErrorCode ec = queueService.startBedDoctorOn(roomId, bedNo, docId, docName, bedId);
        if (!GlobalErrorCodeConstants.SUCCESS.equals(ec)) {
            resp.setReason("error");
            log.warn("[autoSeatByIp] 入座未生效({}). roomId={} bedNo={} docId={}", branch, roomId, bedNo, docId);
            return resp;
        }
        fillSeatInfo(resp, roomMapper.getRoom(roomId, bedNo));
        resp.setSeated(true);
        log.info("[autoSeatByIp] 自动入座成功({}). docId={} roomId={} bedNo={}", branch, docId, roomId, bedNo);
        return resp;
    }
 
    @Override
    public RoomSeatRespVO getMySeat(Long docId) {
        RoomSeatRespVO resp = new RoomSeatRespVO();
        if (null == docId) {
            return resp;
        }
        List<RoomDO> beds = roomMapper.getRoomByDocId(docId);
        if (null == beds || beds.isEmpty()) {
            return resp;
        }
        if (beds.size() > 1) {
            // 【本需求·一人多工位】值班医生可同时占多个工位,这里不再是异常场景;
            // 本接口只为兼容旧前端/其它调用(返回第一个),完整列表用 getMySeats()。
            log.info("[getMySeat] 该医生占用了 {} 个工位,本接口返回第一个(完整列表见 /my-seats). docId={}",
                    beds.size(), docId);
        }
        fillSeatInfo(resp, beds.get(0));
        resp.setSeated(true);
        return resp;
    }
 
    @Override
    public List<RoomSeatRespVO> getMySeats(Long docId) {
        List<RoomSeatRespVO> result = new ArrayList<>();
        if (null == docId) {
            return result;
        }
        // 只取「医生在座 / 医生暂停」且未删除的工位(排除历史残留)
        List<Integer> statusList = new ArrayList<>();
        statusList.add(BedStatusEnum.DOCTOR_ON.getStatus());
        statusList.add(BedStatusEnum.DOCTOR_PAUSE.getStatus());
        List<RoomDO> beds = roomMapper.getMyActiveBeds(docId, statusList);
        if (null == beds || beds.isEmpty()) {
            return result;
        }
        for (RoomDO bed : beds) {
            RoomSeatRespVO resp = new RoomSeatRespVO();
            fillSeatInfo(resp, bed);
            resp.setSeated(true);
            result.add(resp);
        }
        return result;
    }
 
    /**
     * 【本需求·接班自助接管】把工位从"别人占用"改为"当前登录人占用"。
     *
     * <p>这是"登出不释放工位"场景下,接班医生立即用上工位的唯一入口(不需要管理员)。
     * 原占用人那台电脑会在下一次 5s 轮询收到「请重新就坐」并转只读(现有逻辑)。
     *
     * <p>「强制清空 → 我入座」在同一事务内完成,避免中间态被第三方抢到。
     */
    @Override
    @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
    public void takeOverBed(Long roomId, String bedNo) {
        Long userId = cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
        String userNickname = cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserNickname();
        if (null == userId) {
            throw exception(ROOM_NOT_OWNER);
        }
        if (null == roomId || null == bedNo || bedNo.isEmpty()) {
            throw exception(ROOM_NOT_EXISTS);
        }
        RoomDO bed = roomMapper.getRoom(roomId, bedNo);
        if (null == bed) {
            throw exception(ROOM_NOT_EXISTS);
        }
 
        // ① 本来就属于我 → 幂等成功
        if (null != bed.getDocId() && bed.getDocId().equals(userId)) {
            log.info("[takeOverBed] 工位本就属于当前登录人,无需接管. 工位={}/{} docId={}", roomId, bedNo, userId);
            return;
        }
 
        // ② 空闲 → 直接入座("接管"等价于"坐上去",省掉前端再点一次入座)
        if (null == bed.getDocId()) {
            ErrorCode onRet = queueService.startBedDoctorOn(roomId, bedNo, userId, userNickname, bed.getId());
            if (!GlobalErrorCodeConstants.SUCCESS.equals(onRet)) {
                log.warn("[takeOverBed] 空闲工位入座失败. 工位={}/{} ret={}", roomId, bedNo, onRet);
                throw exception(ROOM_INVALID_STATUS);
            }
            return;
        }
 
        // ③ 被他人占用 → 审计日志 + 强制清空 + 立即入座
        log.warn("[takeOverBed][接班接管] 工位={}/{} 原占用人={}({}) → 接管人={}({})",
                roomId, bedNo, bed.getDocName(), bed.getDocId(), userNickname, userId);
        ErrorCode offRet = queueService.startBedDoctorOffByAdmin(roomId, bedNo);
        if (!GlobalErrorCodeConstants.SUCCESS.equals(offRet)) {
            log.warn("[takeOverBed] 接管时清空原占用人失败. 工位={}/{} ret={}", roomId, bedNo, offRet);
            throw exception(ROOM_INVALID_STATUS);
        }
        ErrorCode onRet = queueService.startBedDoctorOn(roomId, bedNo, userId, userNickname, bed.getId());
        if (!GlobalErrorCodeConstants.SUCCESS.equals(onRet)) {
            log.warn("[takeOverBed] 接管后入座失败. 工位={}/{} ret={}", roomId, bedNo, onRet);
            throw exception(ROOM_INVALID_STATUS);
        }
        log.info("[takeOverBed][接班接管] 完成. 工位={}/{} 新占用人={}({})", roomId, bedNo, userNickname, userId);
    }
 
    @Override
    public void assertBedOwner(Long roomId, String bedNo) {
        Long userId = cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
        RoomDO bed = (null == roomId || null == bedNo || bedNo.isEmpty())
                ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed) {
            log.warn("[assertBedOwner] 工位不存在,拒绝写操作. roomId={} bedNo={} docId={}", roomId, bedNo, userId);
            throw exception(ROOM_NOT_OWNER);
        }
        if (null == bed.getDocId() || !bed.getDocId().equals(userId)) {
            log.warn("[assertBedOwner] 非工位主人,拒绝写操作(围观模式). roomId={} bedNo={} 我={} 主人={}({})",
                    roomId, bedNo, userId, bed.getDocName(), bed.getDocId());
            throw exception(ROOM_NOT_OWNER);
        }
    }
 
    @Override
    public void assertBedOwnerByPatient(String patId, Integer checkType) {
        QueueDO queue = resolvePatientBedQueue(patId, checkType);
        if (null == queue) {
            log.warn("[assertBedOwnerByPatient] 找不到该患者当前所在工位,拒绝写操作. patId={} checkType={}",
                    patId, checkType);
            throw exception(ROOM_NOT_OWNER);
        }
        assertBedOwner(queue.getRoomId(), queue.getBedNo());
    }
 
    @Override
    public void assertBedOwnerByPatientSoft(String patId, Integer checkType) {
        QueueDO queue = resolvePatientBedQueue(patId, checkType);
        if (null == queue) {
            // 确费/取消确费常在流程末端调用,患者可能已不在工位队列上;
            // 一律拒绝会误伤正常业务,这里只记警告放行(前端围观模式下按钮已禁用)。
            log.warn("[assertBedOwnerByPatientSoft] 找不到该患者当前所在工位,放行(仅记警告). patId={} checkType={}",
                    patId, checkType);
            return;
        }
        assertBedOwner(queue.getRoomId(), queue.getBedNo());
    }
 
    @Override
    public void assertBedOwnerIs(Long roomId, String bedNo, Long ownerDocId) {
        RoomDO bed = (null == roomId || null == bedNo || bedNo.isEmpty())
                ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed || null == bed.getDocId() || !bed.getDocId().equals(ownerDocId)) {
            log.warn("[assertBedOwnerIs] 工位当前主人与请求的 ownerDocId 不一致,拒绝. roomId={} bedNo={} 请求={} 实际={}",
                    roomId, bedNo, ownerDocId, null == bed ? null : bed.getDocId());
            throw exception(ROOM_NOT_OWNER);
        }
    }
 
    /** 按「患者 + 检查项目」找当前所在工位的那条队列行;找不到工位返回 null */
    private QueueDO resolvePatientBedQueue(String patId, Integer checkType) {
        if (null == patId) {
            return null;
        }
        QueueDO queue = queueMapper.getQueueByPatId(patId, checkType);
        if (null == queue || null == queue.getRoomId() || null == queue.getBedNo()
                || queue.getBedNo().trim().isEmpty()) {
            return null;
        }
        return queue;
    }
 
    @Override
    public Map<String, Object> diagnoseBed(Integer id, Long roomId, String bedNo) {
        Map<String, Object> data = new LinkedHashMap<>();
        String condition = String.format("id=%s, roomId=%s, bedNo=[%s](len=%s)",
                id, roomId, bedNo, null == bedNo ? null : bedNo.length());
        data.put("查询条件", condition);
 
        List<Map<String, Object>> rows = roomMapper.selectBedRawState(id, roomId, bedNo);
        data.put("匹配到的行数", rows.size());
        data.put("行明细", rows);
 
        String verdict;
        if (rows.isEmpty()) {
            verdict = "库里【查不到】这个工位(按 id 与 room_id+TRIM(bed_no) 都没匹配到)→ "
                    + "按 roomId+bedNo 入座必然更新 0 行、报“诊室和诊疗床状态异常”。"
                    + "新版已支持按 id 入座;若仍失败,请把本结果发回。";
        } else {
            Map<String, Object> row = rows.get(0);
            Integer rowStatus = toInt(row.get("status"));
            Integer rowDeleted = toInt(row.get("deleted"));
            Integer bedNoLen = toInt(row.get("bed_no_len"));
            Object rowDocId = row.get("doc_id");
            if (null != rowDeleted && 0 != rowDeleted) {
                verdict = "该工位在库里是【已删除】(deleted=1) → 入座不会命中,请恢复数据或重建工位。";
            } else if (null != bedNoLen && null != bedNo && bedNoLen != bedNo.length()) {
                verdict = String.format("库里 bed_no 的长度(%d)与请求值(%d)不一致 → 请求里的工位编号与库内不同"
                                + "(常见是结尾空格/全角),HEX=%s。新版已改为按 id 入座,可绕开该问题。",
                        bedNoLen, bedNo.length(), row.get("bed_no_hex"));
            } else if (null == rowStatus || (10 != rowStatus && 20 != rowStatus && 30 != rowStatus)) {
                verdict = "该工位状态=" + rowStatus + "(0=已关闭);不在可入座状态(可入座的为 10/20/30),"
                        + "请先在开诊设置/监控页把它开通。";
            } else if (null != rowDocId) {
                verdict = "该工位当前的主人是【" + row.get("doc_name") + "】(doc_id=" + rowDocId + "):"
                        + "若这就是你本人,说明上次没正常离座 —— 新版已支持幂等恢复入座;"
                        + "若是别人,则不能入座(不挤掉正在操作的人)。";
            } else {
                verdict = "该工位当前【可入座】。若之前仍报“状态异常”,请把本结果连同那次请求参数一起发回。";
            }
        }
        data.put("诊断结论", verdict);
        log.info("[diagnoseBed][工位状态诊断] {} → {}", condition, verdict);
        return data;
    }
 
    /** 兼容 bit(1)/Boolean/Number 的取值(诊断接口用) */
    private Integer toInt(Object v) {
        if (null == v) {
            return null;
        }
        if (v instanceof Boolean) {
            return ((Boolean) v) ? 1 : 0;
        }
        if (v instanceof Number) {
            return ((Number) v).intValue();
        }
        try {
            return Integer.valueOf(String.valueOf(v).trim());
        } catch (Exception e) {
            return null;
        }
    }
 
    /** 把工位字段拷进响应体 */
    private void fillSeatInfo(RoomSeatRespVO resp, RoomDO bed) {
        if (null == bed) {
            return;
        }
        resp.setId(bed.getId());
        resp.setRoomId(bed.getRoomId());
        resp.setRoomName(bed.getRoomName());
        resp.setBedNo(bed.getBedNo());
        resp.setIp(bed.getIp());
        resp.setStatus(bed.getStatus());
        resp.setDocId(bed.getDocId());
        resp.setDocName(bed.getDocName());
        resp.setCheckTypes(bed.getCheckTypes());
        resp.setOpType(bed.getOpType());
    }
 
    @Override
    public List<RoomDO> simpleRoomList() {
        List<BedStatusEnum> bedStatusEnumList = new ArrayList<BedStatusEnum>();
        bedStatusEnumList.add(BedStatusEnum.OPENING);
        bedStatusEnumList.add(BedStatusEnum.DOCTOR_ON);
        bedStatusEnumList.add(BedStatusEnum.DOCTOR_PAUSE);
        return  roomMapper.simpleRoomList(bedStatusEnumList);
    }
 
    @Override
    public void resetRoom(Boolean needCloseBed) {
        queueService.startResetRoom(needCloseBed);
    }
 
    @Override
    public MonitorInfoVO getMonitorInfo() {
        return queueService.getMonitorInfo();
    }
}