liusheng
4 天以前 0bbbd746e8c39d91ad7b47a4e36e29ab972dc98c
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
package com.ruoyi.datacollection.service.impl;
 
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.core.service.HisGatherApi;
import com.ruoyi.common.core.service.dto.InhospPositionRecord;
import com.ruoyi.common.core.service.dto.SltdGatherReq;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.datacollection.service.IInhospDeptRefreshService;
import com.ruoyi.inhospvisit.api.InhospVisitFacade;
import com.ruoyi.inhospvisit.domain.PatMedInhosp;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
 
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
 
/**
 * 在院患者"科室 / 病区 / 床位"每日刷新 —— 编排与"改不改"的判定都在本类。
 *
 * <h3>它解决什么</h3>
 * 在院患者会换科室、换病区、换床。{@code pat_med_inhosp} 的位置字段只在"入院采集"时写一次,
 * 之后不会自己更新 —— 于是转科/换床患者会一直停在入院当时的位置
 * (实测 120 人抽样:25 人床位与 HIS 不一致、约 9% 的患者当天已出院但库里仍标在院)。
 * 本类每天全量核对一次,把位置纠回 HIS 的现状。
 *
 * <h3>取数口径({@code dtcQueryHealthcareRecordList},与入院采集同一个接口)</h3>
 * 按 {@code healthcareRecordNo}(= {@code serialnum})单查,配
 * {@code statusList=["FH0109.24"]}(在院),返回的是<b>该患者此刻的状态</b>:
 * <ul>
 *   <li>{@code data} 恰 1 条 → 当前位置见 {@link InhospPositionRecord};</li>
 *   <li>{@code data} 为 {@code []} → 该患者当前<b>不在"入院"状态</b>
 *       (实测这批就是"当天已出院、但库里还是在院"的患者:{@code FH0109.27}
 *       且 {@code preOutHospitalDate} 就在几小时前)→ <b>跳过并计数,不动库内位置</b>;
 *       状态本身由出院采集负责,这里不改 {@code inhospstate}。</li>
 * </ul>
 * 与"分段历史"接口相比,这条路没有"取最新一段"和"空入科时间"的坑,
 * 且 {@code bedNumber} 在院患者实测恒有值 —— 所以"床位为空则清空"可以按字面执行。
 *
 * <h3>字段映射(与"入院采集"同源,故不会出现两套口径)</h3>
 * <pre>
 *   pat_med_inhosp.deptcode                &lt;- String.valueOf(deptId)
 *   pat_med_inhosp.deptname                &lt;- deptName
 *   pat_med_inhosp.hospitaldistrictcode    &lt;- String.valueOf(areaId)
 *   pat_med_inhosp.hospitaldistrictid      &lt;- String.valueOf(areaId)
 *   pat_med_inhosp.hospitaldistrictname    &lt;- areaName
 *   pat_med_inhosp.bed_no                  &lt;- bedNumber(为空则清空)
 * </pre>
 *
 * <h3>刻意不碰的字段</h3>
 * {@code starttime}(入院时间)、{@code endtime}、{@code drcode/drname}、
 * {@code roomno}、{@code deptid}、{@code schemestatus}、
 * {@code deptcheck_flag}/{@code wardcheck_flag} 等任务标志。
 * <b>也不新建"转科"记录</b>:{@code inhospstate} 保持 0,只就地改位置。
 *
 * <h3>失败的处置</h3>
 * 单条失败只记日志并继续下一条;连续失败达到 {@link #MAX_CONSECUTIVE_FAIL} 判定接口不可用、
 * 本轮提前结束(与门急诊采集同款纪律),剩余患者下一轮重新全量核对,不会漏。
 *
 * @see com.ruoyi.datacollection.api.DataCollectionFacade 对外门面
 */
@Slf4j
@Service
public class InhospDeptRefreshServiceImpl implements IInhospDeptRefreshService {
 
    /**
     * 仅南华附一启用:接口按站点配置 {@code sltd_pub_path} 与 {@code lwl_app_key}
     * (南华的 app-key 实测可用;其他站点未验证,故不做默认放开)
     */
    private static final String SITE_NHFY = "nhfy";
 
    /**
     * 机构ID(与 {@code HisDataCollectionServiceImpl} / 采集实现里的字面量一致)
     */
    private static final String ORG_ID = "20001001";
 
