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 @@
-
请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。
+
+ 请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总;选择「退款」时将关联收款单标记为已退款并清零本单已付。
+
+
+
+
+
+
+
+ 将退款的关联收款({{ completeOrderLinkedPays.length }} 笔 · 合计 ¥{{ formatMoney(completeOrderRefundTotal) }})
+
+
+
+
+
+ {{ row.order_type_desc || feeTypeText(row.order_type) }}
+
+
+
+
+ ¥{{ row.amount }}
+
+
+
+
+
+ {{ row.status_desc || orderStatusText(row.status) }}
+
+
+
+
+
+
+ 当前无已关联的收款单;提交后仍将本单标记为「退款」并清零已付。
+
+
+
+
+
+
+
+
取消
-
- 确定
+
+ {{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
@@ -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"
>
- 请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。
+
+ 选择「已完成」按关联收款汇总实付;选择「退款」将关联收款标记已退款并清零已付。
+
+
+
+
+ 将退款 {{ completeOrderLinkedPays.length }} 笔关联收款,合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
+
+
+
+
+
+
+
取消
-
- 确定
+
+ {{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
@@ -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;
+ }
+}