|
|
@@ -121,6 +121,9 @@ public class HealthReportService {
|
|
|
@Resource
|
|
|
private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
|
|
|
|
|
|
+ @Resource
|
|
|
+ private ReportDateExtractor reportDateExtractor;
|
|
|
+
|
|
|
/**
|
|
|
* 创建健康报告(含指标列表)
|
|
|
*/
|
|
|
@@ -1320,6 +1323,135 @@ public class HealthReportService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 存量时序回填(/api/admin/indicator/backfill 的服务端,DS-2026-10-25-01 §4.6)。
|
|
|
+ *
|
|
|
+ * <p>顺序红线:先做日期修复、再做时序回填(污染日期若先固化进 indicator_values 再修就晚了)。
|
|
|
+ * dryRun=true 时只统计不写入,绝不产生任何副作用(不触发 mappingService.resolve,避免惰性建定义)。</p>
|
|
|
+ *
|
|
|
+ * @return scanned / eligible / written / skippedNoSubject / skippedNoPayload / failed /
|
|
|
+ * dryRun / fixReportDates / dateFixable / dateFixed / details
|
|
|
+ */
|
|
|
+ public Map<String, Object> backfillIndicatorValues(Long subjectId, Long reportId, Integer limit,
|
|
|
+ boolean dryRun, boolean fixReportDates) {
|
|
|
+ int batchLimit = (limit == null || limit < 1) ? 200 : Math.min(limit, 500);
|
|
|
+ LambdaQueryWrapper<HealthReport> qw = new LambdaQueryWrapper<HealthReport>()
|
|
|
+ .eq(HealthReport::getStatus, "active");
|
|
|
+ if (subjectId != null) {
|
|
|
+ qw.eq(HealthReport::getSubjectId, subjectId);
|
|
|
+ }
|
|
|
+ if (reportId != null) {
|
|
|
+ qw.eq(HealthReport::getId, reportId);
|
|
|
+ }
|
|
|
+ qw.orderByAsc(HealthReport::getId).last("LIMIT " + batchLimit);
|
|
|
+ List<HealthReport> reports = healthReportMapper.selectList(qw);
|
|
|
+
|
|
|
+ int scanned = reports.size();
|
|
|
+ int eligible = 0;
|
|
|
+ int written = 0;
|
|
|
+ int skippedNoSubject = 0;
|
|
|
+ int skippedNoPayload = 0;
|
|
|
+ int failed = 0;
|
|
|
+ int dateFixable = 0;
|
|
|
+ int dateFixed = 0;
|
|
|
+ List<Map<String, Object>> details = new ArrayList<>();
|
|
|
+
|
|
|
+ for (HealthReport r : reports) {
|
|
|
+ // ---- 第 1 步:日期修复判定(幂等:修好后条件不再成立)----
|
|
|
+ try {
|
|
|
+ java.sql.Date extracted = reportDateExtractor.extractReportDate(r.getPayloadJson());
|
|
|
+ if (extracted != null && r.getReportDate() != null
|
|
|
+ && !toDay(extracted).equals(toDay(r.getReportDate()))
|
|
|
+ && !r.getReportDate().before(toDay(r.getCreatedAt()))) {
|
|
|
+ // report_date >= 入库日当天 = new Date() fallback 污染特征 → 修回真实采样日
|
|
|
+ dateFixable++;
|
|
|
+ if (!dryRun && fixReportDates) {
|
|
|
+ String oldDate = r.getReportDate() == null ? null : toDay(r.getReportDate()).toString();
|
|
|
+ r.setReportDate(extracted);
|
|
|
+ healthReportMapper.updateById(r);
|
|
|
+ dateFixed++;
|
|
|
+ try {
|
|
|
+ refreshMemberIndicatorCurrent(r.getSubjectId(), r.getId());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日期修复后刷新指标当前值失败 reportId={}: {}", r.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+ log.info("回填日期修复 reportId={}, {} → {}", r.getId(), oldDate, extracted);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日期修复判定失败 reportId={}: {}", r.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 第 2 步:时序回填判定 ----
|
|
|
+ if (r.getSubjectId() == null) {
|
|
|
+ skippedNoSubject++;
|
|
|
+ details.add(backfillDetail(r, "skip_no_subject"));
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (r.getPayloadJson() == null || r.getPayloadJson().trim().isEmpty()) {
|
|
|
+ skippedNoPayload++;
|
|
|
+ details.add(backfillDetail(r, "skip_no_payload"));
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ boolean covered = indicatorValueMapper.selectCount(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSourceType, r.getReportType())
|
|
|
+ .eq(IndicatorValue::getSourceId, r.getId())) > 0;
|
|
|
+ if (covered) {
|
|
|
+ details.add(backfillDetail(r, "already_covered"));
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ eligible++;
|
|
|
+ String status = "would_write";
|
|
|
+ if (!dryRun) {
|
|
|
+ try {
|
|
|
+ ParsedReportPayload.Payload payload =
|
|
|
+ objectMapper.readValue(r.getPayloadJson(), ParsedReportPayload.Payload.class);
|
|
|
+ normalizeIndicatorPipeline.processReport(r, payload);
|
|
|
+ written++;
|
|
|
+ status = "written";
|
|
|
+ } catch (Exception e) {
|
|
|
+ failed++;
|
|
|
+ status = "failed";
|
|
|
+ log.warn("时序回填失败 reportId={}: {}", r.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ details.add(backfillDetail(r, status));
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> out = new HashMap<>();
|
|
|
+ out.put("scanned", scanned);
|
|
|
+ out.put("eligible", eligible);
|
|
|
+ out.put("written", written);
|
|
|
+ out.put("skippedNoSubject", skippedNoSubject);
|
|
|
+ out.put("skippedNoPayload", skippedNoPayload);
|
|
|
+ out.put("failed", failed);
|
|
|
+ out.put("dryRun", dryRun);
|
|
|
+ out.put("fixReportDates", fixReportDates);
|
|
|
+ out.put("dateFixable", dateFixable);
|
|
|
+ out.put("dateFixed", dateFixed);
|
|
|
+ out.put("details", details);
|
|
|
+ log.info("存量时序回填(dryRun={}, fixDates={}) scanned={}, eligible={}, dateFixable={}, 已写 {}",
|
|
|
+ dryRun, fixReportDates, scanned, eligible, dateFixable, written);
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> backfillDetail(HealthReport r, String status) {
|
|
|
+ Map<String, Object> d = new HashMap<>();
|
|
|
+ d.put("reportId", r.getId());
|
|
|
+ d.put("status", status);
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 截断到日(仅比较日期部分) */
|
|
|
+ private java.sql.Date toDay(java.util.Date d) {
|
|
|
+ if (d == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return java.sql.Date.valueOf(
|
|
|
+ java.time.Instant.ofEpochMilli(d.getTime())
|
|
|
+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate());
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 重新分配报告归属
|
|
|
*
|