From 37df208706f91b2f097bb4ceba344552c5d45f0d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 3 Jun 2026 14:42:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tongji/composables/useBloodCareAi.js | 203 ++++++++ admin/src/api/tcm.ts | 2 +- .../consumer/prescription/order_list.vue | 146 +++++- .../consumer/prescription/order_list_h5.vue | 111 ++++- .../logic/tcm/PrescriptionOrderLogic.php | 28 +- .../api/logic/tcm/DailyBloodCareAiLogic.php | 442 ++++++++++++++++++ 6 files changed, 917 insertions(+), 15 deletions(-) create mode 100644 TUICallKit-Vue3/tongji/composables/useBloodCareAi.js create mode 100644 server/app/api/logic/tcm/DailyBloodCareAiLogic.php diff --git a/TUICallKit-Vue3/tongji/composables/useBloodCareAi.js b/TUICallKit-Vue3/tongji/composables/useBloodCareAi.js new file mode 100644 index 00000000..c355df7a --- /dev/null +++ b/TUICallKit-Vue3/tongji/composables/useBloodCareAi.js @@ -0,0 +1,203 @@ +import { ref, computed } from 'vue' +import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js' + +const emptyBloodCarePlan = () => ({ + summary: '', + blood_advice: '', + tcm_plan: '', + watch_points: [], + next_steps: '', + control_level: '', + control_label: '', + disclaimer: '', + analysis: [], + source: '', + date: '' +}) + +/** AI 血糖照护建议(累计 7 天有记录后) */ +export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) { + const bloodCarePlan = ref(emptyBloodCarePlan()) + const bloodCareLoading = ref(false) + const bloodCareStreaming = ref(false) + const bloodCareStreamField = ref('') + let careStreamBuffer = '' + /** 用户点击后才展开照护建议卡片 */ + const bloodCarePanelOpen = ref(false) + + const bloodCareEligible = computed(() => { + return !!diagnosisId.value && !chartUseMockData.value + }) + + const hasBloodCareContent = computed(() => { + const p = bloodCarePlan.value + return !!(p.summary || p.blood_advice || p.tcm_plan) + }) + + const bloodCareAnalysis = computed(() => { + const list = bloodCarePlan.value?.analysis + return Array.isArray(list) ? list : [] + }) + + const bloodCareControlLabel = computed(() => { + const label = String(bloodCarePlan.value?.control_label || '').trim() + return label + }) + + const bloodCareDisclaimer = computed(() => { + return String(bloodCarePlan.value?.disclaimer || '').trim() + }) + + function detectCareStreamField(text) { + const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[::]\s*([^\n]*)$/) + if (!tail) return 'summary' + const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' } + return map[tail[1]] || 'summary' + } + + function mergePartialCareFromStream() { + const partial = parsePartialBloodCarePlainText(careStreamBuffer) + const next = { ...bloodCarePlan.value } + for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) { + if (partial[key] != null) next[key] = partial[key] + } + if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points + bloodCarePlan.value = next + bloodCareStreamField.value = detectCareStreamField(careStreamBuffer) + } + + function applyBloodCarePayload(payload) { + if (!payload) return + const prevAnalysis = bloodCarePlan.value.analysis + bloodCarePlan.value = { + ...emptyBloodCarePlan(), + ...payload, + watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [], + analysis: Array.isArray(payload.analysis) && payload.analysis.length + ? payload.analysis + : (Array.isArray(prevAnalysis) ? prevAnalysis : []) + } + } + + function onBloodCareStreamEvent(event, payload) { + if (event === 'analysis') { + if (Array.isArray(payload?.analysis)) { + bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis } + } + } else if (event === 'delta') { + bloodCareStreaming.value = true + if (payload.buffer != null) { + careStreamBuffer = payload.buffer + } else { + careStreamBuffer += payload.text || '' + } + mergePartialCareFromStream() + } else if (event === 'done') { + applyBloodCarePayload(payload) + careStreamBuffer = '' + bloodCareStreamField.value = '' + } else if (event === 'error') { + showUserToast(formatUserMessage(payload?.message, '照护建议加载失败')) + } + } + + async function fetchBloodCareAiFallback(refresh) { + const res = await proxy.apiUrl({ + url: '/api/tcm/dailyBloodCareAiRecommend', + method: 'GET', + timeout: 35000, + data: { + diagnosis_id: diagnosisId.value, + refresh: refresh ? 1 : 0, + ...(refresh ? { _t: Date.now() } : {}) + } + }, false) + if (res?.code === 1 && res.data) { + applyBloodCarePayload(res.data) + } else if (res?.msg) { + showUserToast(res.msg) + } + } + + function closeBloodCarePanel() { + bloodCarePanelOpen.value = false + } + + /** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */ + async function toggleBloodCareFromChart() { + if (!bloodCareEligible.value || bloodCareLoading.value) return + if (bloodCarePanelOpen.value) { + closeBloodCarePanel() + return + } + bloodCarePanelOpen.value = true + if (!hasBloodCareContent.value) { + await fetchBloodCareAi(false) + } + } + + async function fetchBloodCareAi(refresh = false) { + if (!bloodCareEligible.value || bloodCareLoading.value) return + bloodCareLoading.value = true + bloodCareStreaming.value = false + careStreamBuffer = '' + bloodCareStreamField.value = '' + if (refresh) { + applyBloodCarePayload(emptyBloodCarePlan()) + } + try { + await requestAiStream({ + baseUrl: proxy.$url, + url: '/api/tcm/dailyBloodCareAiRecommendStream', + method: 'GET', + data: { + diagnosis_id: diagnosisId.value, + refresh: refresh ? 1 : 0, + ...(refresh ? { _t: Date.now() } : {}) + }, + onEvent: onBloodCareStreamEvent, + fallback: () => fetchBloodCareAiFallback(refresh) + }) + } catch (e) { + try { + await fetchBloodCareAiFallback(refresh) + } catch (e2) { + showUserToast('照护建议加载失败') + } + } finally { + bloodCareLoading.value = false + bloodCareStreaming.value = false + bloodCareStreamField.value = '' + } + } + + function refreshBloodCareAi() { + if (bloodCareLoading.value) return + fetchBloodCareAi(true) + } + + function resetBloodCarePlan() { + applyBloodCarePayload(emptyBloodCarePlan()) + careStreamBuffer = '' + bloodCareStreamField.value = '' + bloodCarePanelOpen.value = false + } + + return { + bloodCarePlan, + bloodCareLoading, + bloodCareStreaming, + bloodCareStreamField, + bloodCareEligible, + bloodCarePanelOpen, + hasBloodCareContent, + bloodCareAnalysis, + bloodCareControlLabel, + bloodCareDisclaimer, + fetchBloodCareAi, + toggleBloodCareFromChart, + closeBloodCarePanel, + refreshBloodCareAi, + resetBloodCarePlan + } +} diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 6a587cd0..746a7acf 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -482,7 +482,7 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) { return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params }) } -/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款) */ +/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */ export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) { return request.post({ url: '/tcm.prescriptionOrder/complete', params }) } diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index c44d48cd..9bcd3c50 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -2099,7 +2099,9 @@ -

