liusheng
8 天以前 34f78281cf395a24e1f3f1c3b861186ff8a03b7b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
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.*;
 
/**
 * 门诊记录基础数据服务:查询(含展示富化)/ 统计 / 增删改 / 分表路由。
 *
 * <h3>迁移对应(@ b25c3b51 · {@code PatMedOuthospServiceImpl})</h3>
 * <table border="1">
 *   <tr><th>本类方法</th><th>原方法</th></tr>
 *   <tr><td>{@link #selectList(PatMedOuthosp)}</td><td>{@code selectPatMedOuthospList} L83-133</td></tr>
 *   <tr><td>{@link #calculateAge}</td><td>{@code calculateAge} L135-185</td></tr>
 *   <tr><td>{@link #insert(PatMedOuthosp)}</td><td>{@code insertPatMedOuthosp} L187-204</td></tr>
 *   <tr><td>{@link #selectExistingSerialnums}</td><td>同名方法 L210-259</td></tr>
 *   <tr><td>{@link #update(PatMedOuthosp)}</td><td>{@code updatePatMedOuthosp} L268-291</td></tr>
 *   <tr><td>{@link #resolveTargetTable}</td><td>同名私有方法 L305-341</td></tr>
 *   <tr><td>{@link #selectCount(PatMedReq)}</td><td>{@code selectPatMedOuthospCount} L335-348</td></tr>
 *   <tr><td>{@link #getDeptCodeByPatId}</td><td>同名方法 L351-357</td></tr>
 *   <tr><td>{@link #selectById}</td><td>{@code selectPatMedOuthospById} L68-71(<b>空壳</b>)</td></tr>
 *   <tr><td>{@link #deleteByIds} / {@link #deleteById}</td>
 *       <td>L319-332(<b>均为空壳</b>)</td></tr>
 * </table>
 *
 * <h3>⚠️ 保留的原实现行为(勿"顺手优化")</h3>
 * <ol>
 *   <li>{@link #selectById} / {@link #deleteByIds} / {@link #deleteById} 是
 *       <b>空壳</b>(真实现被注释掉,直接返回 {@code null} / {@code 0})—— 原样保留;</li>
 *   <li>{@link #getDeptCodeByPatId} 在 {@code CollectionUtils.isEmpty} 通过后
 *       <b>仍直接 {@code get(0)}</b>:列表为空时会 {@code IndexOutOfBoundsException}。原样保留;</li>
 *   <li>{@link #insert} 在 {@code DuplicateKeyException} 时返回 0 并打 info(不当失败处理);</li>
 *   <li>{@link #update} 查不到记录时<b>转调 {@link #insert}</b>,但<b>丢弃其返回值</b>,最终仍 {@code return 0};</li>
 *   <li>{@link #selectList} 只在 {@code pageNum != null} 时才做富化(原实现如此)。</li>
 * </ol>
 *
 * <h3>跨模块能力</h3>
 * 富化与统计用到别人的数据,一律走 ruoyi-common 契约(都在 smartor 侧有过渡实现):
 * {@link ArchiveReadApi}(档案)/ {@link VisitTaskCreateApi#existsSubtaskWithStates}(子任务存在性)
 * / {@link SubtaskStatApi}(随访人次统计)。
 */
@Slf4j
@Service
public class OuthospDataService {
 
    @Autowired
    private PatMedOuthospMapper mapper;
 
    @Autowired
    private ObjectProvider<ArchiveReadApi> archiveApiProvider;
 
    @Autowired
    private ObjectProvider<VisitTaskCreateApi> taskApiProvider;
 
    @Autowired
    private ObjectProvider<SubtaskStatApi> statApiProvider;
 
    // ==========================================================================
    // 查询
    // ==========================================================================
 
    /**
     * 按条件走存储过程查门诊记录(<b>纯转发,不做任何富化</b>)。
     * <p>
     * 与 {@link #selectList} 的区别:{@code selectList} 会在 {@code pageNum != null} 时
     * 做年龄/性别/电话富化 + 子任务 {@code serverState} 查询;
     * 本方法是"原样把存储过程结果给你",供<b>不需要富化</b>的内部调用方使用
     * (如 smartor 的随访答复明细,它只需要原始记录)。
     * <p>
     * ⚠️ 入参请按<b>最小必要字段</b>构造,不要用 {@code DtoConversionUtils} 全量带字段 ——
     * 多带的字段可能改变存储过程的 WHERE 分支。
     */
    public List<PatMedOuthosp> callSpQueryOuthosp(PatMedOuthospQueryReq req) {
        return mapper.callSpQueryOuthosp(req);
    }
 
    /**
     * 按主键查。
     * <p>
     * ⚠️ <b>原实现是空壳</b>(L68-71):`// return patMedOuthospMapper.selectPatMedOuthospById(id); return null;`
     * —— 本方法<b>原样保留空壳语义</b>,不要"顺手实现"它(那会改变线上行为)。
     * 需要按主键取记录请用 {@link #selectList} 或存储过程。
     */
    public PatMedOuthosp selectById(Long id) {
        return null;
    }
 
    /**
     * 按条件走存储过程查门诊记录<b>条数</b>(<b>纯转发</b>,原 {@code callSpQueryOuthospCount})。
     * <p>
     * 供不需要富化、只需要计数的跨模块调用方使用(如 smartor 的随访统计)。
     */
    public Long callSpQueryOuthospCount(PatMedOuthospQueryReq req) {
        return mapper.callSpQueryOuthospCount(req);
    }
 
