liusheng
3 小时以前 c24d16a37523a49080392610357872ce580cbc38
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
package com.ruoyi.datacollection.service.impl;
 
import com.ruoyi.common.core.service.HisGatherApi;
import com.ruoyi.common.core.service.dto.SltdGatherReq;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.datacollection.service.IHisDataCollectionService;
import com.ruoyi.system.domain.SysConfig;
import com.ruoyi.system.service.ISysConfigService;
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.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
 
/**
 * HIS 数据采集调度编排实现 ——
 * <b>由 {@code ruoyi-quartz} 的 {@code RyTask#dealHisData} + {@code collectSegmentData} 原样搬入</b>。
 *
 * <h3>搬运纪律(本类刻意"零改动")</h3>
 * <ul>
 *   <li>站点分支({@code xh} / {@code hn} / {@code sltd}-{@code nhfy})、时间窗口算法、
 *       30 天分段回溯的段长与顺序、每个采集动作的先后、异常吞掉的范围、日志文案
 *       —— 全部与搬运前一致;</li>
 *   <li>唯一的形式变化:内部 VO({@code ServiceSLTDInhospReqVO} / {@code ServiceSLTDDeptReqVO} /
 *       {@code HnDataGatherVO})换成契约 DTO {@link SltdGatherReq} / 两个 {@code Date},
 *       由 smartor 侧 Bridge 逐字段搬回原 VO;字段设置顺序与"显式置 null"都保留;
 *   <li>{@code CollectionUtils.isNotEmpty(...)} 换成等价的 {@code list != null && !list.isEmpty()}
 *       (避免为一次判空给模块引 commons-collections4);</li>
 *   <li>{@code sys_config} 的读法<b>刻意保持</b> {@code selectConfigList(条件)} + 取第 0 行
 *       (含原实现的 LIKE 匹配与"不排序取首行"语义),没有改成 {@code selectConfigByKey}
 *       —— 那是"顺手优化",会改变查询语义,回归时无法逐条比对。</li>
 * </ul>
 *
 * <h3>2026-10 修正:接口 1500 条上限导致的静默丢数</h3>
 * 来未来 {@code dtcQueryHealthcareRecordList} <b>单次最多返回 1500 条</b>,超出部分直接截断,
 * 且 {@code code} 仍是 200、不报错。原"4 天一段"在出院高峰期(院内每天 380~500 条)必然超限,
 * 于是每段悄悄少几十条 —— 表现为"回溯跑成功了,但某月病区人数比真实值少几条"。
 * <p>
 * 现在的做法(见 {@link #gatherRange}):
 * <ol>
 *   <li>契约方法改为返回条数,编排侧据此判断是否触顶;</li>
 *   <li>触顶就让<b>日期二分</b>重采(4 天 → 2 天 → 1 天);</li>
 *   <li>单日仍触顶就按 {@code statusList} <b>拆状态</b>重采(状态互斥,可无损拆分);</li>
 *   <li>连单日单状态都触顶时打 ERROR(参数已无法再拆),把"这次不完整"喊出来,而不是继续静默。</li>
 * </ol>
 * 二分只在触顶时发生,正常分段仍然只发 1 次请求,不会把日常回溯的请求量翻几倍。
 *
 *
 * <h3>它不管什么</h3>
 * 真正的采集动作(HTTP 取数、入库)不在本类:那里仍在 smartor,经
 * {@link HisGatherApi} 契约调用,后续分批搬进本模块。
 *
 * @see com.ruoyi.datacollection.api.DataCollectionFacade 对外门面
 */
@Slf4j
@Service
public class HisDataCollectionServiceImpl implements IHisDataCollectionService {
 
    /**
     * 省立同德 / 南华附一的机构ID(原实现里的字面量 {@code "20001001"})
     */
    private static final String SLTD_ORG_ID = "20001001";
 
    /**
     * 来未来查询接口的单次返回上限:超过这个条数<b>不报错、只截断</b>。
     * <p>
     * 实测 {@code dtcQueryHealthcareRecordList} 有 1500 条硬上限({@code code} 仍为 200,
     * 分页参数 {@code pageNo/pageSize/current/size} 均无效),所以只能靠调用方识别并拆分。
     */
    private static final int HIS_QUERY_LIMIT = 1500;
 
