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;
|
}
|
}
|