Explorar el Código

Merge commit '2133cfaebd0ce9912b8a6e25addb8237f8712b10' into HEAD

Agnes Agent hace 2 semanas
padre
commit
f6c02c89f9

+ 57 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1992,19 +1992,26 @@ public class HealthReportService {
             oldReport.setShannonIndex(scoreSource.get("shannonIndex").toString());
         }
         // 用户可手动修正报告日期(AI 解析结果不准确时)
+        // 严格解析:lenient=false(拒绝 2025-13-45 这种"静默成功")+ 整串必须被消费
+        // 解析失败/未来日期一律抛业务异常(GlobalExceptionHandler → 400 + 明确提示),绝不静默吞掉
+        boolean reportDateChanged = false;
         Object reportDateObj = scoreSource.get("reportDate");
         if (reportDateObj != null && !String.valueOf(reportDateObj).trim().isEmpty()) {
             String reportDateStr = String.valueOf(reportDateObj).trim();
-            try {
-                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
-                java.util.Date parsedDate = sdf.parse(reportDateStr);
-                if (parsedDate != null) {
-                    oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
-                    log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
-                }
-            } catch (Exception e) {
-                log.warn("reportDate 格式解析失败 reportId={}, value={}: {}", reportId, reportDateStr, e.getMessage());
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+            sdf.setLenient(false);
+            java.text.ParsePosition parsePos = new java.text.ParsePosition(0);
+            java.util.Date parsedDate = sdf.parse(reportDateStr, parsePos);
+            if (parsedDate == null || parsePos.getIndex() != reportDateStr.length()) {
+                throw new IllegalArgumentException("报告日期格式应为 yyyy-MM-dd,当前值:" + reportDateStr);
             }
+            // 当天允许,未来日期拒绝
+            if (parsedDate.after(new Date())) {
+                throw new IllegalArgumentException("报告日期不能晚于今天,当前值:" + reportDateStr);
+            }
+            oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
+            reportDateChanged = true;
+            log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
         }
         oldReport.setUpdatedAt(new Date());
         healthReportMapper.updateById(oldReport);
@@ -2167,6 +2174,32 @@ public class HealthReportService {
         } catch (Exception e) {
             log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
         }
+
+        // 8. 报告日期变更后的联动(须在归一化管道之后:管道重写 indicator_values 时序行)
+        if (reportDateChanged) {
+            // a) 刷新成员指标快照:改日期改变 report_date 排序,"上一期"定位需重算
+            //    内部有 subjectId/reportId null 保护;effectiveSubjectId 为本次编辑后的实际归属
+            Long effectiveSubjectId = (subjectId != null && subjectId > 0) ? subjectId : oldSubjectId;
+            try {
+                refreshMemberIndicatorCurrent(effectiveSubjectId, reportId);
+                log.info("报告日期变更后已刷新成员{}的指标快照", effectiveSubjectId);
+            } catch (Exception e) {
+                log.warn("报告日期变更后刷新成员{}指标快照失败 reportId={}: {}",
+                        effectiveSubjectId, reportId, e.getMessage());
+            }
+            // b) 手动修正的日期打 date_source='real' 标记(T01 迁移列;列不存在时跳过不崩)
+            try {
+                if (columnExists("indicator_values", "date_source")) {
+                    int updated = jdbcTemplate.update(
+                            "UPDATE indicator_values SET date_source = 'real' WHERE report_id = ?", reportId);
+                    log.info("已标记报告{}的时序行 date_source='real',共{}行", reportId, updated);
+                } else {
+                    log.info("indicator_values 无 date_source 列(T01 迁移未落地),跳过 date_source 标记 reportId={}", reportId);
+                }
+            } catch (Exception e) {
+                log.warn("date_source 标记失败 reportId={}: {}", reportId, e.getMessage());
+            }
+        }
     }
 
     private Integer toInteger(Object value) {
@@ -2178,6 +2211,21 @@ public class HealthReportService {
         }
     }
 