    /**
     * 回溯时一段最多跨几天(4 天为历史值)。
     * <p>
     * 段长只影响"平时要发几次请求",安全性由 {@link #gatherRange} 的触顶二分保证,
     * 所以不必为了保守把它改成 1 天(那会让日常回溯请求量翻 4 倍)。
     */
    private static final int BACKFILL_SEGMENT_DAYS = 4;
 
    /**
     * 预入院 / 入院 / 出院三类采集各自的状态码(与原实现一一对应,抽常量便于按状态拆分重采)
     */
    private static final List<String> PRE_ADMIT_STATUS = Collections.unmodifiableList(Arrays.asList("FH0109.22", "FH0109.23", "FH0109.53"));
 
    private static final List<String> ADMIT_STATUS = Collections.singletonList("FH0109.24");
 
    private static final List<String> DISCHARGE_STATUS = Collections.unmodifiableList(Arrays.asList("FH0109.27", "FH0109.25"));
 
    /**
     * 诊疗记录的三类采集口径:决定日期条件落在 DTO 的哪一组字段上。
     */
    private enum RecordKind {
 
        PRE_ADMIT("预入院"),
        ADMIT("入院"),
        DISCHARGE("出院");
 
        private final String label;
 
        RecordKind(String label) {
            this.label = label;
        }
    }
 
    /**
     * 当时站点标识(原 {@code RyTask} 的 {@code @Value("${spring.profiles.active}")})
     */
    @Value("${spring.profiles.active}")
    private String active;
 
    /**
     * HIS 采集能力契约(当前实现在 smartor:纯转发到既有 Service)。
     * <p>
     * ⚠️ 刻意<b>直接注入</b>而不是 {@code ObjectProvider} 惰性取:
     * 采集实现是数据链路的必需件,"取不到就静默不采"正是最危险的失败形态,
     * 这里让它启动期就暴露(宁可启动失败,也不要静默少数据)。
     */
    @Autowired
    private HisGatherApi hisGatherApi;
 
    /**
     * 参数配置服务(ruoyi-system 的 API):读 {@code sltd.gather.sysuser} / {@code sltd.gather.campusid}
     */
    @Autowired
    private ISysConfigService configService;
 
