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 数据采集调度编排实现 —— * 由 {@code ruoyi-quartz} 的 {@code RyTask#dealHisData} + {@code collectSegmentData} 原样搬入。 * *

搬运纪律(本类刻意"零改动")

* * *

2026-10 修正:接口 1500 条上限导致的静默丢数

* 来未来 {@code dtcQueryHealthcareRecordList} 单次最多返回 1500 条,超出部分直接截断, * 且 {@code code} 仍是 200、不报错。原"4 天一段"在出院高峰期(院内每天 380~500 条)必然超限, * 于是每段悄悄少几十条 —— 表现为"回溯跑成功了,但某月病区人数比真实值少几条"。 *

* 现在的做法(见 {@link #gatherRange}): *

    *
  1. 契约方法改为返回条数,编排侧据此判断是否触顶;
  2. *
  3. 触顶就让日期二分重采(4 天 → 2 天 → 1 天);
  4. *
  5. 单日仍触顶就按 {@code statusList} 拆状态重采(状态互斥,可无损拆分);
  6. *
  7. 连单日单状态都触顶时打 ERROR(参数已无法再拆),把"这次不完整"喊出来,而不是继续静默。
  8. *
* 二分只在触顶时发生,正常分段仍然只发 1 次请求,不会把日常回溯的请求量翻几倍。 * * *

它不管什么

* 真正的采集动作(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"; /** * 来未来查询接口的单次返回上限:超过这个条数不报错、只截断。 *

* 实测 {@code dtcQueryHealthcareRecordList} 有 1500 条硬上限({@code code} 仍为 200, * 分页参数 {@code pageNo/pageSize/current/size} 均无效),所以只能靠调用方识别并拆分。 */ private static final int HIS_QUERY_LIMIT = 1500; /** * 回溯时一段最多跨几天(4 天为历史值)。 *

* 段长只影响"平时要发几次请求",安全性由 {@link #gatherRange} 的触顶二分保证, * 所以不必为了保守把它改成 1 天(那会让日常回溯请求量翻 4 倍)。 */ private static final int BACKFILL_SEGMENT_DAYS = 4; /** * 预入院 / 入院 / 出院三类采集各自的状态码(与原实现一一对应,抽常量便于按状态拆分重采) */ private static final List PRE_ADMIT_STATUS = Collections.unmodifiableList(Arrays.asList("FH0109.22", "FH0109.23", "FH0109.53")); private static final List ADMIT_STATUS = Collections.singletonList("FH0109.24"); private static final List 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)。 *

* ⚠️ 刻意直接注入而不是 {@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 sysConfigs = configService.selectConfigList(config); //获取省立同德要采集的院区 SysConfig cf = new SysConfig(); cf.setConfigKey("sltd.gather.campusid"); cf.setOrgid(SLTD_ORG_ID); List sysConfigcampusids = configService.selectConfigList(cf); List 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 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); } } } /** * 分段采集数据(预入院、入院、出院)。 *

* 段长见 {@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 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; } /** * 采一段,必要时拆小重采,返回这段实际取到的记录数。 * *

来未来接口单次最多返回 {@link #HIS_QUERY_LIMIT} 条,超出部分直接截断且不报错, * 所以只能用"返回条数触顶"来识别,并按下面的顺序拆分(只有触顶才会拆,平时仍是 1 次请求): *

    *
  1. 跨多天:日期对半拆,递归重采;
  2. *
  3. 已缩到单天:按 {@code statusList} 拆(状态互斥,拆开既不漏也不重);
  4. *
  5. 单日单状态仍触顶:打 ERROR —— 请求参数维度已用尽,只能靠人看日志补采。
  6. *
* * @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 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; } }