    /**
     * 门诊记录列表(含展示富化)。
     * <p>
     * 逐行对应原 {@code selectPatMedOuthospList}(L83-133):
     * <ol>
     *   <li>{@code DtoConversionUtils.sourceToTarget} 转查询入参,
     *       并用 {@code beginTime/endTime} 覆盖 {@code beginAdmitdate/endAdmitdate};
     *       {@code deptcodes} 非空时拼成逗号串塞进 {@code deptcode};</li>
     *   <li>调存储过程 {@code callSpQueryOuthosp};</li>
     *   <li><b>仅当 {@code pageNum != null}</b> 时逐条富化:
     *       档案现算年龄(年/月/日三段拼成 {@code age} 字符串)+ 补 sex/telcode/idcardno,
     *       并按 {@code patid + sendstates[1,2,3] + service_type="3"} 置 {@code serverState="1"}。</li>
     * </ol>
     *
     * <h3>⚠️ 富化里有一处原实现的 NPE 风险(原样保留)</h3>
     * {@code patArchive.getAge() + getAgeUnit() + getAge2() + getAgeUnit2()} 在
     * {@code calculateAge} 返回 null(生日在未来)时会 NPE;原实现没有防护。
     */
    public List<PatMedOuthosp> 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<PatMedOuthosp> 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<String, String> 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<Long> 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,逐行照抄)。
     * <ul>
     *   <li>{@code < 30 天} → 天</li>
     *   <li>{@code < 12 月} → 月 + 天</li>
     *   <li>否则 → 岁 + 月</li>
     * </ul>
     *
     * @return 含 {@code age/ageUnit/age2/ageUnit2} 的 map;
     *         {@code birthdate} 为 null 或晚于今天时返回 {@code null}
     */
    public Map<String, String> calculateAge(LocalDate birthdate, LocalDate today) {
        if (birthdate == null || today.isBefore(birthdate)) {
            return null;
        }
        Map<String, String> 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;
    }
 
    /**
     * 出院/门诊人数统计。
     * <p>
     * 逐行对应原 {@code selectPatMedOuthospCount}(L335-348):
     * <pre>
     * 本域 callSpQueryOuthospCount(req)  -> count
     * 随访域 countVisitForStat(patMedReq) -> PatMedRes
     * patMedRes.setRc(count)
     * </pre>
     * ⚠️ 原实现不判 {@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 取最新一条门诊记录(按就诊日期倒序)。
     * <p>
     * ⚠️ <b>原实现的空列表越界缺陷原样保留</b>(L351-357):
     * <pre>
     * if (!CollectionUtils.isEmpty(list)) { Collections.sort(...); }
     * return list.get(0);   // ← 列表为空时在这里越界
     * </pre>
     */
    public PatMedOuthosp getDeptCodeByPatId(PatMedOuthosp patMedOuthosp) {
        List<PatMedOuthosp> list = selectList(patMedOuthosp);
        if (!CollectionUtils.isEmpty(list)) {
            Collections.sort(list, Comparator.comparing(PatMedOuthosp::getAdmitdate).reversed());
        }
        return list.get(0);
    }
 
    // ==========================================================================
    // 写入
    // ==========================================================================
 
    /**
     * 新增门诊记录(按 {@code admitdate} 路由到对应分表)。
     * <p>逐行对应原 {@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)。
     * <p>逐行对应原 {@code selectExistingSerialnums}(L210-259)。
     */
    public Set<String> selectExistingSerialnums(String admitDateStr, Collection<String> serialnums) {
        Set<String> 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<String> 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} 路由到分表;查不到记录则转新增)。
     * <p>逐行对应原 {@code updatePatMedOuthosp}(L268-291)。
     * ⚠️ 查不到时转调 {@link #insert} 但<b>丢弃其返回值</b>,最终仍 {@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<PatMedOuthosp> 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;
    }
 
    /**
     * 批量删除。
     * <p>
     * ⚠️ <b>原实现是空壳</b>(L319-322):`// return patMedOuthospMapper.deletePatMedOuthospByIds(ids); return 0;`
     * —— 原样保留。
     */
    public int deleteByIds(Long[] ids) {
        return 0;
    }
 
    /**
     * 按主键删除。
     * <p>
     * ⚠️ <b>原实现是空壳</b>(L328-332):`// return patMedOuthospMapper.deletePatMedOuthospById(id); return 0;`
     * —— 原样保留。
     */
    public int deleteById(Long id) {
        return 0;
    }
 
    // ==========================================================================
    // 分表路由
    // ==========================================================================
 
    /**
     * 根据就诊时间推算目标分表名,与建表策略保持一致。
     * <p>逐行对应原私有方法 {@code resolveTargetTable}(L305-341):
     * <ul>
     *   <li>6 位后缀(YYYYMM)→ 按月分表</li>
     *   <li>8 位后缀(YYYYMMdd)→ 按季度分表,后缀固定 0103/0406/0709/1012</li>
     *   <li>4 位后缀(YYYY)→ 按年分表</li>
     * </ul>
     * 推算出表名后校验是否存在,<b>不存在则回落主表</b>(返回 {@code "pat_med_outhosp"},
     * 不是 null)—— 注意与"参数为 null 时返回 null"的区别。
     */
    private String resolveTargetTable(Date createTime) {
        if (createTime == null) {
            return null;
        }
        List<String> 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> T providerGet(ObjectProvider<T> 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;
        }
    }
}