    /**
     * HIS数据采集
     * 定时任务:采集患者信息、出院、门诊数据
     *
     * @param todayZeroStr 当前的时间   例如:2026-05-06 00:00:00
     */
    @Override
    public void dealHisData(String todayZeroStr) {
        log.info("【dealHisData】开始执行HIS数据采集任务");
        // 获取昨天0点到今天0点的时间范围
        LocalDateTime todayZero = LocalDateTime.now().with(LocalTime.MIN);
        if (StringUtils.isNotBlank(todayZeroStr)) {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            todayZero = LocalDateTime.parse(todayZeroStr, formatter);
        }
        LocalDateTime yesterdayZero = todayZero.minusDays(1);
        log.info("【dealHisData】采集时间范围:{} ~ {}", yesterdayZero, todayZero);
 
        if (active.trim().equals("xh")) {
            try {
                // 采集入院数据
                log.info("【dealHisData】新华开始采集入院数据");
                hisGatherApi.gatherXhInhospRecords("0", yesterdayZero, todayZero);
                log.info("【dealHisData】新华入院数据采集完成");
 
                // 采集出院数据
                log.info("【dealHisData】新华开始采集出院数据");
                hisGatherApi.gatherXhInhospRecords("1", yesterdayZero, todayZero);
                log.info("【dealHisData】新华出院数据采集完成");
 
            } catch (Exception e) {
                log.error("【dealHisData】新华数据采集异常", e);
            }
        } else if (active.trim().equals("hn")) {
            try {
                // 河南数据采集
                log.info("【dealHisData】河南开始采集数据");
                Date startTime = Date.from(yesterdayZero.atZone(ZoneId.systemDefault()).toInstant());
                Date endTime = Date.from(todayZero.atZone(ZoneId.systemDefault()).toInstant());
 
                hisGatherApi.gatherHnRecords(startTime, endTime);
                log.info("【dealHisData】河南结束采集数据");
            } catch (Exception e) {
                log.error("【dealHisData】河南数据采集异常", e);
            }
        } else if (active.trim().equals("sltd") || active.trim().equals("nhfy")) {
            //省立同德与华南附一用同一套采集方法(都是来未来提供)
 
            try {
                //获取是否需要采集用户、部门信息
                SysConfig config = new SysConfig();
                config.setConfigKey("sltd.gather.sysuser");
                config.setOrgid(SLTD_ORG_ID);
                List<SysConfig> sysConfigs = configService.selectConfigList(config);
 
                //获取省立同德要采集的院区
                SysConfig cf = new SysConfig();
                cf.setConfigKey("sltd.gather.campusid");
                cf.setOrgid(SLTD_ORG_ID);
                List<SysConfig> sysConfigcampusids = configService.selectConfigList(cf);
 
                List<Long> campusidList = new ArrayList<>();
                if (sysConfigcampusids != null && !sysConfigcampusids.isEmpty()) {
                    String[] split = sysConfigcampusids.get(0).getConfigValue().split(",");
                    campusidList = Arrays.stream(split).map(Long::valueOf).collect(Collectors.toList());
                } else {
                    log.error("SysConfig中的院区ID为空!");
                    return;
                }
 
                //configValue = 1 不需要采集       configValue = 2需要采集
                if (sysConfigs != null && !sysConfigs.isEmpty() && sysConfigs.get(0).getConfigValue().equals("2")) {
                    log.info("【dealHisData】省立同德“部门信息”开始采集数据");
                    SltdGatherReq serviceSLTDDeptReqVO = new SltdGatherReq();
                    serviceSLTDDeptReqVO.setOrgId(SLTD_ORG_ID);
                    serviceSLTDDeptReqVO.setCampusIds(campusidList);
                    Boolean aBoolean1 = hisGatherApi.gatherSltdDeptWardArea(serviceSLTDDeptReqVO);
                    log.info("【dealHisData】省立同德“部门信息”结束采集数据:{}", aBoolean1);
 
                    Boolean aBoolean = false;
                    for (Long cid : campusidList) {
                        log.info("【dealHisData】省立同德“用户信息”开始采集数据,院区:{}", cid);
                        SltdGatherReq reqVO = new SltdGatherReq();
                        reqVO.setOrgId(SLTD_ORG_ID);
                        reqVO.setCampusId(cid);
                        ArrayList<String> statusList = new ArrayList<>();
                        statusList.add("1");
                        reqVO.setStatusList(statusList);
                        aBoolean = hisGatherApi.gatherSltdHospUser(reqVO);
                    }
                    log.info("【dealHisData】省立同德“用户信息”结束采集数据:{}", aBoolean);
                    //疾病字典采集
                    hisGatherApi.gatherIcd10();
                }
 
                // nhfy/sltd回溯30天分段采集判断(每段最多 BACKFILL_SEGMENT_DAYS 天,触顶自动二分)
                boolean isBackfill = (active.trim().equals("nhfy") || active.trim().equals("sltd")) && StringUtils.isNotBlank(todayZeroStr);
                if (isBackfill) {
                    DateTimeFormatter dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
                    LocalDate segEndDate = todayZero.toLocalDate();
                    // 窗口 = [入参日期 - 30 天, 入参日期],两端都含。
                    // ⚠️ 入参日期"之后"的数据不在窗口内:想补 2026-07-31 就要传 2026-07-31,传 07-30 不会带出 7/31。
                    LocalDate segStart = segEndDate.minusDays(30);
                    int segCount = 0;
                    long fetchedTotal = 0L;
                    while (!segStart.isAfter(segEndDate)) {
                        LocalDate segEnd = segStart.plusDays(BACKFILL_SEGMENT_DAYS - 1L);
                        if (segEnd.isAfter(segEndDate)) {
                            segEnd = segEndDate;
                        }
                        segCount++;
                        log.info("【dealHisData】{}回溯第{}段采集,日期范围:{} ~ {}", active.trim(), segCount, segStart.format(dateFmt), segEnd.format(dateFmt));
                        for (Long cid : campusidList) {
                            fetchedTotal += collectSegmentData(cid, segStart.format(dateFmt), segEnd.format(dateFmt));
                        }
                        segStart = segEnd.plusDays(1);
                    }
                    log.info("【dealHisData】{}回溯采集完成,共{}段,窗口{} ~ {},接口返回记录合计{}条", active.trim(), segCount,
                            segEndDate.minusDays(30).format(dateFmt), segEndDate.format(dateFmt), fetchedTotal);
                }
 
                // 省立同德数据采集(非回溯模式才执行)
                if (!isBackfill) {
                    DateTimeFormatter dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
                    for (Long cid : campusidList) {
                        // sltd 只采"今天";nhfy 采"昨天 ~ 今天"(与原实现一致)
                        String nowTime = LocalDate.now().format(dateFmt);
                        String head = nowTime;
                        String tail = nowTime;
                        if (active.trim().equals("nhfy")) {
                            head = yesterdayZero.format(dateFmt);
                            tail = todayZero.format(dateFmt);
                        }
 
                        log.info("【dealHisData】省立同德“预入院”开始采集数据,院区:{}", cid);
                        gatherRange(cid, RecordKind.PRE_ADMIT, head, tail, PRE_ADMIT_STATUS);
                        log.info("【dealHisData】省立同德“预入院”結束采集数据,院区:{}", cid);
 
                        log.info("【dealHisData】省立同德“入院”开始采集数据,院区:{}", cid);
                        gatherRange(cid, RecordKind.ADMIT, head, tail, ADMIT_STATUS);
                        log.info("【dealHisData】省立同德“入院”結束采集数据,院区:{}", cid);
 
                        log.info("【dealHisData】省立同德“出院”开始采集数据,院区:{}", cid);
                        gatherRange(cid, RecordKind.DISCHARGE, head, tail, DISCHARGE_STATUS);
                        log.info("【dealHisData】省立同德“出院”結束采集数据,院区:{}", cid);
                    }
                }
 
            } catch (Exception e) {
                log.error("【dealHisData】省立同德数据采集异常", e);
            }
        }
    }
 
