更新
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -482,7 +482,7 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款) */
|
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */
|
||||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2099,7 +2099,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。</p>
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总;选择「退款」时将关联收款单标记为已退款并清零本单已付。
|
||||||
|
</p>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="completeFulfillmentStatus"
|
v-model="completeFulfillmentStatus"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -2112,11 +2114,76 @@
|
|||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
|
<template v-if="completeFulfillmentStatus === 10">
|
||||||
|
<el-alert
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mt-3 !items-start"
|
||||||
|
title="退款将处理本单全部关联收款单,已付总额清零,且不可撤销。"
|
||||||
|
/>
|
||||||
|
<div v-loading="completeOrderPayLoading" class="mt-3">
|
||||||
|
<div
|
||||||
|
v-if="completeOrderLinkedPays.length"
|
||||||
|
class="rounded-lg border border-amber-200/80 bg-amber-50/50 p-3"
|
||||||
|
>
|
||||||
|
<div class="text-xs font-semibold text-amber-900 mb-2">
|
||||||
|
将退款的关联收款({{ completeOrderLinkedPays.length }} 笔 · 合计 ¥{{ formatMoney(completeOrderRefundTotal) }})
|
||||||
|
</div>
|
||||||
|
<el-table :data="completeOrderLinkedPays" size="small" border stripe max-height="200">
|
||||||
|
<el-table-column prop="order_no" label="单号" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类型" width="88">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.order_type_desc || feeTypeText(row.order_type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="金额" width="88" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="92" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||||
|
{{ row.status_desc || orderStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!completeOrderPayLoading" class="text-xs text-gray-500 mt-2">
|
||||||
|
当前无已关联的收款单;提交后仍将本单标记为「退款」并清零已付。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-form
|
||||||
|
ref="completeRefundFormRef"
|
||||||
|
:model="completeRefundForm"
|
||||||
|
:rules="completeRefundRules"
|
||||||
|
label-width="88px"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
<el-form-item label="退款原因" prop="reason">
|
||||||
|
<el-input
|
||||||
|
v-model="completeRefundForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请填写退款原因(必填)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
|
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
|
<el-button
|
||||||
确定
|
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
|
||||||
|
:loading="completeOrderSubmitting"
|
||||||
|
@click="submitCompleteOrder"
|
||||||
|
>
|
||||||
|
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -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 }) {
|
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 }) {
|
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||||
@@ -5251,6 +5320,7 @@ const completeOrderStatusOptions = [
|
|||||||
{ value: 7, label: '进行中' },
|
{ value: 7, label: '进行中' },
|
||||||
{ value: 8, label: '暂不制药' },
|
{ value: 8, label: '暂不制药' },
|
||||||
{ value: 9, label: '拒收' },
|
{ value: 9, label: '拒收' },
|
||||||
|
{ value: 10, label: '退款' },
|
||||||
{ value: 11, label: '保留药方' },
|
{ value: 11, label: '保留药方' },
|
||||||
{ value: 12, label: '制药缓发' }
|
{ value: 12, label: '制药缓发' }
|
||||||
] as const
|
] as const
|
||||||
@@ -5259,15 +5329,54 @@ const completeOrderDialogVisible = ref(false)
|
|||||||
const completeOrderId = ref(0)
|
const completeOrderId = ref(0)
|
||||||
const completeFulfillmentStatus = ref<number>(3)
|
const completeFulfillmentStatus = ref<number>(3)
|
||||||
const completeOrderSubmitting = ref(false)
|
const completeOrderSubmitting = ref(false)
|
||||||
|
const completeOrderPayLoading = ref(false)
|
||||||
|
const completeOrderLinkedPays = ref<any[]>([])
|
||||||
|
const completeRefundFormRef = ref<FormInstance>()
|
||||||
|
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
|
let completeOrderLogisticsRequestSeq = 0
|
||||||
|
|
||||||
function onCompleteOrderDialogClosed() {
|
function onCompleteOrderDialogClosed() {
|
||||||
completeOrderId.value = 0
|
completeOrderId.value = 0
|
||||||
|
completeOrderLinkedPays.value = []
|
||||||
|
completeOrderPayLoading.value = false
|
||||||
|
completeRefundForm.reason = ''
|
||||||
|
completeRefundFormRef.value?.clearValidate()
|
||||||
completeOrderLogisticsRequestSeq += 1
|
completeOrderLogisticsRequestSeq += 1
|
||||||
completeOrderLogisticsPayload.value = null
|
completeOrderLogisticsPayload.value = null
|
||||||
completeOrderLogisticsLoading.value = false
|
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: {
|
async function fetchCompleteOrderLogisticsForDialog(row: {
|
||||||
id: number
|
id: number
|
||||||
tracking_number?: unknown
|
tracking_number?: unknown
|
||||||
@@ -5306,6 +5415,32 @@ async function fetchCompleteOrderLogisticsForDialog(row: {
|
|||||||
async function submitCompleteOrder() {
|
async function submitCompleteOrder() {
|
||||||
const id = completeOrderId.value
|
const id = completeOrderId.value
|
||||||
if (!id) return
|
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
|
completeOrderSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
|
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
|
||||||
@@ -5540,8 +5675,11 @@ function confirmComplete(row: {
|
|||||||
}) {
|
}) {
|
||||||
completeOrderId.value = row.id
|
completeOrderId.value = row.id
|
||||||
completeFulfillmentStatus.value = 3
|
completeFulfillmentStatus.value = 3
|
||||||
|
completeRefundForm.reason = ''
|
||||||
|
completeOrderLinkedPays.value = []
|
||||||
completeOrderLogisticsPayload.value = null
|
completeOrderLogisticsPayload.value = null
|
||||||
completeOrderDialogVisible.value = true
|
completeOrderDialogVisible.value = true
|
||||||
|
void loadCompleteOrderPayContext(row.id)
|
||||||
if (String(row.tracking_number || '').trim()) {
|
if (String(row.tracking_number || '').trim()) {
|
||||||
void fetchCompleteOrderLogisticsForDialog(row)
|
void fetchCompleteOrderLogisticsForDialog(row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1845,9 +1845,11 @@
|
|||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
class="po-h5-dialog"
|
class="po-h5-dialog"
|
||||||
@closed="completeOrderId = 0"
|
@closed="onCompleteOrderDialogClosed"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。</p>
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
选择「已完成」按关联收款汇总实付;选择「退款」将关联收款标记已退款并清零已付。
|
||||||
|
</p>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="completeFulfillmentStatus"
|
v-model="completeFulfillmentStatus"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -1860,10 +1862,43 @@
|
|||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<template v-if="completeFulfillmentStatus === 10">
|
||||||
|
<el-alert
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mt-3"
|
||||||
|
title="关联收款将标记已退款,本单已付清零,不可撤销。"
|
||||||
|
/>
|
||||||
|
<p v-if="completeOrderLinkedPays.length" class="text-xs text-amber-800 mt-2">
|
||||||
|
将退款 {{ completeOrderLinkedPays.length }} 笔关联收款,合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
|
||||||
|
</p>
|
||||||
|
<el-form
|
||||||
|
ref="completeRefundFormRef"
|
||||||
|
:model="completeRefundForm"
|
||||||
|
:rules="completeRefundRules"
|
||||||
|
label-width="80px"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<el-form-item label="退款原因" prop="reason">
|
||||||
|
<el-input
|
||||||
|
v-model="completeRefundForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="必填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
|
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
|
<el-button
|
||||||
确定
|
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
|
||||||
|
:loading="completeOrderSubmitting"
|
||||||
|
@click="submitCompleteOrder"
|
||||||
|
>
|
||||||
|
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -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 }) {
|
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 }) {
|
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||||
@@ -4609,6 +4645,7 @@ const completeOrderStatusOptions = [
|
|||||||
{ value: 7, label: '进行中' },
|
{ value: 7, label: '进行中' },
|
||||||
{ value: 8, label: '暂不制药' },
|
{ value: 8, label: '暂不制药' },
|
||||||
{ value: 9, label: '拒收' },
|
{ value: 9, label: '拒收' },
|
||||||
|
{ value: 10, label: '退款' },
|
||||||
{ value: 11, label: '保留药方' },
|
{ value: 11, label: '保留药方' },
|
||||||
{ value: 12, label: '制药缓发' }
|
{ value: 12, label: '制药缓发' }
|
||||||
] as const
|
] as const
|
||||||
@@ -4617,10 +4654,71 @@ const completeOrderDialogVisible = ref(false)
|
|||||||
const completeOrderId = ref(0)
|
const completeOrderId = ref(0)
|
||||||
const completeFulfillmentStatus = ref<number>(3)
|
const completeFulfillmentStatus = ref<number>(3)
|
||||||
const completeOrderSubmitting = ref(false)
|
const completeOrderSubmitting = ref(false)
|
||||||
|
const completeOrderLinkedPays = ref<any[]>([])
|
||||||
|
const completeRefundFormRef = ref<FormInstance>()
|
||||||
|
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() {
|
async function submitCompleteOrder() {
|
||||||
const id = completeOrderId.value
|
const id = completeOrderId.value
|
||||||
if (!id) return
|
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
|
completeOrderSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
|
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
|
||||||
@@ -4830,7 +4928,10 @@ async function submitAddPayOrder() {
|
|||||||
function confirmComplete(row: { id: number }) {
|
function confirmComplete(row: { id: number }) {
|
||||||
completeOrderId.value = row.id
|
completeOrderId.value = row.id
|
||||||
completeFulfillmentStatus.value = 3
|
completeFulfillmentStatus.value = 3
|
||||||
|
completeRefundForm.reason = ''
|
||||||
|
completeOrderLinkedPays.value = []
|
||||||
completeOrderDialogVisible.value = true
|
completeOrderDialogVisible.value = true
|
||||||
|
void loadCompleteOrderPayContext(row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 撤回处方审核 ────────────────────────────────────────────
|
// ─── 撤回处方审核 ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2652,8 +2652,8 @@ class PrescriptionOrderLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$curFs = (int) $order->fulfillment_status;
|
$curFs = (int) $order->fulfillment_status;
|
||||||
if ($curFs !== 5 && $curFs !== 6) {
|
if (!in_array($curFs, [3, 5, 6], true)) {
|
||||||
self::$error = '仅「已发货」或「已签收」状态可退款';
|
self::$error = '仅「已发货」「已签收」或「已完成」状态可退款';
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2665,12 +2665,25 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
$linkedPayOrderIds = self::linkedPayOrderIdList($id);
|
$linkedPayOrderIds = self::linkedPayOrderIdList($id);
|
||||||
$refundedPayCount = 0;
|
$refundedPayCount = 0;
|
||||||
|
$refundedAmount = 0.0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($linkedPayOrderIds !== []) {
|
if ($linkedPayOrderIds !== []) {
|
||||||
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
|
$toRefundRows = Order::whereIn('id', $linkedPayOrderIds)
|
||||||
->whereNull('delete_time')
|
->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->fulfillment_status = 10;
|
||||||
$order->paid = 0;
|
$order->paid = 0;
|
||||||
@@ -2684,7 +2697,12 @@ class PrescriptionOrderLogic
|
|||||||
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
|
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
|
||||||
$logLine = '订单退款,状态变更为「退款」;原因:' . $reason;
|
$logLine = '订单退款,状态变更为「退款」;原因:' . $reason;
|
||||||
if ($refundedPayCount > 0) {
|
if ($refundedPayCount > 0) {
|
||||||
$logLine .= ';关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
|
$logLine .= ';关联收款单 ' . $refundedPayCount . ' 笔已标记为已退款';
|
||||||
|
if ($refundedAmount > 0) {
|
||||||
|
$logLine .= ',合计 ¥' . $refundedAmount;
|
||||||
|
}
|
||||||
|
} elseif ($linkedPayOrderIds !== []) {
|
||||||
|
$logLine .= ';关联收款单无待退款的已支付/待审核记录';
|
||||||
}
|
}
|
||||||
$logLine .= ';已付总额清零' . $unassignSummary;
|
$logLine .= ';已付总额清零' . $unassignSummary;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\service\AiChatService;
|
||||||
|
use think\facade\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日常护理 - 血糖照护 AI(累计 7 天有记录后:血糖建议 + 中医调理方向)
|
||||||
|
*/
|
||||||
|
class DailyBloodCareAiLogic
|
||||||
|
{
|
||||||
|
private const CACHE_TTL = 86400;
|
||||||
|
|
||||||
|
private const MIN_RECORD_DAYS = 7;
|
||||||
|
|
||||||
|
private const AI_TIMEOUT_SEC = 14;
|
||||||
|
|
||||||
|
private const AI_STREAM_TIMEOUT_SEC = 22;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,error?:string,data?:array}
|
||||||
|
*/
|
||||||
|
public static function getDailyCarePlan(int $diagnosisId, bool $refresh = false): array
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return ['ok' => 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<string, mixed>):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<string,mixed> $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<string,mixed> $context
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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<string,mixed> $context
|
||||||
|
* @return array<string,mixed>|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<string,mixed> $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 = <<<SYS
|
||||||
|
你是基层中医糖尿病照护助手,面向农村中老年患者,用通俗中文给「血糖管理 + 中医调理方向」建议。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
1. 不得开具具体中药处方名、剂量、加减;不得指导自行增减西药/胰岛素。
|
||||||
|
2. 中医部分写调理思路、食疗方向、穴位艾灸注意事项,强调需面诊脉诊后由医师定方。
|
||||||
|
3. 结合提供的血糖统计与逐日记录,给出可执行的监测与生活方式建议。
|
||||||
|
4. 只输出 JSON:
|
||||||
|
{"summary":"一句话总评","blood_advice":"血糖监测与饮食运动","tcm_plan":"中医辨证与调理方案","watch_points":["留意1","留意2"],"next_steps":"下一步复诊或就医提醒"}
|
||||||
|
SYS;
|
||||||
|
|
||||||
|
$user = sprintf(
|
||||||
|
"患者:%s,%s,%s岁。证型登记:%s。症状摘要:%s。\n日期:%s。\n近7日血糖:%s。\n统计:%s;%s。\n请根据控制好坏给出个性化 JSON。",
|
||||||
|
$context['patient_name'] ?: '患者',
|
||||||
|
$context['gender_text'] ?? '',
|
||||||
|
$context['age'] ?? 0,
|
||||||
|
$syndrome !== '' ? $syndrome : '未登记',
|
||||||
|
$symptoms !== '' ? mb_substr($symptoms, 0, 120) : '无',
|
||||||
|
$context['today'] ?? date('Y-m-d'),
|
||||||
|
$bloodLine,
|
||||||
|
$stat7Line,
|
||||||
|
$stat30Line
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($refresh) {
|
||||||
|
$user .= "\n\n用户点了「换一换」,请换一套不同表述但同样严谨的建议,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$system, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $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 = <<<SYS
|
||||||
|
你是基层中医糖尿病照护助手,给农村中老年患者写血糖与中医调理建议。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
1. 不得写具体中药方名剂量、不得指导自行调药。
|
||||||
|
2. 中医写辨证思路与调理方向,强调面诊后由医师定方。
|
||||||
|
3. 严格按下面 5 行输出,不要 JSON、不要 markdown:
|
||||||
|
总评:(一句话)
|
||||||
|
血糖:(监测、饮食、运动,2-4句)
|
||||||
|
中医:(辨证思路、食疗艾灸方向,2-4句)
|
||||||
|
留意:(用顿号隔开 2-4 条)
|
||||||
|
复诊:(下一步)
|
||||||
|
SYS;
|
||||||
|
|
||||||
|
$user = sprintf(
|
||||||
|
"患者:%s,%s,%s岁。证型:%s。症状:%s。\n近7日血糖:%s。\n%s;%s。",
|
||||||
|
$context['patient_name'] ?: '患者',
|
||||||
|
$context['gender_text'] ?? '',
|
||||||
|
$context['age'] ?? 0,
|
||||||
|
($context['syndrome_type'] ?? '') ?: '未登记',
|
||||||
|
mb_substr((string) ($context['symptoms'] ?? ''), 0, 80) ?: '无',
|
||||||
|
$bloodLine,
|
||||||
|
$stat7Line,
|
||||||
|
$stat30Line
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($refresh) {
|
||||||
|
$user .= "\n\n换一换,重新写一套,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$system, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $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<string,mixed> $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<string,mixed>|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<string,mixed> $raw
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
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<string,mixed>|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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user