+    /**
+     * 列是否存在(与 DatabaseInitializer.columnExists 同实现思路)
+     * 用于可选列(如 T01 的 indicator_values.date_source)做兜底判断:列不存在时跳过写入,不报错
+     */
+    private boolean columnExists(String table, String column) {
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
+                    Integer.class, table, column);
+            return count != null && count > 0;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     /**
      * 保存 payload 快照并记录编辑日志(action: create/edit)
      */

+ 118 - 5
cfc-frontend/pages/health/report-detail.vue

@@ -8,6 +8,16 @@
         <view class="head-meta">
           <text class="type-badge">{{ typeLabel }}</text>
           <text class="head-date">{{ headInfo.reportDateText || '' }}</text>
+          <!-- 报告日期修改入口(view 模式;DAN 报告走独立存储与接口,不在本页编辑范围) -->
+          <text class="head-date-edit" v-if="pageMode==='view' && headInfo.reportDate && reportType !== 'dan'" @tap="startEditDate">修改</text>
+        </view>
+        <!-- 日期编辑行(仅查看态入口,复用 /report/edit 接口) -->
+        <view class="head-date-editor" v-if="dateEditing">
+          <input class="head-date-input" type="text" v-model="dateInput" placeholder="yyyy-MM-dd" />
+          <view class="head-date-actions">
+            <text class="head-date-btn head-date-cancel" @tap="cancelEditDate">取消</text>
+            <text class="head-date-btn head-date-save" @tap="submitDateChange">保存</text>
+          </view>
         </view>
       </view>
 
@@ -409,7 +419,7 @@ import ReportBlocksRenderer from './report-blocks-renderer.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
 import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge } from '../../utils/api.js'