请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。

+

+ 请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总;选择「退款」时将关联收款单标记为已退款并清零本单已付。 +

+ + @@ -3740,7 +3807,9 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s } function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) { - return canCompleteRow(row) + // 已发货(5) / 已签收(6) / 已完成(3),且支付审核已通过(1) 可退款 + const fs = Number(row.fulfillment_status) + return (fs === 5 || fs === 6 || fs === 3) && Number(row.payment_slip_audit_status) === 1 } function canQuickTrackRow(row: { fulfillment_status?: number }) { @@ -5251,6 +5320,7 @@ const completeOrderStatusOptions = [ { value: 7, label: '进行中' }, { value: 8, label: '暂不制药' }, { value: 9, label: '拒收' }, + { value: 10, label: '退款' }, { value: 11, label: '保留药方' }, { value: 12, label: '制药缓发' } ] as const @@ -5259,15 +5329,54 @@ const completeOrderDialogVisible = ref(false) const completeOrderId = ref(0) const completeFulfillmentStatus = ref(3) const completeOrderSubmitting = ref(false) +const completeOrderPayLoading = ref(false) +const completeOrderLinkedPays = ref([]) +const completeRefundFormRef = ref() +const completeRefundForm = reactive({ reason: '' }) +const completeRefundRules: FormRules = { + reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }] +} +const completeOrderRefundTotal = computed(() => { + let sum = 0 + for (const o of completeOrderLinkedPays.value) { + const st = Number(o?.status) + if (st === 2 || st === 5) { + sum += Number(o?.amount) || 0 + } + } + return Math.round(sum * 100) / 100 +}) let completeOrderLogisticsRequestSeq = 0 function onCompleteOrderDialogClosed() { completeOrderId.value = 0 + completeOrderLinkedPays.value = [] + completeOrderPayLoading.value = false + completeRefundForm.reason = '' + completeRefundFormRef.value?.clearValidate() completeOrderLogisticsRequestSeq += 1 completeOrderLogisticsPayload.value = null completeOrderLogisticsLoading.value = false } +async function loadCompleteOrderPayContext(orderId: number) { + if (!orderId) { + completeOrderLinkedPays.value = [] + return + } + completeOrderPayLoading.value = true + try { + const r: any = await prescriptionOrderDetail({ id: orderId }) + const d = r?.data ?? r ?? null + const arr = d?.linked_pay_orders + completeOrderLinkedPays.value = Array.isArray(arr) ? arr : [] + } catch { + completeOrderLinkedPays.value = [] + } finally { + completeOrderPayLoading.value = false + } +} + async function fetchCompleteOrderLogisticsForDialog(row: { id: number tracking_number?: unknown @@ -5306,6 +5415,32 @@ async function fetchCompleteOrderLogisticsForDialog(row: { async function submitCompleteOrder() { const id = completeOrderId.value if (!id) return + + if (completeFulfillmentStatus.value === 10) { + const valid = await completeRefundFormRef.value?.validate().catch(() => false) + if (!valid) return + completeOrderSubmitting.value = true + try { + await prescriptionOrderRefund({ id, reason: completeRefundForm.reason.trim() }) + feedback.msgSuccess('退款成功') + completeOrderDialogVisible.value = false + getLists() + if (detailVisible.value && detailData.value?.id === id) { + try { + const r: any = await prescriptionOrderDetail({ id }) + const nd = r?.data ?? r ?? null + if (nd) detailData.value = nd + await fetchLogs(id) + } catch { /* 静默 */ } + } + } catch { + /* 拦截器已提示 */ + } finally { + completeOrderSubmitting.value = false + } + return + } + completeOrderSubmitting.value = true try { await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value }) @@ -5540,8 +5675,11 @@ function confirmComplete(row: { }) { completeOrderId.value = row.id completeFulfillmentStatus.value = 3 + completeRefundForm.reason = '' + completeOrderLinkedPays.value = [] completeOrderLogisticsPayload.value = null completeOrderDialogVisible.value = true + void loadCompleteOrderPayContext(row.id) if (String(row.tracking_number || '').trim()) { void fetchCompleteOrderLogisticsForDialog(row) } diff --git a/admin/src/views/consumer/prescription/order_list_h5.vue b/admin/src/views/consumer/prescription/order_list_h5.vue index 869ecbed..d881395e 100644 --- a/admin/src/views/consumer/prescription/order_list_h5.vue +++ b/admin/src/views/consumer/prescription/order_list_h5.vue @@ -1845,9 +1845,11 @@ :close-on-click-modal="false" destroy-on-close class="po-h5-dialog" - @closed="completeOrderId = 0" + @closed="onCompleteOrderDialogClosed" > -

请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。

+

+ 选择「已完成」按关联收款汇总实付;选择「退款」将关联收款标记已退款并清零已付。 +

+ @@ -3288,7 +3323,8 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s } function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) { - return canCompleteRow(row) + const fs = Number(row.fulfillment_status) + return (fs === 5 || fs === 6 || fs === 3) && Number(row.payment_slip_audit_status) === 1 } function canQuickTrackRow(row: { fulfillment_status?: number }) { @@ -4609,6 +4645,7 @@ const completeOrderStatusOptions = [ { value: 7, label: '进行中' }, { value: 8, label: '暂不制药' }, { value: 9, label: '拒收' }, + { value: 10, label: '退款' }, { value: 11, label: '保留药方' }, { value: 12, label: '制药缓发' } ] as const @@ -4617,10 +4654,71 @@ const completeOrderDialogVisible = ref(false) const completeOrderId = ref(0) const completeFulfillmentStatus = ref(3) const completeOrderSubmitting = ref(false) +const completeOrderLinkedPays = ref([]) +const completeRefundFormRef = ref() +const completeRefundForm = reactive({ reason: '' }) +const completeRefundRules: FormRules = { + reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }] +} +const completeOrderRefundTotal = computed(() => { + let sum = 0 + for (const o of completeOrderLinkedPays.value) { + const st = Number(o?.status) + if (st === 2 || st === 5) sum += Number(o?.amount) || 0 + } + return Math.round(sum * 100) / 100 +}) + +function onCompleteOrderDialogClosed() { + completeOrderId.value = 0 + completeOrderLinkedPays.value = [] + completeRefundForm.reason = '' + completeRefundFormRef.value?.clearValidate() +} + +async function loadCompleteOrderPayContext(orderId: number) { + if (!orderId) { + completeOrderLinkedPays.value = [] + return + } + try { + const r: any = await prescriptionOrderDetail({ id: orderId }) + const d = r?.data ?? r ?? null + completeOrderLinkedPays.value = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : [] + } catch { + completeOrderLinkedPays.value = [] + } +} async function submitCompleteOrder() { const id = completeOrderId.value if (!id) return + + if (completeFulfillmentStatus.value === 10) { + const valid = await completeRefundFormRef.value?.validate().catch(() => false) + if (!valid) return + completeOrderSubmitting.value = true + try { + await prescriptionOrderRefund({ id, reason: completeRefundForm.reason.trim() }) + feedback.msgSuccess('退款成功') + completeOrderDialogVisible.value = false + getLists() + if (detailVisible.value && detailData.value?.id === id) { + try { + const r: any = await prescriptionOrderDetail({ id }) + const nd = r?.data ?? r ?? null + if (nd) detailData.value = nd + await fetchLogs(id) + } catch { /* 静默 */ } + } + } catch { + /* 拦截器已提示 */ + } finally { + completeOrderSubmitting.value = false + } + return + } + completeOrderSubmitting.value = true try { await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value }) @@ -4830,7 +4928,10 @@ async function submitAddPayOrder() { function confirmComplete(row: { id: number }) { completeOrderId.value = row.id completeFulfillmentStatus.value = 3 + completeRefundForm.reason = '' + completeOrderLinkedPays.value = [] completeOrderDialogVisible.value = true + void loadCompleteOrderPayContext(row.id) } // ─── 撤回处方审核 ──────────────────────────────────────────── diff --git a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php index e380b898..b4de2d5a 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php @@ -2652,8 +2652,8 @@ class PrescriptionOrderLogic return false; } $curFs = (int) $order->fulfillment_status; - if ($curFs !== 5 && $curFs !== 6) { - self::$error = '仅「已发货」或「已签收」状态可退款'; + if (!in_array($curFs, [3, 5, 6], true)) { + self::$error = '仅「已发货」「已签收」或「已完成」状态可退款'; return false; } @@ -2665,12 +2665,25 @@ class PrescriptionOrderLogic $linkedPayOrderIds = self::linkedPayOrderIdList($id); $refundedPayCount = 0; + $refundedAmount = 0.0; try { if ($linkedPayOrderIds !== []) { - $refundedPayCount = Order::whereIn('id', $linkedPayOrderIds) + $toRefundRows = Order::whereIn('id', $linkedPayOrderIds) ->whereNull('delete_time') - ->update(['status' => 4]); + ->whereIn('status', [2, 5]) + ->field(['id', 'amount']) + ->select() + ->toArray(); + foreach ($toRefundRows as $pr) { + $refundedAmount += (float) ($pr['amount'] ?? 0); + } + $refundedAmount = round($refundedAmount, 2); + if ($toRefundRows !== []) { + $refundedPayCount = Order::whereIn('id', array_column($toRefundRows, 'id')) + ->whereNull('delete_time') + ->update(['status' => 4]); + } } $order->fulfillment_status = 10; $order->paid = 0; @@ -2684,7 +2697,12 @@ class PrescriptionOrderLogic $unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id); $logLine = '订单退款,状态变更为「退款」;原因:' . $reason; if ($refundedPayCount > 0) { - $logLine .= ';关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款'; + $logLine .= ';关联收款单 ' . $refundedPayCount . ' 笔已标记为已退款'; + if ($refundedAmount > 0) { + $logLine .= ',合计 ¥' . $refundedAmount; + } + } elseif ($linkedPayOrderIds !== []) { + $logLine .= ';关联收款单无待退款的已支付/待审核记录'; } $logLine .= ';已付总额清零' . $unassignSummary; diff --git a/server/app/api/logic/tcm/DailyBloodCareAiLogic.php b/server/app/api/logic/tcm/DailyBloodCareAiLogic.php new file mode 100644 index 00000000..a93315df --- /dev/null +++ b/server/app/api/logic/tcm/DailyBloodCareAiLogic.php @@ -0,0 +1,442 @@ + false, 'error' => '缺少就诊卡']; + } + + $context = self::buildCareContext($diagnosisId); + if (!$context['ok']) { + return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败']; + } + + $gate = self::ensureEnoughBloodDays($context['data']); + if (!$gate['ok']) { + return ['ok' => false, 'error' => $gate['error']]; + } + + $analysis = self::buildAnalysisPayload($context['data']); + $cacheKey = self::careCacheKey($diagnosisId); + + if (!$refresh) { + $cached = Cache::get($cacheKey); + if (is_array($cached) && !empty($cached['summary'])) { + $cached['cached'] = true; + $cached['analysis'] = $analysis; + return ['ok' => true, 'data' => $cached]; + } + } + + $data = null; + if (AiChatService::isEnabled()) { + $raw = self::aiDailyCarePlan($context['data'], $refresh); + if (is_array($raw) && !empty($raw['summary'])) { + $data = self::finalizeCarePlan($raw); + } + } + + if (!$data) { + $data = GiKnowledge::fallbackBloodCarePlan( + $context['data']['stats_7d'] ?? [], + $context['data']['stats_30d'] ?? [], + $context['data'] + ); + } + + $data['date'] = date('Y-m-d'); + $data['cached'] = false; + $data['analysis'] = $analysis; + Cache::set($cacheKey, $data, self::CACHE_TTL); + + return ['ok' => true, 'data' => $data]; + } + + /** + * @param callable(string, array):void $emit + */ + public static function streamDailyCarePlan(int $diagnosisId, bool $refresh, callable $emit): void + { + if ($diagnosisId <= 0) { + $emit('error', ['message' => '缺少就诊卡']); + return; + } + + $context = self::buildCareContext($diagnosisId); + if (!$context['ok']) { + $emit('error', ['message' => $context['error'] ?? '获取档案失败']); + return; + } + + $gate = self::ensureEnoughBloodDays($context['data']); + if (!$gate['ok']) { + $emit('error', ['message' => $gate['error']]); + return; + } + + $analysis = self::buildAnalysisPayload($context['data']); + $emit('analysis', ['analysis' => $analysis]); + + $cacheKey = self::careCacheKey($diagnosisId); + if (!$refresh) { + $cached = Cache::get($cacheKey); + if (is_array($cached) && !empty($cached['summary'])) { + $cached['cached'] = true; + $cached['analysis'] = $analysis; + $emit('done', $cached); + return; + } + } + + $data = null; + $fullText = ''; + + if (AiChatService::isEnabled()) { + [$system, $user] = self::buildCareStreamPrompts($context['data'], $refresh); + $streamRes = AiChatService::streamChat([ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ], static function (string $delta) use (&$fullText, $emit): void { + $fullText .= $delta; + $emit('delta', ['text' => $delta, 'buffer' => $fullText]); + }, [ + 'temperature' => $refresh ? 0.75 : 0.4, + 'max_tokens' => 720, + 'timeout' => self::AI_STREAM_TIMEOUT_SEC, + ]); + + if ($streamRes['ok'] && trim($fullText) !== '') { + $parsed = self::parseCarePlainText(trim($fullText)); + if ($parsed && !empty($parsed['summary'])) { + $data = self::finalizeCarePlan($parsed); + } + } + } + + if (!$data) { + $data = GiKnowledge::fallbackBloodCarePlan( + $context['data']['stats_7d'] ?? [], + $context['data']['stats_30d'] ?? [], + $context['data'] + ); + } + + $data['date'] = date('Y-m-d'); + $data['cached'] = false; + $data['analysis'] = $analysis; + Cache::set($cacheKey, $data, self::CACHE_TTL); + $emit('done', $data); + } + + private static function careCacheKey(int $diagnosisId): string + { + return 'daily_blood_care_ai_v1:' . $diagnosisId . ':' . date('Y-m-d'); + } + + /** + * @return array{ok:bool,error?:string,data?:array} + */ + private static function buildCareContext(int $diagnosisId): array + { + $base = DailyDietAiLogic::getPatientContext($diagnosisId); + if (!$base['ok']) { + return $base; + } + + $diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find(); + if ($diagnosis) { + $row = $diagnosis->toArray(); + $base['data']['syndrome_type'] = trim((string) ($row['syndrome_type'] ?? '')); + $base['data']['symptoms'] = trim((string) ($row['symptoms'] ?? '')); + $base['data']['remark'] = trim((string) ($row['remark'] ?? '')); + } + + return $base; + } + + /** + * @param array $context + * @return array{ok:bool,error?:string} + */ + private static function ensureEnoughBloodDays(array $context): array + { + $days30 = (int) (($context['stats_30d']['record_days'] ?? 0)); + $days7 = (int) (($context['stats_7d']['record_days'] ?? 0)); + $total = max($days30, $days7); + if ($total < self::MIN_RECORD_DAYS) { + return ['ok' => false, 'error' => '累计记录满7天后可生成照护建议']; + } + + return ['ok' => true]; + } + + /** + * @param array $context + * @return array> + */ + private static function buildAnalysisPayload(array $context): array + { + $out = []; + foreach (['stats_7d', 'stats_30d'] as $key) { + $s = $context[$key] ?? null; + if (is_array($s)) { + $out[] = $s; + } + } + return $out; + } + + /** + * @param array $context + * @return array|null + */ + private static function aiDailyCarePlan(array $context, bool $refresh): ?array + { + [$system, $user] = self::buildCareJsonPrompts($context, $refresh); + $res = AiChatService::chat([ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ], [ + 'temperature' => $refresh ? 0.7 : 0.35, + 'max_tokens' => 650, + 'timeout' => self::AI_TIMEOUT_SEC, + 'response_format' => ['type' => 'json_object'], + ]); + + if (empty($res['ok']) || empty($res['content'])) { + return null; + } + + $json = self::parseJsonObject((string) $res['content']); + return is_array($json) ? $json : null; + } + + /** + * @param array $context + * @return array{0:string,1:string} + */ + private static function buildCareJsonPrompts(array $context, bool $refresh): array + { + $bloodLine = self::bloodLine($context); + $stat7Line = self::statsLine($context['stats_7d'] ?? []); + $stat30Line = self::statsLine($context['stats_30d'] ?? []); + $syndrome = trim((string) ($context['syndrome_type'] ?? '')); + $symptoms = trim((string) ($context['symptoms'] ?? '')); + + $system = << $context + * @return array{0:string,1:string} + */ + private static function buildCareStreamPrompts(array $context, bool $refresh): array + { + $bloodLine = self::bloodLine($context); + $stat7Line = self::statsLine($context['stats_7d'] ?? []); + $stat30Line = self::statsLine($context['stats_30d'] ?? []); + + $system = << $context + */ + private static function bloodLine(array $context): string + { + $recent = $context['blood_recent'] ?? []; + if (!is_array($recent) || empty($recent)) { + return '近7日无逐日明细'; + } + + return implode(';', $recent); + } + + /** + * @param array $stats + */ + private static function statsLine(array $stats): string + { + $label = (string) ($stats['label'] ?? '统计'); + if (($stats['record_days'] ?? 0) <= 0) { + return $label . '无记录'; + } + + $parts = [ + $label . "有记录{$stats['record_days']}天", + "偏高{$stats['high_days']}天", + "达标率{$stats['compliance']}%", + ]; + if ($stats['fasting_avg'] !== null) { + $parts[] = "空腹均值{$stats['fasting_avg']}"; + } + if ($stats['postprandial_avg'] !== null) { + $parts[] = "餐后均值{$stats['postprandial_avg']}"; + } + + return implode(',', $parts); + } + + /** + * @return array|null + */ + private static function parseCarePlainText(string $text): ?array + { + $text = trim($text); + if ($text === '') { + return null; + } + + if ($text[0] === '{') { + $json = self::parseJsonObject($text); + return is_array($json) ? $json : null; + } + + $map = []; + if (preg_match('/总评[::]\s*(.+)$/mu', $text, $m)) { + $map['summary'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]); + } + if (preg_match('/血糖[::]\s*(.+)$/mu', $text, $m)) { + $map['blood_advice'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]); + } + if (preg_match('/中医[::]\s*(.+)$/mu', $text, $m)) { + $map['tcm_plan'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]); + } + if (preg_match('/留意[::]\s*(.+)$/mu', $text, $m)) { + $line = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]); + $parts = preg_split('/[、,,;;\s]+/u', $line) ?: []; + $map['watch_points'] = array_values(array_filter(array_map('trim', $parts))); + } + if (preg_match('/复诊[::]\s*(.+)$/mu', $text, $m)) { + $map['next_steps'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]); + } + + if (empty($map['summary'])) { + return null; + } + + return $map; + } + + /** + * @param array $raw + * @return array + */ + private static function finalizeCarePlan(array $raw): array + { + $watch = $raw['watch_points'] ?? []; + if (!is_array($watch)) { + $watch = preg_split('/[、,,;;\n]+/u', (string) $watch) ?: []; + } + $watch = array_values(array_filter(array_map('trim', $watch))); + + return [ + 'summary' => trim((string) ($raw['summary'] ?? '')), + 'blood_advice' => trim((string) ($raw['blood_advice'] ?? '')), + 'tcm_plan' => trim((string) ($raw['tcm_plan'] ?? '')), + 'watch_points' => $watch, + 'next_steps' => trim((string) ($raw['next_steps'] ?? '')), + 'control_level' => (string) ($raw['control_level'] ?? ''), + 'control_label' => (string) ($raw['control_label'] ?? ''), + 'disclaimer' => trim((string) ($raw['disclaimer'] ?? 'AI 建议供参考,不能替代医师面诊、开方与调药。')), + 'source' => (string) ($raw['source'] ?? 'ai'), + ]; + } + + /** + * @return array|null + */ + private static function parseJsonObject(string $text): ?array + { + $text = trim($text); + if ($text === '') { + return null; + } + if (preg_match('/\{[\s\S]*\}/u', $text, $m)) { + $text = $m[0]; + } + $data = json_decode($text, true); + + return is_array($data) ? $data : null; + } +}