package com.ruoyi.outpatientvisit.service; import com.ruoyi.common.core.service.ArchiveReadApi; import com.ruoyi.common.core.service.SubtaskStatApi; import com.ruoyi.common.core.service.VisitTaskCreateApi; import com.ruoyi.common.core.service.dto.PatMedReq; import com.ruoyi.common.core.service.dto.PatMedRes; import com.ruoyi.common.core.service.dto.VisitArchiveRow; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.DtoConversionUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.outpatientvisit.domain.PatMedOuthosp; import com.ruoyi.outpatientvisit.domain.PatMedOuthospQueryReq; import com.ruoyi.outpatientvisit.mapper.PatMedOuthospMapper; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import java.time.LocalDate; import java.time.Period; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.*; /** * 门诊记录基础数据服务:查询(含展示富化)/ 统计 / 增删改 / 分表路由。 * *

迁移对应(@ b25c3b51 · {@code PatMedOuthospServiceImpl})

* * * * * * * * * * * * * *
本类方法原方法
{@link #selectList(PatMedOuthosp)}{@code selectPatMedOuthospList} L83-133
{@link #calculateAge}{@code calculateAge} L135-185
{@link #insert(PatMedOuthosp)}{@code insertPatMedOuthosp} L187-204
{@link #selectExistingSerialnums}同名方法 L210-259
{@link #update(PatMedOuthosp)}{@code updatePatMedOuthosp} L268-291
{@link #resolveTargetTable}同名私有方法 L305-341
{@link #selectCount(PatMedReq)}{@code selectPatMedOuthospCount} L335-348
{@link #getDeptCodeByPatId}同名方法 L351-357
{@link #selectById}{@code selectPatMedOuthospById} L68-71(空壳)
{@link #deleteByIds} / {@link #deleteById}L319-332(均为空壳)
* *

⚠️ 保留的原实现行为(勿"顺手优化")

*
    *
  1. {@link #selectById} / {@link #deleteByIds} / {@link #deleteById} 是 * 空壳(真实现被注释掉,直接返回 {@code null} / {@code 0})—— 原样保留;
  2. *
  3. {@link #getDeptCodeByPatId} 在 {@code CollectionUtils.isEmpty} 通过后 * 仍直接 {@code get(0)}:列表为空时会 {@code IndexOutOfBoundsException}。原样保留;
  4. *
  5. {@link #insert} 在 {@code DuplicateKeyException} 时返回 0 并打 info(不当失败处理);
  6. *
  7. {@link #update} 查不到记录时转调 {@link #insert},但丢弃其返回值,最终仍 {@code return 0};
  8. *
  9. {@link #selectList} 只在 {@code pageNum != null} 时才做富化(原实现如此)。
  10. *
* *

跨模块能力

* 富化与统计用到别人的数据,一律走 ruoyi-common 契约(都在 smartor 侧有过渡实现): * {@link ArchiveReadApi}(档案)/ {@link VisitTaskCreateApi#existsSubtaskWithStates}(子任务存在性) * / {@link SubtaskStatApi}(随访人次统计)。 */ @Slf4j @Service public class OuthospDataService { @Autowired private PatMedOuthospMapper mapper; @Autowired private ObjectProvider archiveApiProvider; @Autowired private ObjectProvider taskApiProvider; @Autowired private ObjectProvider statApiProvider; // ========================================================================== // 查询 // ========================================================================== /** * 按条件走存储过程查门诊记录(纯转发,不做任何富化)。 *

* 与 {@link #selectList} 的区别:{@code selectList} 会在 {@code pageNum != null} 时 * 做年龄/性别/电话富化 + 子任务 {@code serverState} 查询; * 本方法是"原样把存储过程结果给你",供不需要富化的内部调用方使用 * (如 smartor 的随访答复明细,它只需要原始记录)。 *

* ⚠️ 入参请按最小必要字段构造,不要用 {@code DtoConversionUtils} 全量带字段 —— * 多带的字段可能改变存储过程的 WHERE 分支。 */ public List callSpQueryOuthosp(PatMedOuthospQueryReq req) { return mapper.callSpQueryOuthosp(req); } /** * 按主键查。 *

* ⚠️ 原实现是空壳(L68-71):`// return patMedOuthospMapper.selectPatMedOuthospById(id); return null;` * —— 本方法原样保留空壳语义,不要"顺手实现"它(那会改变线上行为)。 * 需要按主键取记录请用 {@link #selectList} 或存储过程。 */ public PatMedOuthosp selectById(Long id) { return null; } /** * 按条件走存储过程查门诊记录条数(纯转发,原 {@code callSpQueryOuthospCount})。 *

* 供不需要富化、只需要计数的跨模块调用方使用(如 smartor 的随访统计)。 */ public Long callSpQueryOuthospCount(PatMedOuthospQueryReq req) { return mapper.callSpQueryOuthospCount(req); } /** * 门诊记录列表(含展示富化)。 *

* 逐行对应原 {@code selectPatMedOuthospList}(L83-133): *

    *
  1. {@code DtoConversionUtils.sourceToTarget} 转查询入参, * 并用 {@code beginTime/endTime} 覆盖 {@code beginAdmitdate/endAdmitdate}; * {@code deptcodes} 非空时拼成逗号串塞进 {@code deptcode};
  2. *
  3. 调存储过程 {@code callSpQueryOuthosp};
  4. *
  5. 仅当 {@code pageNum != null} 时逐条富化: * 档案现算年龄(年/月/日三段拼成 {@code age} 字符串)+ 补 sex/telcode/idcardno, * 并按 {@code patid + sendstates[1,2,3] + service_type="3"} 置 {@code serverState="1"}。
  6. *
* *

⚠️ 富化里有一处原实现的 NPE 风险(原样保留)

* {@code patArchive.getAge() + getAgeUnit() + getAge2() + getAgeUnit2()} 在 * {@code calculateAge} 返回 null(生日在未来)时会 NPE;原实现没有防护。 */ public List selectList(PatMedOuthosp query) { PatMedOuthospQueryReq req = DtoConversionUtils.sourceToTarget(query, PatMedOuthospQueryReq.class); req.setBeginAdmitdate(query.getBeginTime()); req.setEndAdmitdate(query.getEndTime()); if (!CollectionUtils.isEmpty(query.getDeptcodes())) { req.setDeptcode(String.join(",", query.getDeptcodes())); } List rows = mapper.callSpQueryOuthosp(req); if (query.getPageNum() != null && rows != null) { ArchiveReadApi archiveApi = archiveApi(); VisitTaskCreateApi taskApi = taskApi(); for (PatMedOuthosp row : rows) { // ---- 档案富化 ---- VisitArchiveRow archive = archiveApi == null ? null : archiveApi.findByPatid(row.getPatid()); if (ObjectUtils.isNotEmpty(archive) && archive.getBirthdate() != null) { Map map = calculateAge( archive.getBirthdate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(), LocalDate.now()); archive.setAge(ObjectUtils.isNotEmpty(map.get("age")) ? Long.valueOf(map.get("age")) : null); archive.setAgeUnit(map.get("ageUnit")); archive.setAge2(ObjectUtils.isNotEmpty(map.get("age2")) ? Long.valueOf(map.get("age2")) : null); archive.setAgeUnit2(map.get("ageUnit2")); row.setAge(archive.getAge() + archive.getAgeUnit() + archive.getAge2() + archive.getAgeUnit2()); row.setSex(archive.getSex()); row.setTelcode(archive.getTelcode()); row.setIdcardno(archive.getIdcardno()); } // ---- 子任务富化(口径见契约注释:与 findPendingSubtasks 不同)---- if (taskApi != null) { List sendstates = new ArrayList<>(); sendstates.add(1L); sendstates.add(2L); sendstates.add(3L); if (taskApi.existsSubtaskWithStates(row.getPatid(), sendstates, "3")) { row.setServerState("1"); } } } } return rows; } /** * 年龄段计算(原 {@code calculateAge} L135-185,逐行照抄)。 *
    *
  • {@code < 30 天} → 天
  • *
  • {@code < 12 月} → 月 + 天
  • *
  • 否则 → 岁 + 月
  • *
* * @return 含 {@code age/ageUnit/age2/ageUnit2} 的 map; * {@code birthdate} 为 null 或晚于今天时返回 {@code null} */ public Map calculateAge(LocalDate birthdate, LocalDate today) { if (birthdate == null || today.isBefore(birthdate)) { return null; } Map ageMap = new HashMap<>(); Period period = Period.between(birthdate, today); long totalDays = ChronoUnit.DAYS.between(birthdate, today); long totalMonths = ChronoUnit.MONTHS.between(birthdate, today); int years = period.getYears(); int months = period.getMonths(); int days = period.getDays(); String ageUnit; Integer age; String ageUnit2 = null; Integer age2 = null; if (totalDays < 30) { ageUnit = "天"; age = (int) totalDays; ageMap.put("age", age != null ? age.toString() : null); ageMap.put("ageUnit", ageUnit); ageMap.put("age2", null); ageMap.put("ageUnit2", null); } else if (totalMonths < 12) { ageUnit = "月"; age = (int) totalMonths; ageUnit2 = "天"; age2 = days; ageMap.put("age", age != null ? age.toString() : null); ageMap.put("ageUnit", ageUnit); ageMap.put("age2", age2 != null ? age2.toString() : null); ageMap.put("ageUnit2", ageUnit2); } else { ageUnit = "岁"; age = years; ageUnit2 = "月"; age2 = months; ageMap.put("age", age != null ? age.toString() : null); ageMap.put("ageUnit", ageUnit); ageMap.put("age2", age2 != null ? age2.toString() : null); ageMap.put("ageUnit2", ageUnit2); } return ageMap; } /** * 出院/门诊人数统计。 *

* 逐行对应原 {@code selectPatMedOuthospCount}(L335-348): *

     * 本域 callSpQueryOuthospCount(req)  -> count
     * 随访域 countVisitForStat(patMedReq) -> PatMedRes
     * patMedRes.setRc(count)
     * 
* ⚠️ 原实现不判 {@code patMedRes} 是否 null 就直接 setRc,此处保持原样。 */ public PatMedRes selectCount(PatMedReq patMedReq) { PatMedOuthospQueryReq req = new PatMedOuthospQueryReq(); String deptcodes = CollectionUtils.isEmpty(patMedReq.getDeptcodeList()) ? null : String.join(",", patMedReq.getDeptcodeList()); req.setBeginAdmitdate(patMedReq.getStartDate()); req.setEndAdmitdate(DateUtils.addDays(patMedReq.getEndDate(), 1)); req.setDeptcode(deptcodes); req.setOrgid(patMedReq.getOrgid()); // 原实现把下面这行注释掉了,原样保留(不设 campusid) // req.setCampusid(patMedReq.getCampusid()); Long count = mapper.callSpQueryOuthospCount(req); SubtaskStatApi statApi = statApi(); if (statApi == null) { log.warn("【门急诊随访】SubtaskStatApi 不可用,selectCount 返回 null。"); return null; } PatMedRes patMedRes = statApi.countVisitForStat(patMedReq); patMedRes.setRc(count == null ? 0 : count.intValue()); return patMedRes; } /** * 按 patid 取最新一条门诊记录(按就诊日期倒序)。 *

* ⚠️ 原实现的空列表越界缺陷原样保留(L351-357): *

     * if (!CollectionUtils.isEmpty(list)) { Collections.sort(...); }
     * return list.get(0);   // ← 列表为空时在这里越界
     * 
*/ public PatMedOuthosp getDeptCodeByPatId(PatMedOuthosp patMedOuthosp) { List list = selectList(patMedOuthosp); if (!CollectionUtils.isEmpty(list)) { Collections.sort(list, Comparator.comparing(PatMedOuthosp::getAdmitdate).reversed()); } return list.get(0); } // ========================================================================== // 写入 // ========================================================================== /** * 新增门诊记录(按 {@code admitdate} 路由到对应分表)。 *

逐行对应原 {@code insertPatMedOuthosp}(L187-204)。 */ public int insert(PatMedOuthosp row) { if (row.getAdmitdate() == null) { log.error("门急诊就诊时间不能为空"); return 0; } String targetTable = resolveTargetTable(row.getAdmitdate()); if (targetTable == null) { return 0; } // 插入前按 serialnum 查重,防止重复采集导致重复入库 if (StringUtils.isNotBlank(row.getSerialnum())) { int exists = mapper.countBySerialnum(targetTable, row.getSerialnum()); if (exists > 0) { log.info("[insert] serialnum={} 已存在于表 {},跳过插入", row.getSerialnum(), targetTable); return 0; } } row.setCreateTime(DateUtils.getNowDate()); row.setUpdateTime(DateUtils.getNowDate()); row.setGuid(UUID.randomUUID().toString()); log.info("[insert] 路由到分表: {}", targetTable); try { return mapper.insertIntoTable(targetTable, row); } catch (org.springframework.dao.DuplicateKeyException e) { // 加了 serialnum 唯一索引后,极端并发或重复采集会被数据库直接拦下; // 这属于"记录已存在",按正常跳过处理(返回 0),不要当成入库失败写进错误表 log.info("[insert] serialnum={} 已存在于表 {}(唯一索引拦截),跳过插入", row.getSerialnum(), targetTable); return 0; } } /** * 批量查已入库的 serialnum(一次 IN 查询,避免逐条 count)。 *

逐行对应原 {@code selectExistingSerialnums}(L210-259)。 */ public Set selectExistingSerialnums(String admitDateStr, Collection serialnums) { Set existing = new HashSet<>(); if (CollectionUtils.isEmpty(serialnums)) { return existing; } Date admitDate = null; if (StringUtils.isNotBlank(admitDateStr)) { try { admitDate = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(admitDateStr); } catch (java.text.ParseException e) { log.warn("[selectExistingSerialnums] 就诊日期解析失败:{},按主表查询", admitDateStr); } } String targetTable = admitDate == null ? null : resolveTargetTable(admitDate); if (targetTable == null) { targetTable = "pat_med_outhosp"; } List existed = mapper.selectExistingSerialnums(targetTable, serialnums); if (!CollectionUtils.isEmpty(existed)) { existing.addAll(existed); } log.info("[selectExistingSerialnums] 日期={},分表={},待查{}条,已存在{}条", admitDateStr, targetTable, serialnums.size(), existing.size()); return existing; } /** * 修改门诊记录(按 {@code admitdate} 路由到分表;查不到记录则转新增)。 *

逐行对应原 {@code updatePatMedOuthosp}(L268-291)。 * ⚠️ 查不到时转调 {@link #insert} 但丢弃其返回值,最终仍 {@code return 0} —— 原样保留。 */ public int update(PatMedOuthosp row) { if (row.getSerialnum() == null) { log.error("流水号不能为空"); return 0; } PatMedOuthospQueryReq req = new PatMedOuthospQueryReq(); req.setSerialnum(row.getSerialnum()); req.setGuid(row.getGuid()); req.setOuthospno(row.getOuthospno()); List found = mapper.callSpQueryOuthosp(req); if (found == null || found.isEmpty()) { insert(row); } else { row.setUpdateTime(DateUtils.getNowDate()); String targetTable = resolveTargetTable(row.getAdmitdate()); if (targetTable != null) { log.info("[update] 路由到分表: {}", targetTable); return mapper.updateInTable(targetTable, row); } } return 0; } /** * 批量删除。 *

* ⚠️ 原实现是空壳(L319-322):`// return patMedOuthospMapper.deletePatMedOuthospByIds(ids); return 0;` * —— 原样保留。 */ public int deleteByIds(Long[] ids) { return 0; } /** * 按主键删除。 *

* ⚠️ 原实现是空壳(L328-332):`// return patMedOuthospMapper.deletePatMedOuthospById(id); return 0;` * —— 原样保留。 */ public int deleteById(Long id) { return 0; } // ========================================================================== // 分表路由 // ========================================================================== /** * 根据就诊时间推算目标分表名,与建表策略保持一致。 *

逐行对应原私有方法 {@code resolveTargetTable}(L305-341): *

    *
  • 6 位后缀(YYYYMM)→ 按月分表
  • *
  • 8 位后缀(YYYYMMdd)→ 按季度分表,后缀固定 0103/0406/0709/1012
  • *
  • 4 位后缀(YYYY)→ 按年分表
  • *
* 推算出表名后校验是否存在,不存在则回落主表(返回 {@code "pat_med_outhosp"}, * 不是 null)—— 注意与"参数为 null 时返回 null"的区别。 */ private String resolveTargetTable(Date createTime) { if (createTime == null) { return null; } List allTables = mapper.getAllOuthospTableNames(); if (allTables == null || allTables.isEmpty()) { return null; } String sample = allTables.get(0); String suffix = sample.replaceFirst("pat_med_outhosp_", ""); int suffixLen = suffix.length(); Calendar cal = Calendar.getInstance(); cal.setTime(createTime); int year = cal.get(Calendar.YEAR); int month = cal.get(Calendar.MONTH) + 1; String targetTable; if (suffixLen == 4) { targetTable = "pat_med_outhosp_" + year; } else if (suffixLen == 6) { targetTable = String.format("pat_med_outhosp_%d%02d", year, month); } else { String quarterSuffix; if (month <= 3) { quarterSuffix = "0103"; } else if (month <= 6) { quarterSuffix = "0406"; } else if (month <= 9) { quarterSuffix = "0709"; } else { quarterSuffix = "1012"; } targetTable = "pat_med_outhosp_" + year + quarterSuffix; } boolean exists = allTables.contains(targetTable); log.info("[路由] createTime={} → 目标表={} exists={}", createTime, targetTable, exists); return exists ? targetTable : "pat_med_outhosp"; } // ========================================================================== // 跨模块契约取用 // ========================================================================== private ArchiveReadApi archiveApi() { return providerGet(archiveApiProvider, "ArchiveReadApi"); } private VisitTaskCreateApi taskApi() { return providerGet(taskApiProvider, "VisitTaskCreateApi"); } private SubtaskStatApi statApi() { return providerGet(statApiProvider, "SubtaskStatApi"); } private T providerGet(ObjectProvider p, String name) { if (p == null) { return null; } try { return p.stream().findFirst().orElse(null); } catch (Exception e) { log.warn("【门急诊随访】获取 {} 失败,按不可用处理:{}", name, e.toString()); return null; } } }