    /**
     * 分段采集数据(预入院、入院、出院)。
     * <p>
     * 段长见 {@link #BACKFILL_SEGMENT_DAYS},每类采集内部还会做"触顶二分"(见 {@link #gatherRange}),
     * 因此不会因为来未来接口 1500 条上限而悄悄少数据。
     *
     * @param cid          院区ID
     * @param startDateStr 日期开始 yyyy-MM-dd
     * @param endDateStr   日期结束 yyyy-MM-dd
     * @return 本段三类采集接口返回的记录数合计(回溯结束时用于总量核对)
     */
    private int collectSegmentData(Long cid, String startDateStr, String endDateStr) {
        int count = 0;
 
        log.info("【dealHisData】预入院开始采集数据,院区:{},时间段:{} ~ {}", cid, startDateStr, endDateStr);
        count += gatherRange(cid, RecordKind.PRE_ADMIT, startDateStr, endDateStr, PRE_ADMIT_STATUS);
        log.info("【dealHisData】预入院終了采集数据,院区:{}", cid);
 
        log.info("【dealHisData】入院开始采集数据,院区:{},时间段:{} ~ {}", cid, startDateStr, endDateStr);
        count += gatherRange(cid, RecordKind.ADMIT, startDateStr, endDateStr, ADMIT_STATUS);
        log.info("【dealHisData】入院終了采集数据,院区:{}", cid);
 
        log.info("【dealHisData】出院开始采集数据,院区:{},时间段:{} ~ {}", cid, startDateStr, endDateStr);
        count += gatherRange(cid, RecordKind.DISCHARGE, startDateStr, endDateStr, DISCHARGE_STATUS);
        log.info("【dealHisData】出院结束采集数据,院区:{}", cid);
 
        return count;
    }
 