    /**
     * 院区ID:按约定固定传南华附一主院区(实测按 {@code healthcareRecordNo} 单查时
     * 该参数不参与过滤,传 30001002 或 30038001 返回的都是同一条记录)
     */
    private static final Long CAMPUS_ID = 30001002L;
 
    /**
     * 只取"在院"(入院)状态的记录;已出院/预出院会被 HIS 侧过滤掉,于是返回空列表
     */
    private static final List<String> STATUS_IN_HOSP =
            Collections.unmodifiableList(Arrays.asList("FH0109.24"));
 
    /**
     * 在院状态码(用于校验接口返回的那条记录确实是在院)
     */
    private static final String STATUS_CODE_IN_HOSP = "FH0109.24";
 
    /**
     * 运行锁:sys_job 已配"禁止并发",这里再防一手"手工触发叠加定时任务"
     */
    private static final String RUN_LOCK_KEY = "datacollection:inhospDept:running";
 
    /**
     * 运行锁 TTL:一轮正常约 7~10 分钟(实测 152ms/次 × 约 2900 人),给足余量
     */
    private static final long RUN_LOCK_MINUTES = 120L;
 
    /**
     * 连续失败阈值:达到即判定接口不可用,本轮提前结束(避免几千条错误日志 + 空转)
     */
    private static final int MAX_CONSECUTIVE_FAIL = 20;
 
    /**
     * 站点标识({@code @Value("${spring.profiles.active}")},与既有采集编排同款)
     */
    @Value("${spring.profiles.active}")
    private String active;
 
    /**
     * HIS 采集能力契约(smartor 侧 Bridge 纯转发到 healthcareRecord 查询,只取数不落库)
     */
    @Autowired
    private HisGatherApi hisGatherApi;
 
    /**
     * 出入院数据契约({@code pat_med_inhosp} 归属 ruoyi-inhospvisit):
     * 取在院候选 + 写回位置。刻意不碰该模块的 Mapper。
     */
    @Autowired
    private InhospVisitFacade inhospVisitFacade;
 
    /**
     * 运行锁(ruoyi-common 的 RedisCache)
     */
    @Autowired
    private RedisCache redisCache;
 
    @Override
    public int refreshInhospDeptInfo() {
        String site = active == null ? "" : active.trim();
        if (!SITE_NHFY.equals(site)) {
            log.info("【在院信息刷新】当前站点 {} 不执行(仅 nhfy 启用该接口),跳过", active);
            return 0;
        }
        if (!tryLock()) {
            log.warn("【在院信息刷新】上一轮尚未结束(Redis 锁 {} 还在),本轮跳过", RUN_LOCK_KEY);
            return 0;
        }
 
        long startMs = System.currentTimeMillis();
        int changed = 0;
        int unchanged = 0;
        int notInHosp = 0;
        int skipped = 0;
        int failed = 0;
        int consecutiveFail = 0;
        boolean aborted = false;
        try {
            // 只查 pat_med_inhosp 自身(不 join 档案表、不按证件号过滤),在院口径 inhospstate='0' and del_flag='0'
            List<PatMedInhosp> candidates = inhospVisitFacade.selectInhospRefreshCandidates(ORG_ID);
            if (candidates == null || candidates.isEmpty()) {
                log.warn("【在院信息刷新】未取到任何在院患者(orgid={}),本轮不做事", ORG_ID);
                return 0;
            }
            log.info("【在院信息刷新】开始:在院患者 {} 人,逐人核对科室/病区/床位", candidates.size());
 
            for (PatMedInhosp row : candidates) {
                try {
                    Outcome outcome = refreshOne(row);
                    if (outcome == Outcome.CHANGED) {
                        changed++;
                    } else if (outcome == Outcome.UNCHANGED) {
                        unchanged++;
                    } else if (outcome == Outcome.NOT_IN_HOSP) {
                        notInHosp++;
                    } else {
                        skipped++;
                    }
                    consecutiveFail = 0;
                } catch (Exception e) {
                    failed++;
                    consecutiveFail++;
                    log.error("【在院信息刷新】单条失败已跳过:inhospid={}, serialnum={}, patname={}",
                            row.getInhospid(), row.getSerialnum(), row.getPatname(), e);
                    if (consecutiveFail >= MAX_CONSECUTIVE_FAIL) {
                        aborted = true;
                        log.error("【在院信息刷新】连续 {} 人失败,判定接口不可用,本轮提前结束(已更新 {} 条);"
                                + "剩余患者下一轮会重新全量核对,不会漏", consecutiveFail, changed);
                        break;
                    }
                }
            }
 
            log.info("【在院信息刷新】结束:在院 {} 人,更新 {},无变化 {},已不在院 {},跳过 {},失败 {}{},耗时 {} 秒",
                    candidates.size(), changed, unchanged, notInHosp, skipped, failed,
                    aborted ? "(接口连续失败,提前结束)" : "",
                    (System.currentTimeMillis() - startMs) / 1000);
            return changed;
        } finally {
            unlock();
        }
    }
 
