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 <- String.valueOf(deptId)
|
* pat_med_inhosp.deptname <- deptName
|
* pat_med_inhosp.hospitaldistrictcode <- String.valueOf(areaId)
|
* pat_med_inhosp.hospitaldistrictid <- String.valueOf(areaId)
|
* pat_med_inhosp.hospitaldistrictname <- areaName
|
* pat_med_inhosp.bed_no <- 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
|
}
|
}
|