-import { parseDate } from '../../utils/format.js'
+import { formatDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -470,7 +480,11 @@ export default {
       showHelpPopup: false,
       helpData: {},
       _kbCache: {},
-      feedbackVisible: false
+      feedbackVisible: false,
+      // view 模式报告日期修改(仅日期,走 /report/edit 复用现有接口)
+      dateEditing: false,
+      dateInput: '',
+      lastDetail: null
     }
   },
   computed: {
@@ -614,10 +628,13 @@ export default {
         uni.hideLoading()
         if (res.code === 200) {
           var report = res.data.report || res.data
+          self.lastDetail = res.data
           var head = {}
           head.personName = report.personName || report.childName || ''
           head.childName = report.childName || ''
-          head.reportDateText = report.reportDate ? parseDate(report.reportDate) : ''
+          // 仅日期场景统一 yyyy-MM-dd(禁止渲染 Date 对象/ISO 串)
+          head.reportDate = report.reportDate ? formatDate(report.reportDate) : ''
+          head.reportDateText = head.reportDate
           self.headInfo = head
           var backendBlocks = (res.data && res.data.blocks) || report.blocks || []
           if (res.data.payload) {
@@ -1097,9 +1114,61 @@ export default {
         } else {
           uni.showToast({ title: res.message || '保存失败', icon: 'none' })
         }
-      }).catch(function() {
+      }).catch(function(err) {
+        // 与 submitDateChange 同理:后端明确原因以 err.message 为准。封装层已弹过该文案,
+        // 但微信小程序 showToast 与 showLoading 共用同一组件,hideLoading 会把它一并隐藏,
+        // 故 hideLoading 后重放同一条原因;仅非业务错误(无 message)才兜底弹通用提示。
+        uni.hideLoading()
+        var reason = (err && err.message) ? err.message : ''
+        uni.showToast({ title: reason || '保存异常', icon: 'none' })
+      })
+    },
+    // ===== view 模式:报告日期修改(复用 /report/edit,只改 summary.reportDate)=====
+    startEditDate() {
+      this.dateInput = this.headInfo && this.headInfo.reportDate ? this.headInfo.reportDate : ''
+      this.dateEditing = true
+    },
+    cancelEditDate() {
+      this.dateEditing = false
+      this.dateInput = ''
+    },
+    submitDateChange() {
+      var self = this
+      var val = (self.dateInput || '').trim()
+      if (!val) {
+        uni.showToast({ title: '请输入报告日期', icon: 'none' })
+        return
+      }
+      // 日期专用最小 payload:只带 summary(沿用现有分数等值,仅覆盖 reportDate)。
+      // 刻意不带 indicators/gutFlora/diseaseRisks —— 后端 updateReportFromPayload 对缺失键
+      // 已有 instanceof List 保护,缺键即跳过整表「先删后插」,避免误清 collectionDate /
+      // description / relatedBacteria 等 payload 快照不含的列(若回填这三张表,insert 不会
+      // 写这些列 → 存量数据被永久清掉)。
+      var srcSummary = (self.payload && self.payload.summary) ? self.payload.summary : {}
+      // 复制一份再改:payload.summary 与编辑态日期输入框 v-model 双向绑定,直接改会把
+      // 未保存的日期写进组件状态,保存失败后编辑态会带出错误日期。
+      var summary = Object.assign({}, srcSummary)
+      summary.reportDate = val
+      var dateOnlyPayload = { type: self.payload.type || 'gut_flora', summary: summary }
+      var subjectId = self.selectedMemberId || self.createdMemberId || null
+      uni.showLoading({ title: '保存中...' })
+      editHealthReport(self.reportId, dateOnlyPayload, subjectId).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          self.dateEditing = false
+          uni.showToast({ title: '已保存', icon: 'success' })
+          setTimeout(function() { self.loadData() }, 600)
+        }
+        // 非 200 不在此处理:api.js 封装层对 code!==200 会先弹后端明确原因再 reject,
+        // .then 只会拿到 200(/report/edit 不返回 5001),此处再弹一次只会覆盖具体原因。
+      }).catch(function(err) {
+        // err.message 即后端原文(如「报告日期不能晚于今天,当前值:2099-01-01」)。
+        // 注意:微信小程序 showToast 与 showLoading 共用同一组件,hideLoading 会把封装层
+        // 刚弹的那条提示一并隐藏,所以这里 hideLoading 后重放同一条原因,保证不吞错;
+        // 只有非业务错误对象(无 message)才兜底弹一次通用提示。
         uni.hideLoading()
-        uni.showToast({ title: '保存异常', icon: 'none' })
+        var reason = (err && err.message) ? err.message : ''
+        uni.showToast({ title: reason || '保存失败', icon: 'none' })
       })
     },
     /** 从 blocks 中过滤掉"食物推荐/饮食推荐"块(报告详情不展示饮食推荐) */
@@ -1358,6 +1427,50 @@ $c-rptd-orange-line: #FFD4A8; // 暖橙描边
   font-size: calc(24rpx * var(--font-scale, 1));
   color: $facade-text-muted;
 }
+.head-date-edit {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $c-rptd-blue;
+  background: $c-rptd-blue-bg;
+  padding: 4rpx 14rpx;
+  border-radius: 8rpx;
+  margin-left: 4rpx;
+}
+.head-date-edit:active { opacity: 0.7; }
+.head-date-editor {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+  margin-top: 12rpx;
+}
+.head-date-input {
+  flex: 1;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  padding: 10rpx 16rpx;
+  background: $app-bg;
+  border-radius: 8rpx;
+}
+.head-date-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 12rpx;
+  flex-shrink: 0;
+}
+.head-date-btn {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  padding: 10rpx 24rpx;
+  border-radius: 8rpx;
+}
+.head-date-btn:active { opacity: 0.7; }
+.head-date-cancel {
+  color: $facade-text-muted;
+  background: $facade-fill-slate;
+}
+.head-date-save {
+  color: $app-surface;
+  background: $c-rptd-blue;
+}
 .empty-hint {
   text-align: center;
   color: $facade-text-muted;