    /**
     * 核对并(必要时)更新一个在院患者的位置。
     *
     * @return 本次对该行的处置结果
     */
    private Outcome refreshOne(PatMedInhosp row) {
        String serialnum = StringUtils.trim(row.getSerialnum());
        if (StringUtils.isBlank(serialnum)) {
            log.warn("【在院信息刷新】在院记录没有流水号,无法核对,跳过:inhospid={}", row.getInhospid());
            return Outcome.SKIPPED;
        }
 
        SltdGatherReq req = new SltdGatherReq();
        req.setOrgId(ORG_ID);
        req.setCampusId(CAMPUS_ID);
        req.setStatusList(STATUS_IN_HOSP);
        req.setHealthcareRecordNo(serialnum);
 
        List<InhospPositionRecord> records = hisGatherApi.querySltdInhospPosition(req);
        InhospPositionRecord current = pickInHospRecord(records, serialnum);
        if (current == null) {
            // 接口按在院状态过滤后没有返回记录:该患者当前已不在"入院"状态。
            // 这里只计数、不改库(状态翻转是出院采集的职责),更不拿空值去覆盖位置。
            log.warn("【在院信息刷新】HIS 中该患者已不在院(返回为空),位置保持不动,等出院采集处理状态:"
                    + "inhospid={}, serialnum={}, patname={}", row.getInhospid(), serialnum, row.getPatname());
            return Outcome.NOT_IN_HOSP;
        }
        return applyIfDiff(row, current) ? Outcome.CHANGED : Outcome.UNCHANGED;
    }
 
    /**
     * 从返回记录里取"在院"的那一条。
     * <p>
     * 正常只会返回 1 条;这里仍做一层防御:只接受状态为在院的记录,
     * 一条都不匹配则返回 {@code null}(按"已不在院"处理),
     * 绝不拿一条状态不明的记录去覆盖库内位置。
     */
    private InhospPositionRecord pickInHospRecord(List<InhospPositionRecord> records, String serialnum) {
        if (records == null || records.isEmpty()) {
            return null;
        }
        for (InhospPositionRecord record : records) {
            if (record == null) {
                continue;
            }
            String status = StringUtils.trim(record.getHealthcareRecordStatus());
            if (STATUS_CODE_IN_HOSP.equals(status)) {
                return record;
            }
            log.warn("【在院信息刷新】返回记录状态不是在院,已忽略:serialnum={}, status={}", serialnum, status);
        }
        return null;
    }
 
