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; /** * 在院患者"科室 / 病区 / 床位"每日刷新 —— 编排与"改不改"的判定都在本类。 * *

它解决什么

* 在院患者会换科室、换病区、换床。{@code pat_med_inhosp} 的位置字段只在"入院采集"时写一次, * 之后不会自己更新 —— 于是转科/换床患者会一直停在入院当时的位置 * (实测 120 人抽样:25 人床位与 HIS 不一致、约 9% 的患者当天已出院但库里仍标在院)。 * 本类每天全量核对一次,把位置纠回 HIS 的现状。 * *

取数口径({@code dtcQueryHealthcareRecordList},与入院采集同一个接口)

* 按 {@code healthcareRecordNo}(= {@code serialnum})单查,配 * {@code statusList=["FH0109.24"]}(在院),返回的是该患者此刻的状态: * * 与"分段历史"接口相比,这条路没有"取最新一段"和"空入科时间"的坑, * 且 {@code bedNumber} 在院患者实测恒有值 —— 所以"床位为空则清空"可以按字面执行。 * *

字段映射(与"入院采集"同源,故不会出现两套口径)

*
 *   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(为空则清空)
 * 
* *

刻意不碰的字段

* {@code starttime}(入院时间)、{@code endtime}、{@code drcode/drname}、 * {@code roomno}、{@code deptid}、{@code schemestatus}、 * {@code deptcheck_flag}/{@code wardcheck_flag} 等任务标志。 * 也不新建"转科"记录:{@code inhospstate} 保持 0,只就地改位置。 * *

失败的处置

* 单条失败只记日志并继续下一条;连续失败达到 {@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 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 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 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; } /** * 从返回记录里取"在院"的那一条。 *

* 正常只会返回 1 条;这里仍做一层防御:只接受状态为在院的记录, * 一条都不匹配则返回 {@code null}(按"已不在院"处理), * 绝不拿一条状态不明的记录去覆盖库内位置。 */ private InhospPositionRecord pickInHospRecord(List 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; } /** * 抢占本轮运行锁。 *

* Redis 不可用时退化为无锁执行:每天一次的数据刷新不该因为缓存故障整体不跑, * 最坏情况是手工触发与定时任务叠着跑 —— 而本功能是幂等比对,叠跑只会多打几条日志。 */ 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 } }