This commit is contained in:
Your Name
2026-04-30 17:36:47 +08:00
parent 4c21ee5938
commit 7e557b1ea2
33 changed files with 8718 additions and 94 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"Bash(python *)",
"Bash(php think *)",
"Bash(python3 -c ' *)",
"Bash(python3 *)"
"Bash(python3 *)",
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)"
]
}
}
+6 -1
View File
@@ -20,7 +20,7 @@ export function orderTodayRevenue() {
return request.get({ url: '/order.order/todayRevenue' })
}
// 订单统计(-1全部已支付类型合计,0退款,1挂号费,2问诊费,3药品费用,4首付,5尾款,6其他
// 订单统计(-1全部已支付类型合计,0退款,18:含全部费用与驼奶费用等
export function orderStats(params?: { order_type?: number; days?: number }) {
return request.get({ url: '/order.order/orderStats', params })
}
@@ -80,6 +80,11 @@ export function orderExport(params: any) {
return request.get({ url: '/order.order/export', params }, { isReturnDefaultResponse: true })
}
// 拆分订单(待支付 / 付呗待审核)
export function orderSplit(params: { id: number; amounts: number[]; order_types: number[] }) {
return request.post({ url: '/order.order/split', params })
}
// 搜索患者(从诊单表)
export function searchPatients(params: any) {
return request.get({ url: '/tcm.diagnosis/searchPatient', params })
+6 -1
View File
@@ -328,7 +328,12 @@ export function prescriptionOrderWithdraw(params: { id: number }) {
}
/** 业务订单物流轨迹(快递100 + 顺丰/京东官网链接) */
export function prescriptionOrderLogisticsTrace(params: { id: number; express_company?: string }) {
export function prescriptionOrderLogisticsTrace(params: {
id: number
express_company?: string
/** 顺丰等:收件电话(建议完整 11 位,与面单一致;至少后 4 位) */
phone_tail?: string
}) {
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
}
@@ -1,11 +1,11 @@
<template>
<el-date-picker
v-model="content"
type="datetimerange"
:type="pickerType"
range-separator="-"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:start-placeholder="startPlaceholder"
:end-placeholder="endPlaceholder"
:value-format="valueFormat"
clearable
></el-date-picker>
</template>
@@ -15,10 +15,18 @@ const props = withDefaults(
defineProps<{
startTime?: string
endTime?: string
pickerType?: 'daterange' | 'datetimerange'
valueFormat?: string
startPlaceholder?: string
endPlaceholder?: string
}>(),
{
startTime: '',
endTime: ''
endTime: '',
pickerType: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
}
)
const emit = defineEmits(['update:startTime', 'update:endTime'])
@@ -1152,6 +1152,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
</el-col>
@@ -1,12 +1,12 @@
<!-- 处方业务订单zyt_tcm_prescription_order非支付单 -->
<template>
<div class="prescription-order-page prescription-order-list px-4 py-4 pb-8">
<header class="po-page-head">
<!-- <header class="po-page-head">
<div>
<h1 class="po-page-title">处方业务订单</h1>
<p class="po-page-desc">履约与双审详情可同屏查看<strong>处方单</strong><strong>关联支付单</strong></p>
</div>
</header>
</header> -->
<el-card class="!border-none shadow-sm mb-4" shadow="never">
<div class="flex flex-col gap-3">
@@ -58,6 +58,38 @@
<span class="font-medium">{{ tab.label }}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-gray-500 text-sm font-medium w-20 shrink-0">供货方式</span>
<div
v-for="tab in supplyModeTabs"
:key="'sm-' + String(tab.value)"
:class="[
'px-4 py-1.5 rounded-lg cursor-pointer transition-all duration-200 select-none text-sm',
queryParams.supply_mode === tab.value
? 'bg-primary text-white shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
]"
@click="handleSupplyModeTabClick(tab.value)"
>
<span class="font-medium">{{ tab.label }}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-gray-500 text-sm font-medium w-20 shrink-0">快递方式</span>
<div
v-for="tab in expressCompanyTabs"
:key="'ec-' + String(tab.value)"
:class="[
'px-4 py-1.5 rounded-lg cursor-pointer transition-all duration-200 select-none text-sm',
queryParams.express_company === tab.value
? 'bg-primary text-white shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
]"
@click="handleExpressCompanyTabClick(tab.value)"
>
<span class="font-medium">{{ tab.label }}</span>
</div>
</div>
</div>
</el-card>
@@ -97,12 +129,12 @@
<p class="relative mt-2 text-xs text-slate-500 leading-relaxed">
{{ listStats.periodLine }}
</p>
<p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
<!-- <p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
{{ listStats.orderSplitHint }}
</p>
<p class="relative mt-0.5 text-xs text-slate-400 leading-relaxed">
{{ listStats.scopeHint }}
</p>
</p> -->
</div>
<div
class="po-stat-surface po-stat-surface--pay relative overflow-hidden rounded-xl border border-slate-200/90 px-5 py-4 shadow-sm"
@@ -135,9 +167,9 @@
<p class="relative mt-2 text-xs text-slate-500 leading-relaxed">
{{ listStats.periodLine }}
</p>
<p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
<!-- <p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
业绩 = 除业务订单已取消(4)外关联支付金额下方为合计与已取消明细
</p>
</p> -->
</div>
</div>
<el-form class="ls-form" :model="queryParams" inline>
@@ -145,6 +177,10 @@
<DaterangePicker
v-model:startTime="queryParams.start_time"
v-model:endTime="queryParams.end_time"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="!w-full"
/>
</el-form-item>
@@ -172,6 +208,14 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[220px]" label="快递">
<el-input
v-model="queryParams.express_keyword"
placeholder="快递单号 / 公司"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[200px]" label="医生">
<el-select
v-model="queryParams.doctor_id"
@@ -228,11 +272,63 @@
<el-card v-loading="pager.loading" class="!border-none mt-4 po-table-card shadow-sm" shadow="never">
<div class="po-table-toolbar">
<span class="po-table-title text-gray-800">订单列表</span>
<span class="po-table-hint text-gray-400"> {{ pager.count }} </span>
<div class="po-table-toolbar__title-wrap">
<span class="po-table-title text-gray-800">订单列表</span>
<span class="po-table-hint text-gray-400"> {{ pager.count }} </span>
</div>
<div class="po-focus-board">
<div
class="po-focus-board__item is-pending-rx"
:class="{ 'is-active': activeFocusKey === 'pendingRx' }"
@click="handleFocusBoardSearch('pendingRx')"
>
<span class="po-focus-board__label">待处方审核</span>
<strong class="po-focus-board__value">{{ focusStats.pendingRx }}</strong>
</div>
<div
class="po-focus-board__item is-pending-pay"
:class="{ 'is-active': activeFocusKey === 'pendingPay' }"
@click="handleFocusBoardSearch('pendingPay')"
>
<span class="po-focus-board__label">待支付审核</span>
<strong class="po-focus-board__value">{{ focusStats.pendingPay }}</strong>
</div>
<div
class="po-focus-board__item is-pending-ship"
:class="{ 'is-active': activeFocusKey === 'pendingShip' }"
@click="handleFocusBoardSearch('pendingShip')"
>
<span class="po-focus-board__label">待发货</span>
<strong class="po-focus-board__value">{{ focusStats.pendingShip }}</strong>
</div>
<div
class="po-focus-board__item is-risk"
:class="{ 'is-active': activeFocusKey === 'risk' }"
@click="handleFocusBoardSearch('risk')"
>
<span class="po-focus-board__label">重点异常</span>
<strong class="po-focus-board__value">{{ focusStats.risk }}</strong>
</div>
</div>
</div>
<el-table :data="pager.lists" size="default" stripe class="po-data-table">
<el-table-column label="订单号" prop="order_no" min-width="150" show-overflow-tooltip />
<el-table :data="pager.lists" size="default" stripe class="po-data-table" :row-class-name="orderRowClassName">
<el-table-column label="订单号" min-width="178" show-overflow-tooltip>
<template #default="{ row }">
<div class="po-order-no-cell">
<div class="po-order-no-main">{{ row.order_no || '—' }}</div>
<div class="po-order-no-sub">
<el-tag
size="small"
effect="plain"
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
>
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}
</el-tag>
<span class="text-gray-400">#{{ row.id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="处方ID" prop="prescription_id" width="86" />
<el-table-column label="诊单ID" prop="diagnosis_id" width="86" />
<el-table-column label="收货人" min-width="100">
@@ -294,23 +390,23 @@
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
</template>
</el-table-column>
<el-table-column label="处方审核" width="96">
<el-table-column label="处方审核" width="110">
<template #default="{ row }">
<el-tag :type="auditTagType(row.prescription_audit_status)" size="small">
<el-tag :type="auditTagType(row.prescription_audit_status)" size="small" :class="statusTagClass('rx', row)">
{{ auditStatusText(row.prescription_audit_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="支付单审核" width="102">
<el-table-column label="支付单审核" width="110">
<template #default="{ row }">
<el-tag :type="auditTagType(row.payment_slip_audit_status)" size="small">
<el-tag :type="auditTagType(row.payment_slip_audit_status)" size="small" :class="statusTagClass('pay', row)">
{{ auditStatusText(row.payment_slip_audit_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="履约" width="96">
<el-table-column label="履约" width="108">
<template #default="{ row }">
<el-tag :type="fulfillmentTagType(row.fulfillment_status)" size="small">
<el-tag :type="fulfillmentTagType(row.fulfillment_status)" size="small" :class="statusTagClass('fulfillment', row)">
{{ fulfillmentText(row.fulfillment_status) }}
</el-tag>
</template>
@@ -878,7 +974,19 @@
<span class="font-medium text-[15px]">履约与收货信息</span>
</template>
<el-descriptions :column="3" border size="small" class="po-desc">
<el-descriptions-item label="诊单ID">{{ detailData.diagnosis_id }}</el-descriptions-item>
<el-descriptions-item label="诊单ID">
<span class="font-mono">{{ detailData.diagnosis_id }}</span>
<el-button
v-if="Number(detailData.diagnosis_id) > 0"
type="primary"
link
size="small"
class="ml-2 align-baseline"
@click="openDiagnosisPatientDetailFromOrder"
>
查看患者信息
</el-button>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
@@ -968,9 +1076,17 @@
<span class="font-medium text-[15px]">物流轨迹</span>
</template>
<p class="po-logistics-tip text-gray-400 text-xs mb-3 font-normal">
优先读库,无记录时走快递100顺丰建议手机后四位正确(已自动带入收货手机)
优先读库,无记录时走快递100顺丰会校验单号+电话(快递100要求完整手机号更易通过);默认预填订单收货手机,若仍提示验证码错误请改成与顺丰面单一致的号码
</p>
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-input
v-model="logisticsTracePhoneTail"
clearable
maxlength="20"
placeholder="收件手机(建议11位,与面单一致)"
class="w-[180px]"
@keyup.enter="fetchLogisticsTrace"
/>
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
@@ -1380,6 +1496,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
</el-col>
@@ -1654,6 +1771,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
<el-form-item label="支付金额" prop="pay_amount">
@@ -2132,11 +2250,14 @@
</el-card>
</div>
</el-drawer>
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
</div>
</template>
<script lang="ts" setup name="prescriptionOrderList">
import { computed, onMounted, reactive, ref, nextTick, watch } from 'vue'
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import { Refresh, Loading, ArrowDown, InfoFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import {
@@ -2172,8 +2293,24 @@ import { ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import useUserStore from '@/stores/modules/user'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
const userStore = useUserStore()
const diagnosisViewRef = ref<{
openViewOnly: (id: number) => Promise<void>
} | null>(null)
/** 打开关联诊单抽屉(仅查看,与 tcm/diagnosis/edit openViewOnly 一致) */
function openDiagnosisPatientDetailFromOrder() {
const id = Number(detailData.value?.diagnosis_id)
if (!id) {
feedback.msgWarning('本订单未关联诊单,无法查看患者诊单信息')
return
}
void diagnosisViewRef.value?.openViewOnly(id)
}
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
@@ -2358,10 +2495,35 @@ const fulfillmentStatusTabs = [
]
function handleFulfillmentStatusTabClick(value: number | '') {
activeFocusKey.value = ''
queryParams.fulfillment_status = value
resetPage()
}
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
{ label: '甘草', value: 'gancao' as const },
{ label: '自营', value: 'self' as const }
]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
queryParams.supply_mode = value
resetPage()
}
const expressCompanyTabs = [
{ label: '全部', value: '' as '' | 'sf' | 'jd' },
{ label: '顺丰', value: 'sf' as const },
{ label: '京东', value: 'jd' as const }
]
function handleExpressCompanyTabClick(value: '' | 'sf' | 'jd') {
queryParams.express_company = value
resetPage()
}
const queryParams = reactive({
/** 创建时间区间(与列表筛选一致;不影响「今日」统计) */
start_time: '' as string,
@@ -2370,6 +2532,10 @@ const queryParams = reactive({
prescription_id: '' as string | number,
/** 诊单患者姓名/手机号(后台关联 diagnosis */
patient_keyword: '',
/** 快递检索:快递单号 / 快递公司 */
express_keyword: '',
/** 快递方式快捷筛选:顺丰/京东 */
express_company: '' as '' | 'sf' | 'jd',
/** 开方医生(后台:关联处方 creator_id */
doctor_id: '' as number | '',
/** 诊单医助(后台:关联诊单 assistant_id */
@@ -2378,7 +2544,9 @@ const queryParams = reactive({
assistant_dept_id: '' as number | '',
fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | ''
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self'
})
const rxAuditTabs = [
@@ -2395,14 +2563,47 @@ const payAuditTabs = [
]
function handleRxAuditTabClick(val: number | '') {
activeFocusKey.value = ''
queryParams.prescription_audit_status = val
resetPage()
}
function handlePayAuditTabClick(val: number | '') {
activeFocusKey.value = ''
queryParams.payment_slip_audit_status = val
resetPage()
}
function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk') {
// 再点同一卡片:取消该快捷筛选
if (activeFocusKey.value === key) {
activeFocusKey.value = ''
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.fulfillment_status = ''
resetPage()
return
}
activeFocusKey.value = key
// 快捷筛选统一先清掉三类状态条件,再设置目标条件
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.fulfillment_status = ''
if (key === 'pendingRx') {
queryParams.prescription_audit_status = 0
} else if (key === 'pendingPay') {
queryParams.prescription_audit_status = 1
queryParams.payment_slip_audit_status = 0
} else if (key === 'pendingShip') {
queryParams.fulfillment_status = 2
} else if (key === 'risk') {
// 重点异常优先按“审核驳回”检索
queryParams.payment_slip_audit_status = 2
}
resetPage()
}
async function fetchLists(params: Record<string, unknown>) {
const p: Record<string, unknown> = { ...params }
if (p.prescription_id === '' || p.prescription_id === undefined) {
@@ -2419,6 +2620,9 @@ async function fetchLists(params: Record<string, unknown>) {
if (p.payment_slip_audit_status === '' || p.payment_slip_audit_status === undefined) {
delete p.payment_slip_audit_status
}
if (!p.supply_mode || p.supply_mode === '') {
delete p.supply_mode
}
if (p.doctor_id === '' || p.doctor_id === undefined || p.doctor_id === null) {
delete p.doctor_id
} else {
@@ -2436,9 +2640,19 @@ async function fetchLists(params: Record<string, unknown>) {
}
if (!p.order_no) delete p.order_no
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
if (!p.express_company || p.express_company === '') delete p.express_company
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
delete p.start_time
delete p.end_time
} else {
// 当前页面按“日”筛选:请求接口时展开为整天区间,避免结束日只落在 00:00:00。
const st = String(p.start_time || '').trim()
const et = String(p.end_time || '').trim()
if (/^\d{4}-\d{2}-\d{2}$/.test(st) && /^\d{4}-\d{2}-\d{2}$/.test(et)) {
p.start_time = `${st} 00:00:00`
p.end_time = `${et} 23:59:59`
}
}
return prescriptionOrderLists(p)
}
@@ -2541,6 +2755,44 @@ const listStats = computed(() => {
}
})
const focusStats = computed(() => {
const ex = pager.extend as Record<string, unknown> | undefined
const n = (v: unknown) => {
const x = Number(v)
if (!Number.isFinite(x) || x < 0) return 0
return Math.trunc(x)
}
const pendingRx = n(ex?.focus_pending_rx_count)
const pendingPay = n(ex?.focus_pending_pay_count)
const pendingShip = n(ex?.focus_pending_ship_count)
const risk = n(ex?.focus_risk_count)
return { pendingRx, pendingPay, pendingShip, risk }
})
function statusTagClass(type: 'rx' | 'pay' | 'fulfillment', row: any) {
const rx = Number(row?.prescription_audit_status)
const pay = Number(row?.payment_slip_audit_status)
const fs = Number(row?.fulfillment_status)
if (type === 'rx' && rx === 0) return 'po-status-tag is-focus'
if (type === 'pay' && rx === 1 && pay === 0) return 'po-status-tag is-focus'
if (type === 'fulfillment' && fs === 2) return 'po-status-tag is-focus'
if ((type === 'rx' && rx === 2) || (type === 'pay' && pay === 2) || (type === 'fulfillment' && fs === 4)) {
return 'po-status-tag is-risk'
}
return 'po-status-tag'
}
function orderRowClassName({ row }: { row: any }) {
const rx = Number(row?.prescription_audit_status)
const pay = Number(row?.payment_slip_audit_status)
const fs = Number(row?.fulfillment_status)
const phoneMismatch = recipientVsPrescriptionPhoneMismatch(row?.recipient_phone, row?.prescription_phone)
if (rx === 2 || pay === 2 || phoneMismatch) return 'po-row-risk'
if (rx === 0 || (rx === 1 && pay === 0) || fs === 2) return 'po-row-focus'
if (fs === 3 || fs === 6) return 'po-row-done'
return ''
}
watch(
showPrescriptionAuditFilter,
(show) => {
@@ -2553,17 +2805,21 @@ watch(
)
function handleReset() {
activeFocusKey.value = ''
queryParams.start_time = ''
queryParams.end_time = ''
queryParams.order_no = ''
queryParams.prescription_id = ''
queryParams.patient_keyword = ''
queryParams.express_keyword = ''
queryParams.express_company = ''
queryParams.doctor_id = ''
queryParams.assistant_id = ''
queryParams.assistant_dept_id = ''
queryParams.fulfillment_status = ''
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
resetParams()
}
@@ -2575,7 +2831,8 @@ function feeTypeText(t: number | undefined) {
4: '首付费用',
5: '尾款费用',
6: '其他费用',
7: '全部费用'
7: '全部费用',
8: '驼奶费用'
}
return m[Number(t)] ?? '—'
}
@@ -2974,6 +3231,8 @@ function formatOrderTime(v: unknown) {
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
const logisticsTracePhoneTail = ref('')
const detailLogs = ref<any[]>([])
@@ -3048,10 +3307,15 @@ async function fetchLogisticsTrace() {
if (!id) return
logisticsTraceLoading.value = true
try {
const res: any = await prescriptionOrderLogisticsTrace({
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
express_company: detailLogisticsExpress.value
})
}
if (digits.length >= 4) {
params.phone_tail = digits
}
const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
} catch {
logisticsTracePayload.value = null
@@ -3135,6 +3399,7 @@ async function openDetail(id: number) {
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
detailVisible.value = true
@@ -3145,6 +3410,8 @@ async function openDetail(id: number) {
detailData.value = d
if (d) {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) {
fetchLogisticsTrace()
}
@@ -4605,21 +4872,142 @@ async function downloadPrescriptionSlipPdf() {
.po-table-toolbar {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.po-table-toolbar__title-wrap {
display: flex;
align-items: baseline;
gap: 10px;
}
.po-table-title {
font-size: 16px;
font-weight: 600;
}
.po-focus-board {
display: grid;
grid-template-columns: repeat(4, minmax(106px, 1fr));
gap: 8px;
width: min(100%, 520px);
}
.po-focus-board__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
background: #fff;
padding: 7px 10px;
cursor: pointer;
transition: all 0.18s ease;
}
.po-focus-board__item:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px -14px rgba(15, 23, 42, 0.38);
}
.po-focus-board__item.is-active {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 24%, transparent) inset;
transform: translateY(-1px);
}
.po-focus-board__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.po-focus-board__value {
font-size: 18px;
line-height: 1;
font-weight: 700;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
}
.po-focus-board__item.is-pending-rx {
border-color: color-mix(in srgb, var(--el-color-warning) 34%, #fff);
background: color-mix(in srgb, var(--el-color-warning-light-9) 66%, #fff);
}
.po-focus-board__item.is-pending-pay {
border-color: color-mix(in srgb, #2563eb 30%, #fff);
background: color-mix(in srgb, #dbeafe 62%, #fff);
}
.po-focus-board__item.is-pending-ship {
border-color: color-mix(in srgb, var(--el-color-primary) 30%, #fff);
background: color-mix(in srgb, var(--el-color-primary-light-9) 64%, #fff);
}
.po-focus-board__item.is-risk {
border-color: color-mix(in srgb, var(--el-color-danger) 34%, #fff);
background: color-mix(in srgb, var(--el-color-danger-light-9) 66%, #fff);
}
@media (max-width: 1024px) {
.po-focus-board {
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.po-data-table :deep(th.el-table__cell) {
font-weight: 600;
background: var(--el-fill-color-light) !important;
}
.po-order-no-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.po-order-no-main {
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.35;
}
.po-order-no-sub {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.po-status-tag {
font-weight: 600;
}
.po-status-tag.is-focus {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--el-color-warning) 24%, transparent) inset;
}
.po-status-tag.is-risk {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--el-color-danger) 30%, transparent) inset;
}
.po-data-table :deep(.el-table__row.po-row-focus > td.el-table__cell) {
background: color-mix(in srgb, var(--el-color-warning-light-9) 38%, #fff) !important;
}
.po-data-table :deep(.el-table__row.po-row-risk > td.el-table__cell) {
background: color-mix(in srgb, var(--el-color-danger-light-9) 42%, #fff) !important;
}
.po-data-table :deep(.el-table__row.po-row-done > td.el-table__cell) {
background: color-mix(in srgb, var(--el-color-success-light-9) 24%, #fff) !important;
}
/* 改版 Drawer 样式 */
.po-detail-drawer :deep(.el-drawer__header) {
margin-bottom: 0;
File diff suppressed because it is too large Load Diff
+141 -4
View File
@@ -49,6 +49,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
@@ -199,7 +200,7 @@
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['order.order/detail']"
@@ -217,6 +218,15 @@
>
编辑
</el-button>
<el-button
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && row.payment_method === 'fubei')"
v-perms="['order.order/split']"
type="primary"
link
@click="openSplitOrder(row)"
>
拆分
</el-button>
<el-button
type="primary"
v-perms="['order.order/wxQrcode']"
@@ -542,6 +552,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
@@ -640,6 +651,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
</el-form>
@@ -648,6 +660,67 @@
<el-button type="primary" :loading="editOrderLoading" @click="submitEditOrder">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="splitDialogVisible" title="拆分订单" width="620px" destroy-on-close @closed="splitSource = null">
<template v-if="splitSource">
<p class="text-sm text-gray-600 mb-3">
原订单号 <span class="font-mono">{{ splitSource.order_no }}</span>金额
<span class="text-red-500 font-semibold">¥{{ splitSource.amount }}</span
>每笔子单可分别选择<strong>订单类型</strong>子单继承患者创建人及企微收款信息<strong>已支付</strong>子单继承支付方式支付时间与交易号原单将软删除若已关联业务订单关联会同步到全部子单
</p>
<el-form label-width="88px" @submit.prevent>
<el-form-item label="拆分管数">
<el-input-number
v-model="splitPartCount"
:min="2"
:max="20"
:step="1"
step-strictly
@change="redistributeSplitAmounts"
/>
<span class="text-xs text-gray-400 ml-2">220 </span>
</el-form-item>
</el-form>
<div class="space-y-2 mb-3 max-h-[320px] overflow-y-auto pr-1">
<div v-for="(_amt, idx) in splitAmounts" :key="idx" class="flex items-center gap-2 flex-wrap">
<span class="text-sm text-gray-500 w-14 shrink-0"> {{ idx + 1 }} </span>
<el-select v-model="splitOrderTypes[idx]" placeholder="订单类型" class="w-[136px] shrink-0">
<el-option label="挂号费" :value="1" />
<el-option label="问诊费" :value="2" />
<el-option label="药品费用" :value="3" />
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
<el-input-number
v-model="splitAmounts[idx]"
:min="0.01"
:step="0.01"
:precision="2"
class="flex-1 min-w-[140px]"
controls-position="right"
/>
</div>
</div>
<el-alert
:type="splitSumMatches ? 'success' : 'warning'"
:closable="false"
show-icon
>
<template #title>
子单合计 ¥{{ splitSum.toFixed(2) }}
{{ splitSumMatches ? '与原价一致,可提交' : '须与原价完全一致方可拆分' }}
</template>
</el-alert>
</template>
<template #footer>
<el-button @click="splitDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="splitLoading" :disabled="!splitSumMatches" @click="submitSplitOrder">
确定拆分
</el-button>
</template>
</el-dialog>
<!-- 小程序码弹窗 -->
<el-dialog
v-model="qrcodeDialogVisible"
@@ -705,7 +778,8 @@ import {
wechatPay,
orderActionLogs,
orderActionLogStats,
orderBatchAssignAssistant
orderBatchAssignAssistant,
orderSplit
} from '@/api/order'
import { getAssistants } from '@/api/tcm'
import { generateOrderQrcode } from '@/api/tcm'
@@ -1109,8 +1183,9 @@ const getOrderTypeText = (type: number) => {
3: '药品费用',
4: '首付费用',
5: '尾款费用',
6:'其他费用',
7:'全部费用',
6: '其他费用',
7: '全部费用',
8: '驼奶费用',
}
return typeMap[type] || '未知'
}
@@ -1210,6 +1285,68 @@ const submitEditOrder = async () => {
}
}
/** 拆分订单(待支付 / 已支付 / 付呗待审核) */
const splitDialogVisible = ref(false)
const splitSource = ref<any>(null)
const splitPartCount = ref(2)
const splitAmounts = ref<number[]>([])
const splitOrderTypes = ref<number[]>([])
const splitLoading = ref(false)
const redistributeSplitAmounts = () => {
if (!splitSource.value) return
const total = Number(splitSource.value.amount)
const n = Math.max(2, Math.min(20, Math.floor(Number(splitPartCount.value) || 2)))
splitPartCount.value = n
const cents = Math.round(total * 100)
const base = Math.floor(cents / n)
const rem = cents % n
const next: number[] = []
for (let i = 0; i < n; i++) {
const c = base + (i < rem ? 1 : 0)
next.push(c / 100)
}
const prevTypes = [...splitOrderTypes.value]
const defaultType = Number(splitSource.value.order_type ?? 6)
splitOrderTypes.value = next.map((_, i) => (i < prevTypes.length ? prevTypes[i]! : defaultType))
splitAmounts.value = next
}
const openSplitOrder = (row: any) => {
splitSource.value = row
splitPartCount.value = 2
redistributeSplitAmounts()
splitDialogVisible.value = true
}
const splitSum = computed(() => splitAmounts.value.reduce((s, x) => s + Number(x || 0), 0))
const splitSumMatches = computed(() => {
if (!splitSource.value) return false
const orig = Number(splitSource.value.amount)
return Math.abs(splitSum.value - orig) < 0.0001
})
const submitSplitOrder = async () => {
if (!splitSource.value || !splitSumMatches.value) return
splitLoading.value = true
try {
await orderSplit({
id: splitSource.value.id,
amounts: splitAmounts.value.map((x) => Number(x)),
order_types: splitOrderTypes.value.map((x) => Number(x))
})
feedback.msgSuccess('拆分成功')
splitDialogVisible.value = false
getLists()
fetchTodayRevenue()
} catch (e: any) {
if (e?.msg) feedback.msgError(e.msg)
} finally {
splitLoading.value = false
}
}
// 查看详情
const handleDetail = async (row: any) => {
try {
@@ -1,7 +1,7 @@
<template>
<div class="blood-record-list">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
<el-button v-if="!readOnly" type="primary" @click="handleAdd">添加记录</el-button>
<el-select v-model="trendDays" @change="handleTrendDaysChange" class="ml-2" style="width: 150px">
<el-option label="最近7天" :value="7" />
<el-option label="最近30天" :value="30" />
@@ -62,7 +62,7 @@
<el-table-column prop="western_medicine" label="西药" show-overflow-tooltip />
<el-table-column prop="insulin" label="胰岛素" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right">
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
@@ -185,6 +185,10 @@ const props = defineProps({
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
@@ -1,6 +1,6 @@
<template>
<div class="case-record-list">
<div class="mb-3 flex justify-end">
<div v-if="!readOnly" class="mb-3 flex justify-end">
<el-button type="primary" size="small" @click="handleOpenPrescription">开方</el-button>
</div>
<el-table :data="caseList" border v-loading="loading">
@@ -46,6 +46,11 @@ const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
/** 只读:隐藏开方(与诊单 openViewOnly 一致) */
readOnly: {
type: Boolean,
default: false
}
})
@@ -1,6 +1,6 @@
<template>
<div class="diet-record-list">
<div class="mb-4">
<div v-if="!readOnly" class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
</div>
@@ -25,7 +25,7 @@
</template>
</el-table-column>
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right">
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
@@ -125,6 +125,10 @@ const props = defineProps({
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
@@ -1,6 +1,6 @@
<template>
<div class="exercise-record-list">
<div class="mb-4">
<div v-if="!readOnly" class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
</div>
@@ -18,7 +18,7 @@
</template>
</el-table-column>
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right">
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
@@ -99,6 +99,10 @@ const props = defineProps({
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
+381 -28
View File
@@ -2,14 +2,57 @@
<div class="edit-drawer">
<el-drawer
v-model="visible"
:title="drawerTitle"
size="60%"
:before-close="handleClose"
:z-index="viewOnly ? 4000 : 1500"
:modal="true"
class="diagnosis-drawer"
class="diagnosis-drawer tcm-diagnosis-drawer"
>
<el-tabs v-model="activeTab" class="diagnosis-tabs">
<template #header>
<div class="tcm-dd-header">
<div class="tcm-dd-header__row">
<h2 class="tcm-dd-header__title">{{ drawerTitle }}</h2>
<el-tag
v-if="viewOnly"
type="info"
effect="plain"
round
size="small"
class="tcm-dd-header__badge"
>
只读
</el-tag>
<el-tag
v-else-if="mode === 'add'"
type="success"
effect="plain"
round
size="small"
class="tcm-dd-header__badge"
>
新建
</el-tag>
<el-tag
v-else
type="warning"
effect="plain"
round
size="small"
class="tcm-dd-header__badge"
>
编辑
</el-tag>
</div>
<p v-if="formData.patient_name || formData.id" class="tcm-dd-header__sub">
<span v-if="formData.patient_name" class="tcm-dd-header__name">{{ formData.patient_name }}</span>
<span v-if="formData.id" class="tcm-dd-header__id">
<span v-if="formData.patient_name" class="tcm-dd-header__sep">·</span>
诊单 {{ formData.patient_id || formData.id }}
</span>
</p>
</div>
</template>
<el-tabs v-model="activeTab" class="diagnosis-tabs tcm-diagnosis-tabs">
<!-- 基本信息标签页 -->
<el-tab-pane label="病历" name="basic">
<el-form
@@ -499,8 +542,8 @@
<el-form-item label="病史补充" prop="remark">
<el-input
v-mode="formData.remark"
type="ltextarea"
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="请输入病史补充"
/>
@@ -513,6 +556,7 @@
<NoteTimeline
:notes="diagNotes"
:diagnosis-id="Number(formData.id)"
:readonly="viewOnly"
@refresh="fetchDiagNotes"
/>
</div>
@@ -524,6 +568,7 @@
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
</el-tab-pane>
@@ -534,6 +579,7 @@
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加饮食记录" />
</el-tab-pane>
@@ -544,6 +590,7 @@
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加运动打卡记录" />
</el-tab-pane>
@@ -555,6 +602,7 @@
<case-record-list
ref="caseRecordListRef"
:diagnosis-id="Number(formData.id)"
:read-only="viewOnly"
@view="handleViewCase"
@openPrescription="handleOpenPrescription"
/>
@@ -625,15 +673,18 @@
<tcm-prescription ref="prescriptionRef" @success="caseRecordListRef?.refresh?.()" />
<template #footer>
<div class="flex justify-end gap-3 px-4 pb-4">
<el-button @click="handleClose">{{ viewOnly ? '关闭' : '取消' }}</el-button>
<div class="tcm-dd-footer">
<el-button class="tcm-dd-footer__btn" @click="handleClose">
{{ viewOnly ? '关闭' : '取消' }}
</el-button>
<el-button
v-if="!viewOnly && activeTab === 'basic'"
class="tcm-dd-footer__btn"
type="primary"
@click="handleSubmit"
:loading="submitting"
>
确定
保存病历
</el-button>
</div>
</template>
@@ -1139,6 +1190,7 @@ const handleViewCase = (row: any) => {
// 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用)
const handleOpenPrescription = () => {
if (viewOnly.value) return
const data = {
id: formData.value.id,
diagnosis_id: formData.value.id,
@@ -1245,14 +1297,24 @@ defineExpose({
}
:deep(.el-divider) {
margin: 24px 0 20px;
margin: 28px 0 22px;
border-color: transparent;
&::before,
&::after {
border-top-color: #e2e8f0;
}
.el-divider__text {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #303133;
background: #f5f7fa;
padding: 0 16px;
color: #0f172a;
letter-spacing: -0.01em;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
padding: 7px 16px;
border-radius: 999px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgb(15 23 42 / 6%);
}
}
@@ -1373,24 +1435,111 @@ defineExpose({
color: #909399;
}
// 输入框样式优化
:deep(.el-input__inner),
// 输入圆角:医疗表单更易扫读
:deep(.el-input__wrapper) {
border-radius: 10px;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
:deep(.el-textarea__inner) {
border-radius: 4px;
border-radius: 10px;
}
// 选择器样式优化
:deep(.el-select) {
.el-input__inner {
border-radius: 4px;
}
:deep(.el-select .el-input__wrapper) {
border-radius: 10px;
}
// 日期选择器样式优化
:deep(.el-date-editor) {
.el-input__inner {
border-radius: 4px;
}
:deep(.el-date-editor .el-input__wrapper) {
border-radius: 10px;
}
:deep(.el-input-number .el-input__wrapper) {
border-radius: 10px;
}
}
.tcm-dd-header {
min-width: 0;
flex: 1;
padding-right: 8px;
}
.tcm-dd-header__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
.tcm-dd-header__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #0f172a;
line-height: 1.3;
letter-spacing: -0.02em;
}
.tcm-dd-header__badge {
flex-shrink: 0;
font-weight: 500;
}
.tcm-dd-header__sub {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.45;
color: #475569;
}
.tcm-dd-header__name {
font-weight: 600;
color: #334155;
}
.tcm-dd-header__id {
font-weight: 500;
color: #64748b;
}
.tcm-dd-header__sep {
margin: 0 4px;
color: #94a3b8;
}
.tcm-dd-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 20px 18px;
box-sizing: border-box;
border-top: 1px solid rgb(226 232 240 / 90%);
background: linear-gradient(180deg, rgb(248 250 252 / 95%) 0%, #fff 40%);
}
.tcm-dd-footer__btn {
min-height: 40px;
padding: 10px 20px;
font-weight: 600;
border-radius: 10px;
transition:
transform 0.15s ease,
box-shadow 0.2s ease;
}
.tcm-dd-footer__btn:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.tcm-dd-footer__btn {
transition: none;
}
}
</style>
@@ -1415,5 +1564,209 @@ defineExpose({
.el-select-dropdown {
z-index: 3000 !important;
}
</style>
<style lang="scss">
/**
* 诊单抽屉壳层挂到 body 时通过 .diagnosis-drawer 限定作用域
*/
.diagnosis-drawer.tcm-diagnosis-drawer {
.el-drawer__header {
align-items: flex-start !important;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #fff 0%, #f8fafc 100%);
}
.el-drawer__body {
background: #f8fafc;
}
.el-drawer__footer {
padding: 0 !important;
background: #fff;
box-shadow: 0 -4px 24px rgb(15 23 42 / 7%);
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar {
display: none;
height: 0;
}
.tcm-diagnosis-tabs .el-tabs__nav {
display: flex;
flex-wrap: nowrap;
float: none !important;
}
.tcm-diagnosis-tabs .el-tabs__item {
flex: 0 0 auto;
padding: 0 14px;
font-weight: 500;
color: #64748b;
transition: color 0.2s ease;
}
.tcm-diagnosis-tabs .el-tabs__item.is-active {
color: var(--el-color-primary);
font-weight: 600;
}
.tcm-diagnosis-tabs .el-tabs__active-bar {
height: 3px;
border-radius: 3px 3px 0 0;
}
.tcm-dd-footer {
box-shadow: none;
border-top: 1px solid #e2e8f0;
background: #fff;
}
}
@media (prefers-reduced-motion: reduce) {
.diagnosis-drawer.tcm-diagnosis-drawer .tcm-diagnosis-tabs .el-tabs__item {
transition: none;
}
}
/**
* 窄屏诊单抽屉drawer 通过 teleport 挂到 bodyscoped 无法作用到内部此处用抽屉 class H5 适配
*/
@media screen and (max-width: 768px) {
.diagnosis-drawer.el-drawer {
width: 100% !important;
max-width: 100vw;
}
.diagnosis-drawer .el-drawer__header {
padding: 12px 14px !important;
margin-bottom: 0 !important;
}
.diagnosis-drawer .el-drawer__body {
padding: 10px 12px 12px !important;
}
.diagnosis-drawer .el-drawer__footer {
padding: 0 !important;
}
.diagnosis-drawer .tcm-dd-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px)) !important;
}
.diagnosis-drawer .tcm-dd-footer__btn {
width: 100%;
min-height: 44px;
}
.diagnosis-drawer .tcm-dd-header__title {
font-size: 17px;
}
.diagnosis-drawer .diagnosis-tabs .el-tabs__header {
margin: 0 0 10px;
}
.diagnosis-drawer .el-tabs__item {
padding: 0 12px;
font-size: 13px;
min-height: 44px;
line-height: 44px;
}
.diagnosis-drawer .el-tabs__nav-wrap::after {
height: 1px;
}
/* 表单标签在上控件全宽避免 160px 标签 + 三列栅格把内容挤没 */
.diagnosis-drawer .el-form-item {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
margin-bottom: 14px !important;
}
.diagnosis-drawer .el-form-item__label {
width: 100% !important;
max-width: 100% !important;
height: auto !important;
line-height: 1.4 !important;
text-align: left !important;
justify-content: flex-start !important;
margin: 0 0 6px !important;
padding: 0 !important;
box-sizing: border-box;
}
.diagnosis-drawer .el-form-item__content {
margin-left: 0 !important;
max-width: 100% !important;
flex: 1 1 auto;
}
.diagnosis-drawer .el-row {
margin-left: 0 !important;
margin-right: 0 !important;
row-gap: 0;
}
.diagnosis-drawer .el-col,
.diagnosis-drawer .el-col-12,
.diagnosis-drawer .el-col-8,
.diagnosis-drawer .el-col-20,
.diagnosis-drawer [class*='el-col-'] {
flex: 0 0 100% !important;
max-width: 100% !important;
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
.diagnosis-drawer .el-input-number {
width: 100% !important;
}
.diagnosis-drawer .el-radio-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px 16px;
align-items: center;
}
.diagnosis-drawer .el-radio {
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.diagnosis-drawer .el-radio-button:last-child {
margin-right: 0;
}
.diagnosis-drawer .el-checkbox-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px 10px;
}
.diagnosis-drawer .el-divider {
margin: 18px 0 14px;
}
.diagnosis-drawer .el-divider__text {
font-size: 13px;
padding: 0 10px;
}
}
</style>
+1
View File
@@ -438,6 +438,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
@@ -399,6 +399,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
<el-form-item label="订单金额" prop="amount">
+1
View File
@@ -372,6 +372,7 @@
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
+2
View File
@@ -30,6 +30,8 @@
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
<el-option label="退款统计" :value="0" />
</el-select>
<el-select v-model="orderStatsDays" class="wb-select" @change="loadOrderStats">
File diff suppressed because one or more lines are too long
@@ -289,6 +289,45 @@ class OrderController extends BaseAdminController
return $this->success($msg, $result);
}
/**
* @notes 拆分订单:生成多笔子单(待支付/待审核/已支付继承支付信息),金额合计须等于原单;原单软删除;业务订单关联自动迁移
* @return \think\response\Json
*/
public function split()
{
$params = (new OrderValidate())->post()->goCheck('split');
$orderId = (int) $params['id'];
$amounts = $params['amounts'] ?? [];
if (! is_array($amounts)) {
return $this->fail('子单金额格式错误');
}
$orderTypes = $params['order_types'] ?? [];
if (! is_array($orderTypes)) {
return $this->fail('子单订单类型格式错误');
}
$order = \app\common\model\Order::find($orderId);
if (! $order) {
return $this->fail('订单不存在');
}
if (! $this->canEditOrder($order)) {
return $this->fail('无权限拆分此订单');
}
$result = OrderLogic::split($orderId, $amounts, $orderTypes);
if ($result === false) {
return $this->fail(OrderLogic::getError());
}
$summary = '拆分为子单 id=' . implode(',', $result['new_order_ids'] ?? []);
$this->logOrderAction($orderId, 'split', $summary);
foreach ($result['new_order_ids'] ?? [] as $nid) {
$this->logOrderAction((int) $nid, 'split_child', '来源原单 id=' . $orderId . ' ' . (string) ($order->order_no ?? ''));
}
return $this->success('拆分成功', $result);
}
/**
* @notes 编辑订单(关联患者、订单类型)
* 权限:超管或指定角色组可修改任意订单;其他用户只能修改自己创建的订单
@@ -79,11 +79,13 @@ class PrescriptionOrderController extends BaseAdminController
{
$params = (new PrescriptionOrderValidate())->get()->goCheck('logisticsTrace');
$expressOverride = trim((string) $this->request->get('express_company', ''));
$phoneTail = trim((string) ($params['phone_tail'] ?? ''));
$data = PrescriptionOrderLogic::logisticsTrace(
(int) $params['id'],
$expressOverride,
$this->adminId,
$this->adminInfo
$this->adminInfo,
$phoneTail
);
if ($data === null) {
return $this->fail(PrescriptionOrderLogic::getError());
@@ -16,6 +16,7 @@ use app\common\model\auth\AdminDept;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService;
use think\facade\Config;
use think\db\Query;
@@ -66,16 +67,111 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
));
}
/**
* 重点看板统计:去掉审核/履约状态筛选,保留其它筛选(时间、医生、医助、患者、供货方式等)
*
* @return array<int, array{0: string, 1: string, 2: mixed}>
*/
private function searchWhereWithoutStatusFilters(): array
{
return array_values(array_filter(
$this->searchWhere,
static function ($w): bool {
if (!\is_array($w) || \count($w) < 2) {
return true;
}
$field = (string) ($w[0] ?? '');
if (!in_array($field, ['prescription_audit_status', 'payment_slip_audit_status', 'fulfillment_status'], true)) {
return true;
}
$op = strtolower((string) ($w[1] ?? ''));
return $op !== '=';
}
));
}
private function applyPermissionAndExtraFilters($query): void
{
$this->applyDoctorAssistantFilters($query);
$this->applyPatientKeywordFilter($query);
$this->applyExpressCompanyFilter($query);
$this->applyExpressKeywordFilter($query);
$this->applySupplyModeFilter($query);
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
$query->where('creator_id', $this->adminId);
}
$this->applyDataScopeForPrescriptionOrder($query);
}
/**
* 按快递公司筛选(点击快捷标签:顺丰 / 京东)
*
* 说明:主表 express_company 常为 auto/空;快递100 回写承运商在 zyt_express_tracking,需一并匹配。
*/
private function applyExpressCompanyFilter($query): void
{
$company = strtolower(trim((string) ($this->params['express_company'] ?? '')));
if (!in_array($company, ['sf', 'jd'], true)) {
return;
}
$poTbl = (new PrescriptionOrder())->getTable();
$etTbl = (new ExpressTracking())->getTable();
if ($company === 'sf') {
$po = "(LOWER(TRIM(IFNULL(`{$poTbl}`.`express_company`,''))) IN ('sf','shunfeng')"
. " OR `{$poTbl}`.`express_company` LIKE '%顺丰%'"
. " OR (TRIM(IFNULL(`{$poTbl}`.`tracking_number`,'')) <> ''"
. " AND (`{$poTbl}`.`tracking_number` LIKE 'SF%' OR `{$poTbl}`.`tracking_number` LIKE 'sf%')))";
$et = "EXISTS (SELECT 1 FROM `{$etTbl}` et WHERE et.`delete_time` IS NULL"
. " AND et.`order_id` = `{$poTbl}`.`id` AND et.`order_type` = 'prescription'"
. " AND (LOWER(TRIM(IFNULL(et.`express_company`,''))) IN ('sf','shunfeng')"
. " OR et.`express_company_name` LIKE '%顺丰%'"
. " OR (TRIM(IFNULL(et.`tracking_number`,'')) <> ''"
. " AND (et.`tracking_number` LIKE 'SF%' OR et.`tracking_number` LIKE 'sf%'))))";
$query->whereRaw("(($po) OR ($et))");
return;
}
$po = "(LOWER(TRIM(IFNULL(`{$poTbl}`.`express_company`,''))) IN ('jd','jingdong')"
. " OR `{$poTbl}`.`express_company` LIKE '%京东%'"
. " OR (TRIM(IFNULL(`{$poTbl}`.`tracking_number`,'')) <> ''"
. " AND (`{$poTbl}`.`tracking_number` LIKE 'JD%' OR `{$poTbl}`.`tracking_number` LIKE 'jd%'"
. " OR `{$poTbl}`.`tracking_number` LIKE 'JDVB%' OR `{$poTbl}`.`tracking_number` LIKE 'jdvb%')))";
$et = "EXISTS (SELECT 1 FROM `{$etTbl}` et WHERE et.`delete_time` IS NULL"
. " AND et.`order_id` = `{$poTbl}`.`id` AND et.`order_type` = 'prescription'"
. " AND (LOWER(TRIM(IFNULL(et.`express_company`,''))) IN ('jd','jingdong')"
. " OR et.`express_company_name` LIKE '%京东%'"
. " OR (TRIM(IFNULL(et.`tracking_number`,'')) <> ''"
. " AND (et.`tracking_number` LIKE 'JD%' OR et.`tracking_number` LIKE 'jd%'"
. " OR et.`tracking_number` LIKE 'JDVB%' OR et.`tracking_number` LIKE 'jdvb%'))))";
$query->whereRaw("(($po) OR ($et))");
}
/**
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
*
* 入参 supply_modegancao | self,空表示不限
*/
private function applySupplyModeFilter($query): void
{
$mode = strtolower(trim((string) ($this->params['supply_mode'] ?? '')));
if ($mode === '') {
return;
}
if ($mode === 'gancao') {
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''");
return;
}
if ($mode === 'self') {
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''");
return;
}
}
/**
* 数据隔离:创建者 可见 admin,或 关联诊单医助 可见 admin
*/
@@ -302,6 +398,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
public function extend(): array
{
$s = $this->computeListStatsForExtend();
$focus = $this->computeFocusCountsForExtend();
return [
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
@@ -320,6 +417,32 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'stats_time_end' => $s['label_end'],
/** 统计口径:all=支付审核白名单全量;assistant=医助仅诊单协助业绩;restricted=与列表同域 */
'stats_scope' => $s['scope'],
'focus_pending_rx_count' => $focus['pending_rx'],
'focus_pending_pay_count' => $focus['pending_pay'],
'focus_pending_ship_count' => $focus['pending_ship'],
'focus_risk_count' => $focus['risk'],
];
}
/**
* 重点看板计数(基于“当前筛选(去除审核/履约状态)”后的全量命中)
*
* @return array{pending_rx:int,pending_pay:int,pending_ship:int,risk:int}
*/
private function computeFocusCountsForExtend(): array
{
$query = PrescriptionOrder::where($this->searchWhereWithoutStatusFilters())->whereNull('delete_time');
$this->applyPermissionAndExtraFilters($query);
return [
// 处方审核待处理
'pending_rx' => (int) (clone $query)->where('prescription_audit_status', 0)->count(),
// 支付审核待处理(前提:处方已通过)
'pending_pay' => (int) (clone $query)->where('prescription_audit_status', 1)->where('payment_slip_audit_status', 0)->count(),
// 履约待发货
'pending_ship' => (int) (clone $query)->where('fulfillment_status', 2)->count(),
// 重点异常:支付审核驳回(与前端「重点异常」快捷筛选口径一致)
'risk' => (int) (clone $query)->where('payment_slip_audit_status', 2)->count(),
];
}
@@ -512,4 +635,30 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
. "AND (dg.`patient_name` LIKE '{$likeSql}' OR dg.`phone` LIKE '{$likeSql}')"
);
}
/**
* 按快递单号 / 快递公司 模糊检索
*/
private function applyExpressKeywordFilter($query): void
{
$kw = trim((string) ($this->params['express_keyword'] ?? ''));
if ($kw === '') {
return;
}
$likeInner = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $kw);
$like = '%' . $likeInner . '%';
$likeSql = str_replace("'", "''", $like);
$poTbl = (new PrescriptionOrder())->getTable();
$etTbl = (new ExpressTracking())->getTable();
$query->whereRaw(
"(IFNULL(`{$poTbl}`.`tracking_number`,'') LIKE '{$likeSql}'"
. " OR IFNULL(`{$poTbl}`.`express_company`,'') LIKE '{$likeSql}'"
. " OR EXISTS (SELECT 1 FROM `{$etTbl}` et WHERE et.`delete_time` IS NULL"
. " AND et.`order_id` = `{$poTbl}`.`id` AND et.`order_type` = 'prescription'"
. " AND (IFNULL(et.`tracking_number`,'') LIKE '{$likeSql}'"
. " OR IFNULL(et.`express_company`,'') LIKE '{$likeSql}'"
. " OR IFNULL(et.`express_company_name`,'') LIKE '{$likeSql}'))"
. ")"
);
}
}
@@ -24,6 +24,8 @@ class OrderActionLogLogic
'create' => '创建订单',
'create_wechat_work' => '创建订单(企微对外收款)',
'assign_assistant' => '变更创建人(指派医助)',
'split' => '拆分订单',
'split_child' => '拆分生成子单',
];
public static function record(
+180 -2
View File
@@ -450,6 +450,182 @@ class OrderLogic
}
}
/**
* 拆分订单:待支付(1)、付呗待审核(5)、已支付(2);已关联业务订单的自动迁关联至各子单
*
* @param array<int, float|int|string> $amounts 子单金额(元),220 笔,分合计须与原单一致
* @param array<int, int|string> $orderTypes 与子单一一对应的订单类型 17
* @return array{new_order_ids: int[], orders: array<int, array<string, mixed>>}|false
*/
public static function split(int $orderId, array $amounts, array $orderTypes): array|false
{
$order = Order::whereNull('delete_time')->find($orderId);
if (! $order) {
self::setError('订单不存在');
return false;
}
$st = (int) $order->status;
if ($st === 5 && (string) ($order->payment_method ?? '') !== 'fubei') {
self::setError('待审核订单仅支持付呗渠道拆分');
return false;
}
if (! in_array($st, [1, 5, 2], true)) {
self::setError('仅「待支付」「待审核(付呗)」或「已支付」的订单可拆分');
return false;
}
$rawList = array_values($amounts);
$count = count($rawList);
if ($count < 2 || $count > 20) {
self::setError('拆分管数须在 220 之间');
return false;
}
$centsList = [];
foreach ($rawList as $a) {
$v = round((float) $a, 2);
if ($v <= 0) {
self::setError('每笔子单金额须大于 0');
return false;
}
$centsList[] = (int) round($v * 100);
}
$origCents = (int) round(round((float) $order->amount, 2) * 100);
if (array_sum($centsList) !== $origCents) {
self::setError(
'子单金额之和必须等于原订单金额(¥' . number_format($origCents / 100, 2, '.', '') . ''
);
return false;
}
$normAmounts = array_map(static fn (int $c) => round($c / 100, 2), $centsList);
$typeList = array_values($orderTypes);
if (count($typeList) !== $count) {
self::setError('订单类型笔数须与拆分数一致');
return false;
}
$allowedOt = [1, 2, 3, 4, 5, 6, 7, 8];
foreach ($typeList as $ot) {
if (! in_array((int) $ot, $allowedOt, true)) {
self::setError('订单类型无效,须为 18');
return false;
}
}
Db::startTrans();
try {
$origRemark = trim((string) ($order->remark ?? ''));
$splitTag = '[拆自原单#' . $orderId . '-' . (string) ($order->order_no ?? '') . ']';
$newOrders = [];
foreach ($normAmounts as $i => $amt) {
$n = new Order();
$n->order_no = self::generateOrderNo();
$n->patient_id = $order->patient_id;
$n->creator_id = $order->creator_id;
$n->order_type = (int) $typeList[$i];
$n->amount = $amt;
if ($st === 5) {
$n->payment_method = 'fubei';
$n->status = 5;
} elseif ($st === 2) {
$n->status = 2;
$n->payment_method = (string) ($order->payment_method ?? 'manual');
if ((string) ($order->payment_time ?? '') !== '') {
$n->payment_time = (string) $order->payment_time;
}
if ((string) ($order->trade_no ?? '') !== '') {
$n->trade_no = (string) $order->trade_no;
}
} else {
$n->status = 1;
}
$combined = $origRemark !== '' ? $origRemark . ' ' . $splitTag : $splitTag;
$n->remark = mb_substr($combined, 0, 500);
if ($order->payee_userid !== null && (string) $order->payee_userid !== '') {
$n->payee_userid = (string) $order->payee_userid;
}
if ($order->payer_external_userid !== null && (string) $order->payer_external_userid !== '') {
$n->payer_external_userid = (string) $order->payer_external_userid;
}
$n->save();
$newOrders[] = $n;
}
$newIds = [];
foreach ($newOrders as $o) {
$newIds[] = (int) $o->id;
}
// 处方业务订单 ↔ 支付单:原单关联迁移到全部子单(各业务订单对每笔子单各一条关联,合计金额不变)
$linkRows = PrescriptionOrderPayOrder::where('pay_order_id', $orderId)->select();
foreach ($linkRows as $link) {
$poId = (int) $link->prescription_order_id;
if ($poId <= 0) {
continue;
}
PrescriptionOrderPayOrder::where('prescription_order_id', $poId)
->where('pay_order_id', $orderId)
->delete();
foreach ($newIds as $nid) {
$dup = PrescriptionOrderPayOrder::where('prescription_order_id', $poId)
->where('pay_order_id', $nid)
->count();
if ($dup > 0) {
continue;
}
$nl = new PrescriptionOrderPayOrder();
$nl->prescription_order_id = $poId;
$nl->pay_order_id = $nid;
$nl->create_time = time();
$nl->save();
}
}
$order->remark = mb_substr(
($origRemark !== '' ? $origRemark . ' | ' : '') . '已拆分,子单ID:' . implode(',', $newIds),
0,
500
);
$order->save();
$order->delete();
Db::commit();
$brief = [];
foreach ($newOrders as $o) {
$brief[] = [
'id' => (int) $o->id,
'order_no' => (string) $o->order_no,
'amount' => (float) $o->amount,
'order_type' => (int) $o->order_type,
];
}
return [
'new_order_ids' => $newIds,
'orders' => $brief,
];
} catch (\Throwable $e) {
Db::rollback();
Log::error('Order split failed: ' . $e->getMessage(), ['order_id' => $orderId]);
self::setError('拆分失败:' . $e->getMessage());
return false;
}
}
/**
* @notes 今日收益(按角色权限:超管/主管看全部,普通员工看自己)
* @param int $adminId 当前管理员ID
@@ -723,16 +899,18 @@ class OrderLogic
4 => '首付费用',
5 => '尾款费用',
6 => '其他费用',
7 => '全部费用',
8 => '驼奶费用',
];
/**
* @notes 订单统计(按订单类型或退款)
* @param array $params order_type(-1全部已支付类型合计,0退款,1挂号费,2问诊费,3药品费用,4首付,5尾款,6其他), days(0今天,7,30)
* @param array $params order_type(-1全部已支付类型合计,0退款,18 各费用类型含驼奶费用), days(0今天,7,30)
* @return array
*/
public static function orderStats(array $params = [], int $adminId = 0, ?array $adminInfo = null)
{
$allPaidOrderTypes = [1, 2, 3, 4, 5, 6];
$allPaidOrderTypes = [1, 2, 3, 4, 5, 6, 7, 8];
$orderType = array_key_exists('order_type', $params) ? (int) $params['order_type'] : 1;
if ($orderType !== -1 && $orderType !== 0 && !array_key_exists($orderType, self::$orderTypeNames)) {
$orderType = 1;
@@ -422,6 +422,7 @@ class PrescriptionOrderLogic
5 => '尾款费用',
6 => '其他费用',
7 => '全部费用',
8 => '驼奶费用',
];
$statusMap = [
1 => '待支付',
@@ -882,7 +883,7 @@ class PrescriptionOrderLogic
*
* @return array<string,mixed>|null
*/
public static function logisticsTrace(int $id, string $expressCompanyOverride, int $adminId, array $adminInfo): ?array
public static function logisticsTrace(int $id, string $expressCompanyOverride, int $adminId, array $adminInfo, string $phoneTail = ''): ?array
{
self::$error = '';
$row = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
@@ -933,7 +934,7 @@ class PrescriptionOrderLogic
];
} else {
// 数据库没有数据,调用快递100 API
$payload = ExpressTrackService::query($ec, $num, (string) ($row->recipient_phone ?? ''));
$payload = ExpressTrackService::query($ec, $num, (string) ($row->recipient_phone ?? ''), $phoneTail);
$payload['source'] = 'api';
}
@@ -17,7 +17,7 @@ class OrderValidate extends BaseValidate
'id' => 'require|integer',
'patient_id' => 'require|integer',
'patient_id_optional' => 'integer',
'order_type' => 'require|in:1,2,3,4,5,6,7',
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
'amount' => 'require|float',
'status' => 'require|in:1,2,3,4',
'payment_method' => 'in:alipay,wechat,wechat_work,fubei,manual',
@@ -26,6 +26,8 @@ class OrderValidate extends BaseValidate
'is_supplement' => 'in:0,1',
'order_ids' => 'require|array',
'assistant_id' => 'require|integer|gt:0',
'amounts' => 'require|array',
'order_types' => 'require|array',
];
protected $message = [
@@ -47,5 +49,6 @@ class OrderValidate extends BaseValidate
'refund' => ['id'],
'delete' => ['id'],
'assign_assistant' => ['order_ids', 'assistant_id'],
'split' => ['id', 'amounts', 'order_types'],
];
}
@@ -23,9 +23,9 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number' => 'max:80',
'express_company' => 'max:20',
'ship_mode' => 'in:gancao,direct',
'fee_type' => 'require|in:1,2,3,4,5,6,7',
'fee_type' => 'require|in:1,2,3,4,5,6,7,8',
'amount' => 'require|float',
'order_type' => 'require|in:1,2,3,4,5,6,7',
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
'pay_amount' => 'require|float|gt:0',
'pay_remark' => 'max:200',
'remark_extra' => 'max:500',
@@ -34,6 +34,7 @@ class PrescriptionOrderValidate extends BaseValidate
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
'patient_name' => 'require|max:50',
'phone' => 'require|max:20',
'phone_tail' => 'max:20|regex:/^\\d*$/',
];
protected $message = [
@@ -47,6 +48,7 @@ class PrescriptionOrderValidate extends BaseValidate
'action.require' => '请选择审核操作',
'patient_name.require' => '请输入患者姓名',
'phone.require' => '请输入手机号',
'phone_tail.regex' => '手机后四位仅支持数字',
];
protected $scene = [
@@ -61,7 +63,7 @@ class PrescriptionOrderValidate extends BaseValidate
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'pay_order_ids',
],
'logisticsTrace' => ['id'],
'logisticsTrace' => ['id', 'phone_tail'],
'auditPrescription' => ['id', 'action', 'remark'],
'auditPayment' => ['id', 'action', 'remark'],
'withdraw' => ['id'],
@@ -8,6 +8,7 @@ use app\common\model\Order;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\service\ExpressTrackingService;
use think\facade\Config;
use think\facade\Log;
use think\Response;
@@ -342,14 +343,23 @@ class GancaoCallbackController extends BaseApiController
break;
}
$savedOk = false;
try {
$order->save();
$savedOk = true;
} catch (\Throwable $e) {
Log::error('Gancao callback save failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
if ($savedOk && in_array((int) $order->fulfillment_status, [5, 6], true)) {
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_number' => (string) ($order->tracking_number ?? ''),
'source' => 'gancao_callback',
]);
}
}
/**
+12 -1
View File
@@ -33,7 +33,7 @@ class ExpressAutoUpdate extends Command
$startTime = microtime(true);
try {
$result = ExpressTrackingService::autoUpdateBatch(150);
$result = ExpressTrackingService::autoUpdateBatch(200);
$duration = round(microtime(true) - $startTime, 2);
@@ -41,6 +41,17 @@ class ExpressAutoUpdate extends Command
$output->writeln("总数: {$result['total']}");
$output->writeln("成功: {$result['success']}");
$output->writeln("失败: {$result['failed']}");
$output->writeln("医助已移除: " . (int) ($result['assistant_cleared'] ?? 0));
$output->writeln("医助保留(有指派记录): " . (int) ($result['assistant_skipped_assign_log'] ?? 0));
$lines = $result['assistant_lines'] ?? [];
if ($lines === []) {
$output->writeln('医助明细: (无)');
} else {
$output->writeln('医助明细:');
foreach ($lines as $line) {
$output->writeln(' ' . $line);
}
}
$output->writeln("耗时: {$duration}");
return 0;
@@ -94,6 +94,17 @@ class GancaoSyncLogisticsRoute extends Command
$output->writeln('总数:' . $stats['total']);
$output->writeln('成功:' . $stats['success']);
$output->writeln('失败:' . $stats['failed']);
$output->writeln('医助已移除:' . (int) ($stats['assistant_cleared'] ?? 0));
$output->writeln('医助保留(有指派记录)' . (int) ($stats['assistant_skipped_assign_log'] ?? 0));
$lines = $stats['assistant_lines'] ?? [];
if ($lines === []) {
$output->writeln('医助明细:(无)');
} else {
$output->writeln('医助明细:');
foreach ($lines as $line) {
$output->writeln(' ' . $line);
}
}
$output->writeln('耗时:' . $duration . 's');
return 0;
@@ -19,6 +19,8 @@ class ExpressTrackService
private const KUAIDI_COM_JT = 'jtexpress'; // 极兔速递
/**
* @param string $phoneTailOverride 手工填写的收件电话(仅数字;完整 11 位或与面单一致的后四位等),优先于订单收货手机
*
* @return array{
* carrier: string,
* carrier_label: string,
@@ -31,11 +33,13 @@ class ExpressTrackService
* official_url: string
* }
*/
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = ''): array
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = '', string $phoneTailOverride = ''): array
{
$num = trim($trackingNumber);
$phoneDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
$phoneLast4 = strlen($phoneDigits) >= 4 ? substr($phoneDigits, -4) : '';
$overrideDigits = preg_replace('/\D/', '', $phoneTailOverride) ?? '';
$recipientDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
// 快递100 文档:phone 为收/寄件人电话;顺丰等必填。示例为完整 11 位手机号,仅传后四位易触发 408「验证码错误」
$phoneForKuaidi = self::buildKuaidiPhoneParam($overrideDigits, $recipientDigits);
$resolved = self::resolveCarrier($expressCompany, $num);
$carrier = $resolved['carrier'];
@@ -64,13 +68,20 @@ class ExpressTrackService
return $out;
}
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
if ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
$out['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
return $out;
}
$paramArr = [
'com' => $kuaidiCom,
'num' => $num,
'resultv2' => '1',
];
if ($phoneLast4 !== '') {
$paramArr['phone'] = $phoneLast4;
if ($phoneForKuaidi !== '') {
$paramArr['phone'] = $phoneForKuaidi;
}
$paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE);
@@ -154,6 +165,31 @@ class ExpressTrackService
return $out;
}
/**
* 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。
* 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。
*/
private static function buildKuaidiPhoneParam(string $overrideDigits, string $recipientDigits): string
{
$d = strlen($overrideDigits) >= 4 ? $overrideDigits : $recipientDigits;
if ($d === '') {
return '';
}
if (strlen($d) >= 11) {
return substr($d, -11);
}
return $d;
}
/** 实时查询文档:顺丰速运、中通快递等 phone 必填 */
private static function kuaidiPhoneRequired(string $kuaidiCom): bool
{
$c = strtolower($kuaidiCom);
return $c === self::KUAIDI_COM_SF || $c === 'zhongtong';
}
/**
* @return array{carrier: string, kuaidi_com: string, label: string}
*/
@@ -8,6 +8,8 @@ use app\common\model\ExpressTracking;
use app\common\model\ExpressTrace;
use app\common\model\ExpressStateLog;
use app\common\model\ExpressQueryLog;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use think\facade\Log;
@@ -76,7 +78,7 @@ class ExpressTrackingService
*
* @param int $trackingId
* @param bool $isAuto 是否自动查询
* @return array|null
* @return array<string, mixed>|null 快递100 查询结果;自动任务时可能含 _assistant_sync(医助移除/跳过)
*/
public static function queryAndUpdate(int $trackingId, bool $isAuto = false): ?array
{
@@ -174,7 +176,13 @@ class ExpressTrackingService
self::logStateChange($tracking, $oldState);
}
return $result;
// 定时拉轨迹:业务订单已发货时释放诊单医助(有正式指派记录则跳过,与结案逻辑一致)
$assistantSync = null;
if ($isAuto) {
$assistantSync = self::maybeClearDiagnosisAssistantWhenShippedPrescription($tracking);
}
return $assistantSync !== null ? array_merge($result, ['_assistant_sync' => $assistantSync]) : $result;
}
/**
@@ -233,6 +241,147 @@ class ExpressTrackingService
}
}
/**
* 业务订单已发货(5)/已签收(6)时清空关联诊单医助;存在 tcm_diagnosis_assign_log 则跳过。
* express:auto-update、gancao:sync-logistics、甘草回调等共用。
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
* action: 'cleared'|'skipped_assign_log',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
*/
public static function applyAssistantReleaseForShippedPrescriptionOrder(PrescriptionOrder $order, array $meta = []): ?array
{
$orderId = (int) $order->id;
if ($orderId <= 0) {
return null;
}
$source = (string) ($meta['source'] ?? '');
$trackingId = (int) ($meta['tracking_id'] ?? 0);
$tn = (string) ($meta['tracking_number'] ?? $order->tracking_number ?? '');
try {
$fs = (int) $order->fulfillment_status;
if (!in_array($fs, [5, 6], true)) {
return null;
}
$diagnosisId = (int) ($order->diagnosis_id ?? 0);
if ($diagnosisId <= 0) {
return null;
}
$diag = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
if (!$diag) {
return null;
}
$oldAssistant = (string) ($diag->getAttr('assistant_id') ?? '');
if ($oldAssistant === '' || $oldAssistant === '0') {
return null;
}
$assignLogCount = (int) DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->count();
if ($assignLogCount > 0) {
return [
'action' => 'skipped_assign_log',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'assistant_id' => $oldAssistant,
'assign_log_count' => $assignLogCount,
'fulfillment_status' => $fs,
];
}
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => '']);
Log::info('Cleared diagnosis assistant (shipped/signed prescription order)', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $oldAssistant,
]);
return [
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => $oldAssistant,
'fulfillment_status' => $fs,
];
} catch (\Throwable $e) {
Log::warning('applyAssistantReleaseForShippedPrescriptionOrder failed: ' . $e->getMessage(), [
'source' => $source,
'order_id' => $orderId,
]);
return null;
}
}
/**
* @return array{
* action: 'cleared'|'skipped_assign_log',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
*/
private static function maybeClearDiagnosisAssistantWhenShippedPrescription(ExpressTracking $tracking): ?array
{
if ((string) $tracking->order_type !== 'prescription') {
return null;
}
$orderId = (int) $tracking->order_id;
if ($orderId <= 0) {
return null;
}
try {
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
if (!$order) {
return null;
}
return self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_id' => (int) $tracking->id,
'tracking_number' => (string) $tracking->tracking_number,
'source' => 'express_auto_update',
]);
} catch (\Throwable $e) {
Log::warning('maybeClearDiagnosisAssistantWhenShippedPrescription failed: ' . $e->getMessage(), [
'tracking_id' => (int) $tracking->id,
'order_id' => $orderId,
]);
return null;
}
}
/**
* 物流表未存收件人手机时,从关联业务订单回填(顺丰/京东等查件必填后四位)
*/
@@ -383,7 +532,14 @@ class ExpressTrackingService
* 批量自动更新(定时任务调用)
*
* @param int $limit 每次处理数量
* @return array
* @return array{
* total: int,
* success: int,
* failed: int,
* assistant_cleared: int,
* assistant_skipped_assign_log: int,
* assistant_lines: list<string>
* }
*/
public static function autoUpdateBatch(int $limit = 50): array
{
@@ -423,11 +579,40 @@ class ExpressTrackingService
$success = 0;
$failed = 0;
$assistantCleared = 0;
$assistantSkippedAssign = 0;
$assistantLines = [];
foreach ($list as $tracking) {
try {
self::queryAndUpdate($tracking->id, true);
$ret = self::queryAndUpdate($tracking->id, true);
$success++;
if (is_array($ret) && isset($ret['_assistant_sync']) && is_array($ret['_assistant_sync'])) {
$sync = $ret['_assistant_sync'];
$act = (string) ($sync['action'] ?? '');
if ($act === 'cleared') {
$assistantCleared++;
$assistantLines[] = sprintf(
'[移除医助] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
} elseif ($act === 'skipped_assign_log') {
$assistantSkippedAssign++;
$assistantLines[] = sprintf(
'[保留医助] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} catch (\Throwable $e) {
$failed++;
Log::error('Auto update express tracking failed', [
@@ -442,6 +627,9 @@ class ExpressTrackingService
'total' => count($list),
'success' => $success,
'failed' => $failed,
'assistant_cleared' => $assistantCleared,
'assistant_skipped_assign_log' => $assistantSkippedAssign,
'assistant_lines' => $assistantLines,
];
}
@@ -9,6 +9,7 @@ use app\common\model\ExpressStateLog;
use app\common\model\ExpressTrace;
use app\common\model\ExpressTracking;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\ExpressTrackingService;
use think\facade\Log;
/**
@@ -57,7 +58,7 @@ final class GancaoLogisticsRouteService
* 拉取并落库单个订单的物流路由
*
* @param PrescriptionOrder $order
* @return array{success:bool, message:string, traces_count:int, state:string, raw?:array}
* @return array{success:bool, message:string, traces_count:int, state:string, raw?:array, assistant_sync?:array|null}
*/
public static function syncOne(PrescriptionOrder $order): array
{
@@ -118,12 +119,18 @@ final class GancaoLogisticsRouteService
self::logQuery($order, $trackingNumber, 'auto', true, $resp['runtime_ms'], '', count($routeList));
$assistantSync = ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_number' => $trackingNumber,
'source' => 'gancao_route_sync',
]);
return [
'success' => true,
'message' => 'ok',
'traces_count' => $tracesCount,
'state' => $newState,
'raw' => $result,
'assistant_sync' => $assistantSync,
];
}
@@ -423,11 +430,20 @@ final class GancaoLogisticsRouteService
/**
* 批量同步:选取候选订单(已上传甘草、且未签收/未取消的)
*
* @return array{total:int, success:int, failed:int, skipped:int, details:array<int, array<string,mixed>>}
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
*/
public static function syncBatch(int $limit = 100, ?int $onlyOrderId = null): array
{
$stats = ['total' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0, 'details' => []];
$stats = [
'total' => 0,
'success' => 0,
'failed' => 0,
'skipped' => 0,
'assistant_cleared' => 0,
'assistant_skipped_assign_log' => 0,
'assistant_lines' => [],
'details' => [],
];
$q = PrescriptionOrder::whereNull('delete_time')
->where('gancao_reciperl_order_no', '<>', '');
@@ -462,6 +478,32 @@ final class GancaoLogisticsRouteService
];
if ($r['success']) {
$stats['success']++;
if (!empty($r['assistant_sync']) && is_array($r['assistant_sync'])) {
$sync = $r['assistant_sync'];
$act = (string) ($sync['action'] ?? '');
if ($act === 'cleared') {
$stats['assistant_cleared']++;
$stats['assistant_lines'][] = sprintf(
'[移除医助][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
} elseif ($act === 'skipped_assign_log') {
$stats['assistant_skipped_assign_log']++;
$stats['assistant_lines'][] = sprintf(
'[保留医助][甘草路由] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} else {
$stats['failed']++;
}
@@ -0,0 +1,7 @@
-- 支付单列表:拆分订单按钮权限(对接 order.order/split
SET @order_menu_pid = (SELECT id FROM system_menu WHERE perms = 'order.order/lists' LIMIT 1);
INSERT INTO system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time, delete_time)
SELECT @order_menu_pid, 'A', '拆分订单', '', 88, 'order.order/split', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), NULL
WHERE @order_menu_pid IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM system_menu WHERE perms = 'order.order/split' LIMIT 1);