    /**
     * 与库内值逐字段比对,只把"变化的字段"写回;没有任何变化则不动库(也不刷新 update_time)。
     *
     * @return 是否实际执行了更新
     */
    private boolean applyIfDiff(PatMedInhosp row, InhospPositionRecord current) {
        PatMedInhosp update = new PatMedInhosp();
        update.setInhospid(row.getInhospid());
        StringBuilder changedFields = new StringBuilder();
        boolean diff = false;
 
        // 科室:有值才更新(口径:科室有值更新科室,病区有值更新病区,互不牵连)
        if (current.getDeptId() != null) {
            String deptCode = String.valueOf(current.getDeptId());
            String old = StringUtils.trimToEmpty(row.getDeptcode());
            if (!deptCode.equals(old)) {
                update.setDeptcode(deptCode);
                changedFields.append("deptcode[").append(old).append("→").append(deptCode).append("] ");
                diff = true;
            }
        }
        if (StringUtils.isNotBlank(current.getDeptName())) {
            String old = StringUtils.trimToEmpty(row.getDeptname());
            if (!current.getDeptName().equals(old)) {
                update.setDeptname(current.getDeptName());
                changedFields.append("deptname[").append(old).append("→").append(current.getDeptName()).append("] ");
                diff = true;
            }
        }
 
        // 病区:code 与 id 同值(既有采集口径就是两个字段都写 areaId)
        if (current.getAreaId() != null) {
            String areaId = String.valueOf(current.getAreaId());
            String oldCode = StringUtils.trimToEmpty(row.getHospitaldistrictcode());
            if (!areaId.equals(oldCode)) {
                update.setHospitaldistrictcode(areaId);
                changedFields.append("hospitaldistrictcode[").append(oldCode).append("→").append(areaId).append("] ");
                diff = true;
            }
            String oldAreaId = StringUtils.trimToEmpty(row.getHospitaldistrictid());
            if (!areaId.equals(oldAreaId)) {
                update.setHospitaldistrictid(areaId);
                changedFields.append("hospitaldistrictid[").append(oldAreaId).append("→").append(areaId).append("] ");
                diff = true;
            }
        }
        if (StringUtils.isNotBlank(current.getAreaName())) {
            String old = StringUtils.trimToEmpty(row.getHospitaldistrictname());
            if (!current.getAreaName().equals(old)) {
                update.setHospitaldistrictname(current.getAreaName());
                changedFields.append("hospitaldistrictname[").append(old).append("→").append(current.getAreaName()).append("] ");
                diff = true;
            }
        }
 
        // 床位:按业务口径"接口给什么就是什么,为空则清空"。
        // ⚠️ mapper 里"空串=置 null、null=不改动",所以清空必须显式传空串。
        String apiBed = StringUtils.trim(current.getBedNumber());
        String dbBed = StringUtils.trimToEmpty(row.getBedNo());
        if (StringUtils.isBlank(apiBed)) {
            if (StringUtils.isNotBlank(dbBed)) {
                update.setBedNo("");
                changedFields.append("bed_no[").append(dbBed).append("→(清空)] ");
                diff = true;
            }
        } else if (!apiBed.equals(dbBed)) {
            update.setBedNo(apiBed);
            changedFields.append("bed_no[").append(dbBed).append("→").append(apiBed).append("] ");
            diff = true;
        }
 
        if (!diff) {
            return false;
        }
        update.setUpdateTime(new Date());
        int rows = inhospVisitFacade.updatePatMedInhosp(update);
        if (rows <= 0) {
            log.warn("【在院信息刷新】更新未生效(影响行数 {}):inhospid={}, serialnum={}, 待改字段:{}",
                    rows, row.getInhospid(), row.getSerialnum(), changedFields);
            return false;
        }
        log.info("【在院信息刷新】已更新:inhospid={}, serialnum={}, patname={}, 变更:{}",
                row.getInhospid(), row.getSerialnum(), row.getPatname(), changedFields);
        return true;
    }
 
    /**
     * 抢占本轮运行锁。
     * <p>
     * Redis 不可用时<b>退化为无锁执行</b>:每天一次的数据刷新不该因为缓存故障整体不跑,
     * 最坏情况是手工触发与定时任务叠着跑 —— 而本功能是幂等比对,叠跑只会多打几条日志。
     */
    private boolean tryLock() {
        try {
            if (Boolean.TRUE.equals(redisCache.hasKey(RUN_LOCK_KEY))) {
                return false;
            }
            redisCache.setCacheObject(RUN_LOCK_KEY, String.valueOf(System.currentTimeMillis()),
                    RUN_LOCK_MINUTES, TimeUnit.MINUTES);
            return true;
        } catch (Exception e) {
            log.warn("【在院信息刷新】Redis 运行锁不可用({}),本轮无锁执行", e.getMessage());
            return true;
        }
    }
 
    private void unlock() {
        try {
            redisCache.deleteObject(RUN_LOCK_KEY);
        } catch (Exception e) {
            log.warn("【在院信息刷新】释放运行锁失败:{}", e.getMessage());
        }
    }
 
    /**
     * 单条患者的处置结果
     */
    private enum Outcome {
        /**
         * 位置有变化并已写回
         */
        CHANGED,
        /**
         * 与库内一致,未改动
         */
        UNCHANGED,
        /**
         * HIS 里该患者已不在"入院"状态(接口返回为空),库内位置保持不动
         */
        NOT_IN_HOSP,
        /**
         * 拿不到可用信息(无流水号 / 返回记录状态不对)
         */
        SKIPPED
    }
}