    /**
     * 按采集口径组装入参:三类的区别只在"日期条件落在哪一组字段上"。
     *
     * @param cid      院区ID
     * @param kind     预入院 / 入院 / 出院
     * @param from     起始日期 yyyy-MM-dd
     * @param to       截止日期 yyyy-MM-dd(含)
     * @param statuses 该口径对应的状态码
     */
    private SltdGatherReq buildReq(Long cid, RecordKind kind, String from, String to, List<String> statuses) {
        SltdGatherReq req = new SltdGatherReq();
        req.setOrgId(SLTD_ORG_ID);
        req.setCampusId(cid);
        switch (kind) {
            case PRE_ADMIT:
                req.setEncounterTimeStart(from);
                req.setEncounterTimeEnd(to);
                break;
            case ADMIT:
                req.setStartHeadTime(from);
                req.setStartTailTime(to);
                break;
            case DISCHARGE:
                req.setPreOutHospitalHeadDate(from);
                req.setPreOutHospitalTailDate(to);
                // 出院采集只按出院时间过滤,诊疗类型显式置 null(与原实现一致)
                req.setHealthcareRecordTypeList(null);
                break;
            default:
                break;
        }
        req.setStatusList(statuses);
        return req;
    }
 
    /**
     * 采一段,必要时拆小重采,返回这段实际取到的记录数。
     *
     * <p>来未来接口单次最多返回 {@link #HIS_QUERY_LIMIT} 条,超出部分<b>直接截断且不报错</b>,
     * 所以只能用"返回条数触顶"来识别,并按下面的顺序拆分(<b>只有触顶才会拆,平时仍是 1 次请求</b>):
     * <ol>
     *   <li>跨多天:日期对半拆,递归重采;</li>
     *   <li>已缩到单天:按 {@code statusList} 拆(状态互斥,拆开既不漏也不重);</li>
     *   <li>单日单状态仍触顶:打 ERROR —— 请求参数维度已用尽,只能靠人看日志补采。</li>
     * </ol>
     *
     * @param cid      院区ID
     * @param kind     预入院 / 入院 / 出院
     * @param from     起始日期 yyyy-MM-dd
     * @param to       截止日期 yyyy-MM-dd(含)
     * @param statuses 该口径对应的状态码
     * @return 实际取到的记录数(拆分后为各子段之和)
     */
    private int gatherRange(Long cid, RecordKind kind, String from, String to, List<String> statuses) {
        SltdGatherReq req = buildReq(cid, kind, from, to, statuses);
        log.info("【dealHisData】{}采集入参:{}", kind.label, req);
 
        int count;
        try {
            count = hisGatherApi.gatherSltdHealthcareRecords(req);
        } catch (Exception e) {
            // 单类数据失败不向上抛:否则一段失败会让本次回溯后面的所有段都不执行
            log.error("【dealHisData】{}采集失败,跳过该类继续。院区:{},时间段:{} ~ {}", kind.label, cid, from, to, e);
            return 0;
        }
        if (count < HIS_QUERY_LIMIT) {
            return count;
        }
 
        // 命中上限:接口不报错,只是把超出的数据丢掉,必须拆小重采
        LocalDate start = LocalDate.parse(from);
        LocalDate end = LocalDate.parse(to);
        if (start.isBefore(end)) {
            LocalDate mid = start.plusDays(ChronoUnit.DAYS.between(start, end) / 2);
            log.warn("【dealHisData】{}一次返回触达接口上限 {} 条,按日期二分重采。院区:{},{} ~ {} => {} ~ {} / {} ~ {}",
                    kind.label, HIS_QUERY_LIMIT, cid, from, to, from, mid, mid.plusDays(1), to);
            return gatherRange(cid, kind, from, mid.toString(), statuses)
                    + gatherRange(cid, kind, mid.plusDays(1).toString(), to, statuses);
        }
        if (statuses.size() > 1) {
            log.warn("【dealHisData】{}单日仍触达接口上限 {} 条,按状态拆分重采。院区:{},日期:{},状态:{}",
                    kind.label, HIS_QUERY_LIMIT, cid, from, statuses);
            int sum = 0;
            for (String status : statuses) {
                sum += gatherRange(cid, kind, from, to, Collections.singletonList(status));
            }
            return sum;
        }
        log.error("【dealHisData】{}单日单状态仍触达接口上限 {} 条,已无法再拆分,本次数据可能不完整!院区:{},日期:{},状态:{}",
                kind.label, HIS_QUERY_LIMIT, cid, from, statuses);
        return count;
    }
}