Compare commits

..
50 Commits
Author SHA1 Message Date
Your Name 86903cdb59 更新 2026-05-16 15:50:06 +08:00
Your Name 7ba9dabdb4 更新 2026-05-14 16:26:27 +08:00
Your Name 611f3dcd5c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 16:23:52 +08:00
Your Name d3388b160e 更新 2026-05-14 16:23:29 +08:00
long f83d4c06cb Merge branch 'long-20260514' 2026-05-14 16:22:38 +08:00
long 4941ea3e21 Update DailyMatrix.vue
更新血糖折线图显示
2026-05-14 15:54:30 +08:00
Your Name e35696e153 更新 2026-05-14 15:34:28 +08:00
Your Name e1df584ed5 更新 2026-05-14 11:25:48 +08:00
Your Name 928f75e016 更新 2026-05-14 11:00:56 +08:00
Your Name 37fa160c24 更新 2026-05-13 17:23:29 +08:00
Your Name 0eaacbb4ce 更新 2026-05-13 16:54:54 +08:00
Your Name 1e8b5c4646 修复bug 2026-05-13 09:49:47 +08:00
Your Name fabaa84373 新增 2026-05-12 18:14:20 +08:00
Your Name da017bdf20 更新 2026-05-12 18:10:40 +08:00
Your Name 5d94ffab3e 更新 2026-05-12 17:58:54 +08:00
Your Name 796ca12fb8 更新 2026-05-12 17:26:11 +08:00
Your Name c644f2554f 更新 2026-05-12 11:26:51 +08:00
Your Name e83dac756d 新增 2026-05-12 10:59:03 +08:00
Your Name 0c1709d589 Merge branch 'master' into yeji-5-11 2026-05-12 10:43:04 +08:00
Your Name a3892001d3 更新 2026-05-12 10:42:14 +08:00
Your Name 7add364e20 更新 2026-05-11 17:48:47 +08:00
Your Name 0ebea74fde 更新 2026-05-11 17:46:29 +08:00
Your Name 1156787327 更新 2026-05-11 16:34:44 +08:00
Your Name 15cf6324f6 更新 2026-05-11 16:10:06 +08:00
long 1338b8735c Merge branch '20260511-long' 2026-05-11 15:06:07 +08:00
long 82f229adf2 Update 2026_05_11_diagnosis_assign_read.sql 2026-05-11 15:06:01 +08:00
long 048e43a662 Merge branch '20260511-long' 2026-05-11 15:04:33 +08:00
long d8d4da197e 新指派医助提示 2026-05-11 15:04:24 +08:00
gr a0325a8fb1 更新 2026-05-11 12:35:46 +08:00
Your Name 091db50841 更新 2026-05-11 12:32:25 +08:00
long 1774a87dd7 update 2026-05-11 11:41:42 +08:00
Your Name 62d15efbaf 更新 2026-05-11 11:36:26 +08:00
long c468c57cda 关联支付单豁免 2026-05-11 11:25:03 +08:00
Your Name bacdd6386f 新增 2026-05-09 18:36:46 +08:00
long ea290c4c7b Merge branch 'long-0509' 2026-05-09 18:32:23 +08:00
long 31b4afdcb7 Update PrescriptionLogic.php 2026-05-09 18:32:16 +08:00
long 379d2ef404 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-09 18:07:38 +08:00
long 5cdb7ebb9e Merge branch 'long-0509' 2026-05-09 18:07:37 +08:00
long 496a2b870b 业务订单优化 2026-05-09 18:07:30 +08:00
Your Name 471bdf12b7 更新 2026-05-09 17:45:04 +08:00
long 8fe4683e64 Revert "1"
This reverts commit 0a37335f3c.
2026-05-09 17:35:33 +08:00
long c34409edde Merge branch 'long-0509' 2026-05-09 17:14:13 +08:00
long 0a37335f3c 1 2026-05-09 17:14:03 +08:00
Your Name 985916dea7 修复bug 2026-05-09 17:04:50 +08:00
Your Name 3330613254 更新 2026-05-09 15:57:12 +08:00
Your Name 3f12829204 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-09 11:35:21 +08:00
Your Name f28500e449 更新 2026-05-09 11:35:09 +08:00
long fd9053a290 患者列表优化 2026-05-09 11:30:04 +08:00
Your Name dc1c5d5b17 更新 2026-05-08 18:32:39 +08:00
Your Name 1297d9f510 更新 2026-05-08 17:26:51 +08:00
499 changed files with 11583 additions and 1620 deletions
+4
View File
@@ -29,3 +29,7 @@ bin-release/
/.claude
/.claude
/.claude
/.kbctx
/server/.spool
/server/.claude
/.spool
+2 -1
View File
@@ -1,4 +1,5 @@
NODE_ENV = 'development'
# Base API
# 后端接口根地址(含协议与端口),例如 http://127.0.0.1:8080
# 相对路径 /uploads/… 若此项为完整 URL,会与后端对齐;未填时由 vite 将 /uploads 代理到默认 http://127.0.0.1:8080。
VITE_APP_BASE_URL=''
+7
View File
@@ -24,6 +24,7 @@
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.16",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
@@ -8264,6 +8265,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
+1
View File
@@ -26,6 +26,7 @@
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.16",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
+10
View File
@@ -66,6 +66,16 @@ export function appointmentLists(params: any) {
return request.get({ url: '/doctor.appointment/lists', params })
}
/** 后台编辑挂号(预约日期/时段/类型/状态/备注/医助) */
export function appointmentAdminEdit(params: any) {
return request.post({ url: '/doctor.appointment/edit', params })
}
/** 批量修改挂号渠道来源(与 edit 共用 doctor.appointment/edit 权限) */
export function appointmentBatchEditChannel(params: any) {
return request.post({ url: '/doctor.appointment/batchEditChannel', params })
}
// 获取挂号详情
export function appointmentDetail(params: any) {
return request.get({ url: '/doctor.appointment/detail', params })
+5
View File
@@ -104,3 +104,8 @@ export function orderActionLogStats(params?: { start_time?: string; end_time?: s
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/order.order/assignAssistant', params })
}
/** 设置/取消支付单豁免权 */
export function orderSetExempt(params: { id: number; is_exempt: 0 | 1 }) {
return request.post({ url: '/order.order/setExempt', params })
}
+76
View File
@@ -103,3 +103,79 @@ export function doctorDailyStatsOverview(params: {
}) {
return request.get({ url: '/stats.doctorDailyStats/overview', params })
}
/** 提成结算接口计算较重,单独放宽超时(默认多为 60s) */
const COMMISSION_SETTLEMENT_TIMEOUT_MS = 120000
/** 提成结算业绩(独立于业绩看板 yejiStats) */
export function commissionSettlementOverview(params: {
settlement_month: string
/** 与 tcm.prescriptionOrder/lists 同源:create_time between */
start_time?: string
end_time?: string
/** 默认 3=履约完成,与列表 fulfillment_status 一致 */
fulfillment_status?: number
/** 传 1 时仅统计 is_system_auto=1;显式时段下默认不传(含手动) */
require_system_auto_prescription?: 0 | 1
dept_ids?: number[] | string
channel_code?: string
}) {
return request.get(
{ url: '/stats.commissionSettlement/overview', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
export function commissionSettlementDeptOptions() {
return request.get({ url: '/stats.commissionSettlement/deptOptions' })
}
export function commissionSettlementChannelOptions() {
return request.get({ url: '/stats.commissionSettlement/channelOptions' })
}
/** 提成核对:订单明细分页 bucket: 空|current|deferredappt_channel_value 可与 assistant_id/doctor_id 组合(0=未匹配挂号渠道) */
export function commissionSettlementOrderLines(params: {
settlement_month: string
start_time?: string
end_time?: string
fulfillment_status?: number
require_system_auto_prescription?: 0 | 1
dept_ids?: number[] | string
channel_code?: string
page?: number
page_size?: number
bucket?: string
assistant_id?: number
doctor_id?: number
appt_channel_value?: number
}) {
return request.get(
{ url: '/stats.commissionSettlement/orderLines', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
export function commissionSettlementConfirmStatus(params: {
settlement_month: string
dept_ids?: number[] | string
channel_code?: string
}) {
return request.get({ url: '/stats.commissionSettlement/confirmStatus', params })
}
export function commissionSettlementSaveReconcile(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/saveReconcile', params })
}
export function commissionSettlementConfirmFinalize(params: Record<string, any>) {
return request.post(
{ url: '/stats.commissionSettlement/confirmFinalize', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
/** 撤回「确定本期业绩」:清除顺延结转,状态变为可再次核对/确定 */
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
}
+5
View File
@@ -363,6 +363,11 @@ export function prescriptionOrderLists(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
}
/** 处方业务订单导出(export=1 预估条数,export=2 下载 Excel */
export function prescriptionOrderExport(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/export', params })
}
/**
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
@@ -13,6 +13,7 @@
<el-button>导出</el-button>
</template>
<div>
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
<el-form-item label="数据量:">
预计导出{{ exportData.count }}条数据 {{ exportData.sum_page }}每页{{
@@ -79,6 +80,11 @@ const props = defineProps({
fetchFun: {
type: Function,
required: true
},
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
exportHint: {
type: String,
default: ''
}
})
const popupRef = shallowRef<InstanceType<typeof Popup>>()
+135 -9
View File
@@ -75,12 +75,37 @@
</el-row>
</div>
<div class="info-section">
<div class="section-title">
中药处方 (RP)
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
<el-button type="warning" size="small" @click="openPasteRecipeDialog">导入药方</el-button>
<div class="info-section info-section--rp">
<div class="rp-toolbar" role="region" aria-label="中药处方">
<div class="rp-toolbar__lead">
<span class="rp-toolbar__marker" aria-hidden="true" />
<div class="rp-toolbar__text">
<h3 class="rp-toolbar__heading">
中药处方
<span class="rp-toolbar__abbr">(RP)</span>
</h3>
<p class="rp-toolbar__meta">
<strong>{{ formData.herbs.length }}</strong> 味药材 · 可手动添加从处方库或粘贴药方批量录入药品库同名匹配
</p>
</div>
</div>
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb">
添加中药
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="CollectionTag"
@click="showLibraryDialog = true"
>
从处方库导入
</el-button>
<el-button type="primary" plain size="small" :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
</div>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
@@ -135,11 +160,11 @@
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<!-- <el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
<el-option label="汤剂" value="汤剂" /> -->
</el-select>
</el-form-item>
</el-col>
@@ -709,7 +734,7 @@ import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { Download, Document } from '@element-plus/icons-vue'
import { CirclePlus, CollectionTag, Download, Document, EditPen } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
interface Herb {
@@ -1761,6 +1786,107 @@ defineExpose({
align-items: center;
}
&.info-section--rp {
padding-top: 14px;
}
.rp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 20px;
margin-bottom: 14px;
padding: 14px 16px;
background: linear-gradient(
145deg,
var(--el-bg-color) 0%,
var(--el-fill-color-extra-light) 100%
);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.rp-toolbar:hover {
border-color: var(--el-border-color);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.07);
}
.rp-toolbar__lead {
display: flex;
align-items: stretch;
gap: 12px;
min-width: 0;
flex: 1;
}
.rp-toolbar__marker {
width: 4px;
min-height: 44px;
border-radius: 4px;
background: linear-gradient(
180deg,
var(--el-color-primary) 0%,
var(--el-color-primary-light-5) 100%
);
flex-shrink: 0;
}
.rp-toolbar__text {
min-width: 0;
}
.rp-toolbar__heading {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
line-height: 1.35;
}
.rp-toolbar__abbr {
margin-left: 4px;
font-weight: 500;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta strong {
font-weight: 600;
color: var(--el-text-color-regular);
}
.rp-toolbar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.rp-toolbar__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 960px) {
.rp-toolbar__actions {
width: 100%;
justify-content: flex-start;
}
}
.herbs-editor-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+17 -2
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { getConfig } from '@/api/app'
import configs from '@/config'
interface AppSate {
config: Record<string, any>
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
getImageUrl(url: string) {
if (!url || typeof url !== 'string') return ''
if (url.startsWith('http://') || url.startsWith('https://')) return url
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + url}` : url
const path = url.startsWith('/') ? url : '/' + url
const oss = String(this.config.oss_domain || '').replace(/\/$/, '')
if (oss) {
return `${oss}${path}`
}
// 开发环境:后台接口与 Vite 不同端口时,相对路径不能拼到 location.origin(应对接到 php 静态域名)
const apiRoot = String(configs.baseUrl || '/').replace(/\/+$/, '')
if (apiRoot.startsWith('http://') || apiRoot.startsWith('https://')) {
try {
return new URL(path, `${apiRoot}/`).href
} catch {
return `${apiRoot}${path}`
}
}
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return origin ? `${origin}${path}` : path
},
getConfig() {
return new Promise((resolve, reject) => {
@@ -0,0 +1,582 @@
<template>
<div class="guahao-list">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" :inline="true" @submit.prevent>
<el-form-item label="预约日期">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="患者姓名">
<el-input
v-model="queryParams.patient_name"
placeholder="模糊搜索"
clearable
class="!w-[160px]"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医生">
<el-input
v-model="queryParams.doctor_name"
placeholder="模糊搜索"
clearable
class="!w-[140px]"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医助">
<el-select
v-model="queryParams.assistant_id"
placeholder="全部"
clearable
filterable
class="!w-[180px]"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name + (a.account ? ` (${a.account})` : '')"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
<el-option label="已预约" :value="1" />
<el-option label="已取消" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已过号" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-select
v-model="queryParams.channel_source"
placeholder="全部"
clearable
filterable
class="!w-[200px]"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-button
v-perms="['doctor.appointment/edit']"
type="primary"
:disabled="selectedRows.length === 0"
@click="openBatchChannelDialog"
>
批量修改渠道
</el-button>
<span v-if="selectedRows.length > 0" class="text-sm text-gray-500">
已选 {{ selectedRows.length }}
</span>
</div>
<el-table
ref="tableRef"
v-loading="pager.loading"
row-key="id"
:data="pager.lists"
size="large"
stripe
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" align="center" reserve-selection />
<el-table-column label="ID" prop="id" width="72" align="center" />
<el-table-column label="诊单/患者" min-width="140">
<template #default="{ row }">
<div class="text-sm">
<div class="font-medium">{{ row.patient_name || '—' }}</div>
<div class="text-gray-500">{{ maskPhone(row.patient_phone) }}</div>
<div class="text-xs text-gray-400">诊单 #{{ row.diagnosis_id ?? row.patient_id }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="医生" prop="doctor_name" width="100" show-overflow-tooltip />
<el-table-column label="医助" prop="assistant_name" width="100" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="128">
<template #default="{ row }">
<div>{{ row.appointment_date }}</div>
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
</template>
</el-table-column>
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
<div v-if="row.channel_source_detail" class="text-xs text-gray-400 truncate">
{{ row.channel_source_detail }}
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag
:type="
row.status === 1 ? 'success' : row.status === 2 ? 'info' : row.status === 3 ? 'primary' : 'danger'
"
size="small"
effect="light"
>
{{ row.status_desc }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button v-perms="['doctor.appointment/edit']" type="primary" link @click="openEdit(row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<el-dialog v-model="editVisible" title="编辑挂号" width="560px" destroy-on-close @closed="resetEditForm">
<el-form :model="editForm" label-width="96px">
<el-form-item label="预约日期" required>
<el-date-picker
v-model="editForm.appointment_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="!w-full"
/>
</el-form-item>
<el-form-item label="预约时间" required>
<el-time-picker
v-model="editForm.appointment_time"
format="HH:mm"
value-format="HH:mm"
placeholder="时间"
class="!w-full"
/>
</el-form-item>
<el-form-item label="时段" required>
<el-radio-group v-model="editForm.period">
<el-radio-button label="morning">上午</el-radio-button>
<el-radio-button label="afternoon">下午</el-radio-button>
<el-radio-button label="all">全天</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="问诊类型" required>
<el-select v-model="editForm.appointment_type" class="!w-full">
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
<el-option label="电话问诊" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="渠道来源" required>
<el-select
v-model="editForm.channel_source"
placeholder="请选择渠道来源"
filterable
class="!w-full"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="needsChannelSourceDetail" label="自媒体补充" required>
<el-input
v-model="editForm.channel_source_detail"
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
clearable
class="!w-full"
/>
</el-form-item>
<el-form-item label="状态" required>
<el-select v-model="editForm.status" class="!w-full">
<el-option label="已预约" :value="1" />
<el-option label="已取消" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已过号" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="挂号医助">
<el-select
v-model="editForm.assistant_id"
placeholder="不修改可留空"
clearable
filterable
class="!w-full"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name + (a.account ? ` (${a.account})` : '')"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchChannelVisible"
title="批量修改渠道"
width="520px"
destroy-on-close
@closed="resetBatchChannelForm"
>
<p class="text-sm text-gray-600 mb-4">将对已选 {{ selectedRows.length }} 条挂号写入同一渠道来源仅改渠道不改其它字段</p>
<el-form :model="batchChannelForm" label-width="96px">
<el-form-item label="渠道来源" required>
<el-select
v-model="batchChannelForm.channel_source"
placeholder="请选择渠道来源"
filterable
class="!w-full"
@change="onBatchChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="needsBatchChannelSourceDetail" label="自媒体补充" required>
<el-input
v-model="batchChannelForm.channel_source_detail"
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
clearable
class="!w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchChannelVisible = false">取消</el-button>
<el-button type="primary" :loading="batchChannelSaving" @click="submitBatchChannel">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="consumerPrescriptionGuahao">
import DaterangePicker from '@/components/daterange-picker/index.vue'
import { getDictData } from '@/api/app'
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
import { getAssistants } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
const queryParams = reactive({
start_date: '',
end_date: '',
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
status: '' as number | '',
channel_source: '' as string
})
const queryInit = {
start_date: '',
end_date: '',
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
status: '' as number | '',
channel_source: ''
}
const { pager, getLists, resetPage } = usePaging({
fetchFun: appointmentLists,
params: queryParams
})
const assistantOptions = ref<Array<{ id: number; name: string; account?: string }>>([])
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
const selectedRows = ref<Record<string, unknown>[]>([])
function onSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
const channelOptions = ref<Array<{ name: string; value: string }>>([])
const selectedChannelDictName = computed(() => {
const v = editForm.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
const needsChannelSourceDetail = computed(() => channelNameRequiresSelfMediaDetail(selectedChannelDictName.value))
const batchSelectedChannelDictName = computed(() => {
const v = batchChannelForm.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
const needsBatchChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(batchSelectedChannelDictName.value)
)
function onBatchChannelSourceChange(val: string) {
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
batchChannelForm.channel_source_detail = ''
}
}
const batchChannelVisible = ref(false)
const batchChannelSaving = ref(false)
const batchChannelForm = reactive({
channel_source: '' as string,
channel_source_detail: '' as string
})
function openBatchChannelDialog() {
if (selectedRows.value.length === 0) {
feedback.msgWarning('请先勾选挂号记录')
return
}
batchChannelForm.channel_source = ''
batchChannelForm.channel_source_detail = ''
batchChannelVisible.value = true
}
function resetBatchChannelForm() {
batchChannelForm.channel_source = ''
batchChannelForm.channel_source_detail = ''
}
async function submitBatchChannel() {
if (selectedRows.value.length === 0) {
feedback.msgWarning('请先勾选挂号记录')
return
}
if (!batchChannelForm.channel_source) {
feedback.msgError('请选择渠道来源')
return
}
if (needsBatchChannelSourceDetail.value && !batchChannelForm.channel_source_detail.trim()) {
feedback.msgError('请填写自媒体补充说明')
return
}
batchChannelSaving.value = true
try {
const ids = selectedRows.value.map((r) => Number(r.id))
await appointmentBatchEditChannel({
ids,
channel_source: batchChannelForm.channel_source,
channel_source_detail: batchChannelForm.channel_source_detail.trim()
})
feedback.msgSuccess('批量修改成功')
batchChannelVisible.value = false
tableRef.value?.clearSelection?.()
selectedRows.value = []
getLists()
} finally {
batchChannelSaving.value = false
}
}
function onChannelSourceChange(val: string) {
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
editForm.channel_source_detail = ''
}
}
const editVisible = ref(false)
const editSaving = ref(false)
const editForm = reactive({
id: 0,
appointment_date: '',
appointment_time: '' as string,
period: 'morning' as 'morning' | 'afternoon' | 'all',
appointment_type: 'video',
status: 1,
remark: '',
assistant_id: undefined as number | undefined,
channel_source: '' as string,
channel_source_detail: '' as string
})
function maskPhone(phone?: string) {
const p = String(phone || '')
if (p.length >= 11) {
return p.slice(0, 3) + '****' + p.slice(-4)
}
return p || '—'
}
function formatHm(t?: string) {
const s = String(t || '')
return s.length >= 5 ? s.slice(0, 5) : s || '—'
}
function resetFilter() {
Object.assign(queryParams, queryInit)
resetPage()
}
function rowPeriod(row: Record<string, unknown>): 'morning' | 'afternoon' | 'all' {
const v = String(row.period ?? row.type ?? 'morning')
if (v === 'afternoon' || v === 'all') {
return v
}
return 'morning'
}
async function loadAssistants() {
try {
const res: any = await getAssistants()
assistantOptions.value = res?.lists ?? res ?? []
} catch {
assistantOptions.value = []
}
}
async function loadChannelOptions() {
try {
const data: any = await getDictData({ type: 'channels' })
const rows = (data?.channels || []).filter((row: { status?: number }) => row.status !== 0)
rows.sort((a: { sort?: number; id?: number }, b: { sort?: number; id?: number }) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
channelOptions.value = rows
} catch {
channelOptions.value = []
}
}
function openEdit(row: Record<string, unknown>) {
editForm.id = Number(row.id)
editForm.appointment_date = String(row.appointment_date || '')
editForm.appointment_time = formatHm(String(row.appointment_time || ''))
editForm.period = rowPeriod(row)
editForm.appointment_type = String(row.appointment_type || 'video')
editForm.status = Number(row.status)
editForm.remark = String(row.remark || '')
const raw = row.appointment_assistant_id
editForm.assistant_id =
raw !== undefined && raw !== null && raw !== '' ? Number(raw) : undefined
editForm.channel_source = String(row.channel_source ?? row.channels ?? '')
editForm.channel_source_detail = String(row.channel_source_detail ?? '')
editVisible.value = true
}
function resetEditForm() {
editForm.id = 0
editForm.appointment_date = ''
editForm.appointment_time = ''
editForm.period = 'morning'
editForm.appointment_type = 'video'
editForm.status = 1
editForm.remark = ''
editForm.assistant_id = undefined
editForm.channel_source = ''
editForm.channel_source_detail = ''
}
async function submitEdit() {
if (!editForm.appointment_date) {
feedback.msgError('请选择预约日期')
return
}
if (!editForm.appointment_time) {
feedback.msgError('请选择预约时间')
return
}
if (!editForm.channel_source) {
feedback.msgError('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !editForm.channel_source_detail.trim()) {
feedback.msgError('请填写自媒体补充说明')
return
}
editSaving.value = true
try {
const payload: Record<string, unknown> = {
id: editForm.id,
appointment_date: editForm.appointment_date,
appointment_time: editForm.appointment_time,
period: editForm.period,
appointment_type: editForm.appointment_type,
status: editForm.status,
remark: editForm.remark,
assistant_id: editForm.assistant_id ?? '',
channel_source: editForm.channel_source,
channel_source_detail: editForm.channel_source_detail.trim()
}
await appointmentAdminEdit(payload)
feedback.msgSuccess('保存成功')
editVisible.value = false
getLists()
} finally {
editSaving.value = false
}
}
onMounted(() => {
loadAssistants()
loadChannelOptions()
})
onActivated(() => {
getLists()
})
getLists()
</script>
+233 -10
View File
@@ -548,12 +548,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="editForm.patient_name" placeholder="请输入患者姓名" />
<el-input
v-model="editForm.patient_name"
placeholder="请输入患者姓名"
:disabled="editMode === 'edit'"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input v-model="editForm.visit_no" placeholder="自动生成或手动输入" />
<el-input
v-model="editForm.visit_no"
placeholder="自动生成或手动输入"
:disabled="editMode === 'edit'"
/>
</el-form-item>
</el-col>
</el-row>
@@ -631,12 +639,22 @@
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" class="ml-2" @click="openLibraryDialog">
从处方库导入
</el-button>
<div class="flex flex-wrap items-center gap-2">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" @click="openLibraryDialog">
从处方库导入
</el-button>
</div>
<div class="mt-2">
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
<span class="text-xs text-gray-500 ml-2 align-middle">
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
</span>
</div>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
@@ -1359,6 +1377,40 @@
</div>
</el-dialog>
<!-- 粘贴药方文本导入(与诊间 tcm-prescription 逻辑一致:药品库完全同名才录入) -->
<el-dialog
v-model="showPasteRecipeDialog"
title="导入药方(识别药材)"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="pasteRecipeText = ''"
>
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g
白术10g」);「黄芪、党参各10克」等同剂量写法亦可识别。
</p>
<el-input
v-model="pasteRecipeText"
type="textarea"
:rows="10"
placeholder="示例:&#10;黄芪15 党参12 茯苓10&#10;柴胡10g、黄芩10、半夏12克&#10;黄芪、白术、茯苓各15克"
/>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="pasteRecipeImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</el-dialog>
<el-dialog v-model="auditDialogVisible" title="处方审核" width="480px" destroy-on-close @closed="auditRemark = ''">
<p class="text-sm text-gray-600 mb-3">
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
@@ -1401,7 +1453,7 @@ import {
getDoctors,
prescriptionLibraryLists
} from '@/api/tcm'
import { searchPatients as searchPatientsAPI } from '@/api/order'
import { medicineLists } from '@/api/medicine'
import { getDictData } from '@/api/app'
import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
@@ -1410,7 +1462,7 @@ import feedback from '@/utils/feedback'
import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import { Search, Download, Printer } from '@element-plus/icons-vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
@@ -2971,6 +3023,177 @@ watch(showLibraryDialog, (open) => {
}
})
/** 粘贴药方导入(与 components/tcm-prescription 一致) */
const showPasteRecipeDialog = ref(false)
const pasteRecipeText = ref('')
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
const pasteRecipeImportLoading = ref(false)
const PASTE_RECIPE_SKIP_SEGMENT =
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
function normalizeRecipePasteInput(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48))
return s.trim()
}
function stripRecipePasteHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbPasteNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
function parseHerbPasteToken(seg: string): { name: string; dosage: number }[] | null {
const s = seg.trim()
if (!s || PASTE_RECIPE_SKIP_SEGMENT.test(s)) return null
if (/^\d/.test(s)) return null
if (/^\d+\s*剂$/.test(s)) return null
const ge = s.match(/^(.+)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$/i)
if (ge) {
const dose = parseFloat(ge[2])
if (!Number.isFinite(dose) || dose <= 0) return null
const names = ge[1]
.split(/[、,,\s]+/)
.map((x) => x.trim())
.filter(Boolean)
if (!names.length) return null
return names.map((name) => ({ name: stripHerbPasteNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbPasteNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbPasteNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipePasteText(text: string): string[] {
const t = stripRecipePasteHeaders(normalizeRecipePasteInput(text))
const out: string[] = []
for (const line of t.split(/\n+/)) {
const ln = line.trim()
if (!ln) continue
for (const piece of ln.split(/[、,,;]/)) {
const p = piece.trim()
if (!p) continue
if (/\s/.test(p) && !parseHerbPasteToken(p)) {
const sub = p.split(/\s+(?=[\u4e00-\u9fff]{2,})/)
for (const x of sub) {
const c = x.trim()
if (c) out.push(c)
}
} else {
out.push(p)
}
}
}
return out
}
function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipePasteText(text)) {
const parsed = parseHerbPasteToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
const res = await medicineLists({
name: q,
page_no: 1,
page_size: 200,
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
}
function openPasteRecipeDialog() {
showPasteRecipeDialog.value = true
}
async function handlePasteRecipeImport() {
const parsed = parseRecipePasteToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: Array<{ name: string; dosage: number }> = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
feedback.msgWarning(
skippedUnique.length
? `药品库中无以下完全同名药材,未录入:${skippedUnique.join('、')}`
: '未能匹配到药品库中的药材'
)
return
}
if (pasteRecipeImportMode.value === 'replace') {
editForm.herbs = resolved
} else {
editForm.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNamesLocal()
const skipHint =
skippedUnique.length > 0 ? `;药品库未收录已跳过:${skippedUnique.join('、')}` : ''
if (dupNames.length) {
feedback.msgWarning(
`已导入 ${resolved.length} 味药材${skipHint}。存在重复药名:${dupNames.join(
'、'
)},请合并剂量或删除多余行`
)
} else if (skippedUnique.length) {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材${skipHint},请核对剂量`)
} else {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材,请核对药名与剂量`)
}
showPasteRecipeDialog.value = false
pasteRecipeText.value = ''
} finally {
pasteRecipeImportLoading.value = false
}
}
// 添加药材
const addHerb = () => {
editForm.herbs.push({
@@ -263,9 +263,22 @@
/>
</el-select>
</el-form-item>
<el-form-item class="w-[160px]" label="服务渠道">
<el-select v-model="queryParams.service_channel" clearable placeholder="全部" class="!w-full">
<el-option label="未指派" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<export-data
v-perms="['tcm.prescriptionOrder/export']"
class="ml-2.5"
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:按诊单关联患者取最近一条挂号的渠道字典与细分。"
/>
</el-form-item>
</el-form>
</el-card>
@@ -426,7 +439,37 @@
</el-table-column>
<el-table-column label="开方人" prop="doctor_name" width="90" show-overflow-tooltip />
<el-table-column label="创建人" prop="creator_name" width="90" show-overflow-tooltip />
<el-table-column min-width="128" show-overflow-tooltip>
<template #header>
<span>二中心·指派</span>
<el-tooltip
placement="top"
max-width="320"
content="与诊单「指派记录」快照一致:仅当本条业务单在操作瞬间为快照中的「关联业务单」(创建人+创建时间命中)时显示。绿色=新医助在名称含「二中心」的部门及其下级;灰色=系统自动释放诊单医助。"
>
<el-icon class="ml-0.5 align-middle text-gray-400 cursor-help"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<template v-if="!Number(row.po_assign_snapshot_hit)">
<span class="text-gray-400"></span>
</template>
<template v-else-if="Number(row.po_assign_is_release)">
<el-tag type="info" size="small">已释放</el-tag>
</template>
<template v-else-if="Number(row.po_assign_to_er_center)">
<el-tag type="success" size="small">
{{ row.po_assign_to_assistant_name || ('医助#' + row.po_assign_to_assistant_id) }}
</el-tag>
</template>
<template v-else>
<el-tag type="warning" size="small" effect="plain">
非二中心 {{ row.po_assign_to_assistant_name || ('医助#' + row.po_assign_to_assistant_id) }}
</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<div class="flex items-center gap-1 flex-wrap">
@@ -621,6 +664,41 @@
</template>
</el-alert>
<!-- 物流轨迹含拒收/退回等抽屉内强提示 -->
<el-alert
v-if="detailLogisticsUrgent.show && String(detailData?.tracking_number || '').trim()"
type="error"
effect="dark"
:closable="false"
show-icon
class="mb-4 !items-start ring-2 ring-red-600/90 shadow-lg"
title="物流异常:轨迹中出现拒收 / 退回等字样,请优先跟进处理"
>
<template #default>
<div class="text-sm leading-relaxed space-y-1.5">
<div v-if="detailLogisticsUrgent.keywords.length" class="flex flex-wrap gap-1.5 items-center">
<span class="text-red-100 text-xs">命中关键词</span>
<el-tag
v-for="k in detailLogisticsUrgent.keywords"
:key="k"
type="danger"
effect="plain"
size="small"
class="!border-white/40 !text-white"
>
{{ k }}
</el-tag>
</div>
<ul v-if="detailLogisticsUrgent.lines.length" class="list-disc pl-5 space-y-1 text-red-50">
<li v-for="(line, idx) in detailLogisticsUrgent.lines" :key="idx">{{ line }}</li>
</ul>
<div class="text-xs text-red-100/90 pt-1 border-t border-white/15">
请核实是否需改址重发拦截退件或协调患者签收并在备注中记录处理结果
</div>
</div>
</template>
</el-alert>
<div class="bg-gray-50/80 border border-gray-100 rounded-lg p-5 mb-5 shadow-sm">
<el-steps :active="workflowActiveStep" align-center finish-status="success">
<el-step title="业务订单创建" :description="formatTime(detailData.create_time)" />
@@ -911,11 +989,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -968,11 +1047,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -1124,10 +1204,25 @@
<el-card
v-if="String(detailData.tracking_number || '').trim()"
shadow="never"
class="po-panel po-panel-logistics border-gray-100"
class="po-panel po-panel-logistics"
:class="
detailLogisticsUrgent.show
? '!border-2 !border-red-500 shadow-md shadow-red-500/10'
: 'border-gray-100'
"
>
<template #header>
<span class="font-medium text-[15px]">物流轨迹</span>
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-[15px]">物流轨迹</span>
<el-tag
v-if="detailLogisticsUrgent.show"
type="danger"
effect="dark"
size="small"
>
拒收/退回 · 待处理
</el-tag>
</div>
</template>
<p class="po-logistics-tip text-gray-400 text-xs mb-3 font-normal">
优先读库无记录时走快递100顺丰会校验单号+电话快递100要求完整手机号更易通过默认预填订单收货手机若仍提示验证码错误请改成与顺丰面单一致的号码
@@ -1198,7 +1293,14 @@
:timestamp="t.time"
placement="top"
>
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
<span
:class="[
idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500',
logisticsTraceLineUrgent(t.context)
? '!text-red-700 font-semibold bg-red-50 px-1.5 py-0.5 rounded'
: ''
]"
>{{ t.context }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
@@ -1280,7 +1382,7 @@
<!-- 编辑分步向导与创建业务订单弹窗统一 -->
<el-dialog
v-model="editVisible"
title="编辑业务订单"
:title="editGancaoLogisticsOnlyMode ? '修改物流信息(甘草订单)' : '编辑业务订单'"
width="940px"
top="4vh"
:close-on-click-modal="false"
@@ -1303,18 +1405,33 @@
</el-tooltip>
</div>
<el-steps
:active="editOrderStep"
finish-status="success"
align-center
class="create-order-steps"
<el-alert
v-if="editGancaoLogisticsOnlyMode"
type="info"
:closable="false"
show-icon
class="mb-4"
title="订单已提交甘草,仅可修改快递单号与承运商;地址、金额等变更请先走取消流程。"
>
<el-step title="患者与收货" description="诊单与物流地址" />
<el-step title="服务与支付单" description="套餐与关联收款" />
<el-step title="金额与确认" description="费用与备注" />
</el-steps>
<template v-if="editGancaoDisplayNo" #default>
<div class="text-sm text-gray-700 mt-1">甘草处方单号{{ editGancaoDisplayNo }}</div>
</template>
</el-alert>
<p class="create-order-step-lead">{{ editOrderStepLead }}</p>
<template v-if="!editGancaoLogisticsOnlyMode">
<el-steps
:active="editOrderStep"
finish-status="success"
align-center
class="create-order-steps"
>
<el-step title="患者与收货" description="诊单与物流地址" />
<el-step title="服务与支付单" description="套餐与关联收款" />
<el-step title="金额与确认" description="费用与备注" />
</el-steps>
<p class="create-order-step-lead">{{ editOrderStepLead }}</p>
</template>
<el-form
ref="editFormRef"
@@ -1323,6 +1440,24 @@
label-width="108px"
class="create-order-form"
>
<template v-if="editGancaoLogisticsOnlyMode">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="物流设置">
<div class="flex gap-2 w-full">
<el-select v-model="editForm.express_company" placeholder="承运商" class="w-[140px] shrink-0">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
</div>
</el-form-item>
</el-col>
</el-row>
</template>
<template v-if="!editGancaoLogisticsOnlyMode">
<div v-show="editOrderStep === 0" class="create-order-step-panel">
<el-row :gutter="20">
<el-col :span="12">
@@ -1469,7 +1604,17 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="服务渠道">
<el-input v-model="editForm.service_channel" placeholder="可选" maxlength="100" />
<el-select
v-model="editForm.service_channel"
class="w-full"
filterable
allow-create
default-first-option
clearable
placeholder="可选或自定义输入"
>
<el-option label="未指派" value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -1646,12 +1791,13 @@
</el-col>
</el-row>
</div>
</template>
</el-form>
</div>
<template #footer>
<div class="create-order-footer">
<div class="create-order-footer__hint">
<div v-if="!editGancaoLogisticsOnlyMode" class="create-order-footer__hint">
<span class="create-order-footer__step"> {{ editOrderStep + 1 }} / 3 </span>
<span v-if="editOrderStep === 2 && editDepositMin > 0" class="create-order-footer__warn">
订单金额与关联支付单合计须 ¥{{ formatMoney(editDepositMin) }}
@@ -1659,17 +1805,19 @@
</div>
<div class="create-order-footer__actions">
<el-button size="large" @click="editVisible = false">取消</el-button>
<el-button v-if="editOrderStep > 0" size="large" @click="goEditOrderPrevStep">上一步</el-button>
<template v-if="!editGancaoLogisticsOnlyMode">
<el-button v-if="editOrderStep > 0" size="large" @click="goEditOrderPrevStep">上一步</el-button>
<el-button
v-if="editOrderStep < 2"
type="primary"
size="large"
@click="goEditOrderNextStep"
>
下一步
</el-button>
</template>
<el-button
v-if="editOrderStep < 2"
type="primary"
size="large"
@click="goEditOrderNextStep"
>
下一步
</el-button>
<el-button
v-if="editOrderStep === 2"
v-if="editGancaoLogisticsOnlyMode || editOrderStep === 2"
type="primary"
size="large"
:loading="editSaving"
@@ -1813,21 +1961,56 @@
width="480px"
:close-on-click-modal="false"
destroy-on-close
@closed="completeOrderId = 0"
@closed="onCompleteOrderDialogClosed"
>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
placeholder="请选择"
>
<el-option
v-for="opt in completeOrderStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<div v-loading="completeOrderLogisticsLoading" class="min-h-[52px]">
<el-alert
v-if="completeOrderLogisticsUrgent.show"
type="error"
effect="dark"
:closable="false"
show-icon
class="mb-3 !items-start ring-2 ring-red-600/70"
title="物流异常:轨迹含拒收/退回等,请先核实处理再结案"
>
<template #default>
<div class="text-xs leading-relaxed space-y-1.5">
<div v-if="completeOrderLogisticsUrgent.keywords.length" class="flex flex-wrap gap-1 items-center">
<span class="text-red-100/90">命中</span>
<el-tag
v-for="k in completeOrderLogisticsUrgent.keywords"
:key="k"
type="danger"
effect="plain"
size="small"
class="!border-white/35 !text-white"
>
{{ k }}
</el-tag>
</div>
<ul v-if="completeOrderLogisticsUrgent.lines.length" class="list-disc pl-4 space-y-0.5 text-red-50">
<li v-for="(line, idx) in completeOrderLogisticsUrgent.lines" :key="idx">{{ line }}</li>
</ul>
<div class="text-red-100/85 border-t border-white/15 pt-1.5">
确认已协调拒收/退回后续重发退款改址等后再完成订单
</div>
</div>
</template>
</el-alert>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
placeholder="请选择"
>
<el-option
v-for="opt in completeOrderStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<template #footer>
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
@@ -2373,7 +2556,7 @@
<script lang="ts" setup name="prescriptionOrderList">
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { Refresh, Loading, ArrowDown, InfoFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import { Refresh, Loading, ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import {
prescriptionOrderAuditPayment,
@@ -2381,6 +2564,7 @@ import {
prescriptionOrderDetail,
prescriptionOrderEdit,
prescriptionOrderLists,
prescriptionOrderExport,
prescriptionOrderPaidPayOrders,
prescriptionOrderWithdraw,
prescriptionOrderLogisticsTrace,
@@ -2665,6 +2849,8 @@ const queryParams = reactive({
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self',
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */
service_channel: '' as '' | '0',
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
exclude_fulfillment_cancelled: 0 as number
})
@@ -2729,21 +2915,35 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
resetPage()
}
async function fetchLists(params: Record<string, unknown>) {
function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): Record<string, unknown> {
const p: Record<string, unknown> = { ...params }
if (p.prescription_id === '' || p.prescription_id === undefined) {
delete p.prescription_id
} else {
p.prescription_id = Number(p.prescription_id)
}
if (p.fulfillment_status === '' || p.fulfillment_status === undefined) {
if (p.fulfillment_status === '' || p.fulfillment_status === undefined || p.fulfillment_status === null) {
delete p.fulfillment_status
} else {
p.fulfillment_status = Number(p.fulfillment_status)
}
if (p.prescription_audit_status === '' || p.prescription_audit_status === undefined) {
if (
p.prescription_audit_status === '' ||
p.prescription_audit_status === undefined ||
p.prescription_audit_status === null
) {
delete p.prescription_audit_status
} else {
p.prescription_audit_status = Number(p.prescription_audit_status)
}
if (p.payment_slip_audit_status === '' || p.payment_slip_audit_status === undefined) {
if (
p.payment_slip_audit_status === '' ||
p.payment_slip_audit_status === undefined ||
p.payment_slip_audit_status === null
) {
delete p.payment_slip_audit_status
} else {
p.payment_slip_audit_status = Number(p.payment_slip_audit_status)
}
if (!p.supply_mode || p.supply_mode === '') {
delete p.supply_mode
@@ -2767,12 +2967,17 @@ async function fetchLists(params: Record<string, unknown>) {
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 (p.service_channel === '' || p.service_channel === undefined || p.service_channel === null) {
delete p.service_channel
} else {
p.service_channel = String(p.service_channel).trim()
if (p.service_channel === '') delete p.service_channel
}
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
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)) {
@@ -2780,9 +2985,16 @@ async function fetchLists(params: Record<string, unknown>) {
p.end_time = `${et} 23:59:59`
}
}
return p
}
async function fetchLists(params: Record<string, unknown>) {
const p = normalizePrescriptionOrderListQuery({ ...params })
return prescriptionOrderLists(p)
}
const prescriptionOrderExportParams = computed(() => normalizePrescriptionOrderListQuery({ ...queryParams }))
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: fetchLists,
params: queryParams
@@ -2978,6 +3190,7 @@ function handleReset() {
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
queryParams.service_channel = ''
queryParams.exclude_fulfillment_cancelled = 0
resetParams()
}
@@ -3081,14 +3294,21 @@ function canEditRow(row: {
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
// 已发货(5)、已签收(6)、已完成(3)、已取消(4) 不可编辑
const fs = Number(row.fulfillment_status)
if (fs !== 1 && fs !== 2) return false
// 已成功提交甘草(已生成甘草处方单号 或 提交时间 > 0)不允许再编辑
// (3)(4)
if (fs === 3 || fs === 4) return false
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
if (gcNo !== '' || gcTime > 0) return false
return true
const gcLocked = gcNo !== '' || gcTime > 0
// /// 6 edit
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
// (1)(2)
return fs === 1 || fs === 2
}
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
@@ -3157,9 +3377,8 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// 已发货(5) / 已签收(6) 后如需修改快递信息可单独使用此功能
const fs = Number(row.fulfillment_status)
return fs === 5 || fs === 6
// (5) (6)
return Number(row.fulfillment_status) === 5
}
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
@@ -3532,6 +3751,75 @@ const logisticsTraceList = computed(() => {
return p.traces as Array<{ time: string; context: string }>
})
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
const LOGISTICS_URGENT_KEYWORDS = [
'拒收',
'拒签',
'退件',
'客户拒收',
'拦截退回',
'退回发件',
'派件退回',
'无人签收',
'退回快件'
]
function logisticsStringHasUrgentKeyword(s: unknown): boolean {
const t = String(s ?? '')
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
}
function logisticsTraceLineUrgent(context: unknown): boolean {
return logisticsStringHasUrgentKeyword(context)
}
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
show: boolean
keywords: string[]
lines: string[]
} {
if (!p) {
return { show: false, keywords: [], lines: [] }
}
const traces = Array.isArray(p.traces)
? (p.traces as Array<{ time?: string; context?: string }>)
: []
const chunks: string[] = []
if (p.state_text) chunks.push(String(p.state_text))
if (p.hint) chunks.push(String(p.hint))
for (const row of traces) {
if (row.context) chunks.push(String(row.context))
}
const joined = chunks.join('\n')
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
if (!keywords.length) {
return { show: false, keywords: [], lines: [] }
}
const lines: string[] = []
for (const row of traces) {
const ctx = String(row.context || '')
if (logisticsStringHasUrgentKeyword(ctx)) {
lines.push(ctx)
if (lines.length >= 5) break
}
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
lines.push(`最新状态:${p.state_text}`)
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
lines.push(String(p.hint))
}
return { show: true, keywords, lines }
}
/** 详情抽屉:轨迹/状态中是否出现拒收等字样,用于顶部与物流卡片强提示 */
const detailLogisticsUrgent = computed(() => analyzeLogisticsPayloadUrgent(logisticsTracePayload.value))
const completeOrderLogisticsPayload = ref<Record<string, any> | null>(null)
const completeOrderLogisticsLoading = ref(false)
const completeOrderLogisticsUrgent = computed(() => analyzeLogisticsPayloadUrgent(completeOrderLogisticsPayload.value))
function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
@@ -3726,6 +4014,10 @@ const editSaving = ref(false)
const editOrderStep = ref(0)
/** 顶部「关联处方」卡片数据(来自业务订单详情中的 prescription */
const editOrderPrescription = ref<Record<string, any> | null>(null)
/** 甘草 SCM 已提交:弹窗仅展示并提交快递单号与承运商 */
const editGancaoLogisticsOnlyMode = ref(false)
/** 用于提示文案展示甘草处方单号 */
const editGancaoDisplayNo = ref('')
const editOrderStepLead = computed(() => {
const texts = [
@@ -3904,6 +4196,8 @@ const editRules = computed<FormRules>(() => {
function resetEditOrderDialog() {
editOrderStep.value = 0
editOrderPrescription.value = null
editGancaoLogisticsOnlyMode.value = false
editGancaoDisplayNo.value = ''
editFormRef.value?.clearValidate()
}
@@ -4023,7 +4317,12 @@ async function openEdit(row: { id: number }) {
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
editForm.internal_cost =
d.internal_cost != null && d.internal_cost !== '' ? Number(d.internal_cost) : undefined
const gcNo = String(d.gancao_reciperl_order_no || '').trim()
const gcTime = Number(d.gancao_submit_time || 0)
editGancaoLogisticsOnlyMode.value = gcNo !== '' || gcTime > 0
editGancaoDisplayNo.value = gcNo || ''
await loadEditPaidOrders(editForm.diagnosis_id, editForm.id)
// BUG FIX: NextTick
@@ -4039,8 +4338,14 @@ async function openEdit(row: { id: number }) {
}
async function submitEdit() {
if (!editFormRef.value || editOrderStep.value !== 2) return
await editFormRef.value.validate()
if (!editFormRef.value) return
const gancaoLogisticsOnly = editGancaoLogisticsOnlyMode.value
if (!gancaoLogisticsOnly && editOrderStep.value !== 2) return
if (!gancaoLogisticsOnly) {
await editFormRef.value.validate()
} else {
editFormRef.value.clearValidate()
}
editSaving.value = true
try {
const payload: Record<string, unknown> = {
@@ -4242,6 +4547,61 @@ async function testGancaoPreviewFromDetail() {
if (d && d.success) {
gancaoPreviewData.value = d
gancaoPreviewDrawerVisible.value = true
// ( + + ) internal_cost canViewInternalCost
if (canViewFinanceFields() && d.fee) {
const internalTotal = calculateTotalFee(d.fee)
try {
const resDetail: any = await prescriptionOrderDetail({ id: detailData.value.id })
const od = resDetail?.data ?? resDetail
if (od?.id) {
const pkgRaw = od.service_package
const servicePackageStr = Array.isArray(pkgRaw)
? pkgRaw.join(',')
: String(pkgRaw || '')
await prescriptionOrderEdit({
id: od.id,
recipient_name: od.recipient_name || '',
recipient_phone: od.recipient_phone || '',
shipping_province: od.shipping_province || '',
shipping_city: od.shipping_city || '',
shipping_district: od.shipping_district || '',
shipping_address: od.shipping_address || '',
is_follow_up: od.is_follow_up ? 1 : 0,
medication_days:
od.medication_days != null && String(od.medication_days).trim() !== ''
? od.medication_days
: '',
dose_unit: od.dose_unit || '剂',
dose_count: od.dose_count > 0 ? od.dose_count : 1,
prev_staff: od.prev_staff || '',
service_channel: od.service_channel || '',
service_package: servicePackageStr,
express_company: od.express_company || 'auto',
tracking_number: od.tracking_number || '',
fee_type: Number(od.fee_type) || 3,
amount: Number(od.amount) || 0,
remark_extra: od.remark_extra || '',
remark_assistant: od.remark_assistant || '',
internal_cost: internalTotal,
pay_order_ids: Array.isArray(od.pay_order_ids) ? od.pay_order_ids : []
})
feedback.msgSuccess('内部成本已按预报价总计更新')
getLists()
if (detailVisible.value && Number(detailData.value?.id) === Number(od.id)) {
try {
const r: any = await prescriptionOrderDetail({ id: od.id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
} catch {
detailData.value = { ...detailData.value, internal_cost: internalTotal }
}
}
}
} catch {
/* 拦截器已提示;仍保留抽屉中的预报价结果 */
}
}
//feedback.msgSuccess('')
} else {
// feedback.msgError(d?.error || '')
@@ -4357,8 +4717,8 @@ async function submitQuickTrack() {
if (nd) detailData.value = nd
} catch { /* 静默 */ }
}
// 履约中状态且刚填单号,提示是否立即发货
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
// (2)(5)/(6)
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
try {
await ElMessageBox.confirm(
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
@@ -4439,6 +4799,37 @@ const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLogisticsPayload.value = null
completeOrderLogisticsLoading.value = false
}
async function fetchCompleteOrderLogisticsForDialog(row: {
id: number
express_company?: unknown
recipient_phone?: unknown
}) {
completeOrderLogisticsLoading.value = true
completeOrderLogisticsPayload.value = null
try {
const digits = String(row.recipient_phone || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id: row.id,
express_company: String(row.express_company || 'auto') || 'auto'
}
if (digits.length >= 4) {
params.phone_tail = digits
}
const res: any = await prescriptionOrderLogisticsTrace(params)
completeOrderLogisticsPayload.value = (res?.data ?? res) as Record<string, any>
} catch {
completeOrderLogisticsPayload.value = null
} finally {
completeOrderLogisticsLoading.value = false
}
}
async function submitCompleteOrder() {
const id = completeOrderId.value
if (!id) return
@@ -4601,10 +4992,19 @@ async function submitAddPayOrder() {
}
// / +
function confirmComplete(row: { id: number }) {
function confirmComplete(row: {
id: number
tracking_number?: unknown
express_company?: unknown
recipient_phone?: unknown
}) {
completeOrderId.value = row.id
completeFulfillmentStatus.value = 3
completeOrderLogisticsPayload.value = null
completeOrderDialogVisible.value = true
if (String(row.tracking_number || '').trim()) {
void fetchCompleteOrderLogisticsForDialog(row)
}
}
//
@@ -925,11 +925,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -982,11 +983,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -3050,14 +3052,18 @@ function canEditRow(row: {
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
// (5)(6)(3)(4)
const fs = Number(row.fulfillment_status)
if (fs !== 1 && fs !== 2) return false
// > 0
if (fs === 3 || fs === 4) return false
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
if (gcNo !== '' || gcTime > 0) return false
return true
const gcLocked = gcNo !== '' || gcTime > 0
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
return fs === 1 || fs === 2
}
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
@@ -3126,9 +3132,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// (5) / (6) 使
const fs = Number(row.fulfillment_status)
return fs === 5 || fs === 6
return Number(row.fulfillment_status) === 5
}
function canUploadGancaoRow(row: {
@@ -4310,8 +4314,8 @@ async function submitQuickTrack() {
if (nd) detailData.value = nd
} catch { /* 静默 */ }
}
//
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
// (2)(5)/(6)
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
try {
await ElMessageBox.confirm(
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -102,6 +102,18 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="添加时间">
<el-date-picker
v-model="addTimeRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
class="!w-[280px]"
@change="onAddTimeRangeChange"
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryParams.tag_ids"
@@ -557,12 +569,34 @@ const syncSettings = reactive({
interval: 3600
})
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
const queryParams = reactive<{
name: string
follow_user: string
tag_ids: string[]
add_time_start: string
add_time_end: string
}>({
name: '',
follow_user: '',
tag_ids: []
tag_ids: [],
add_time_start: '',
add_time_end: ''
})
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time */
const addTimeRange = ref<[string, string] | null>(null)
function onAddTimeRangeChange(val: [string, string] | null) {
if (val && val.length === 2 && val[0] && val[1]) {
queryParams.add_time_start = val[0]
queryParams.add_time_end = val[1]
} else {
queryParams.add_time_start = ''
queryParams.add_time_end = ''
}
resetPage()
}
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
interface TagItem {
tag_id: string
@@ -793,6 +827,9 @@ function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
addTimeRange.value = null
resetParams()
}
+438 -246
View File
@@ -290,12 +290,12 @@
</table>
</div>
<div class="yeji-table__foot yeji-table__foot--left">
<span>诊金单位 · 被指派数与部门业绩表同口径 · 接诊率 = 诊金 ÷ 进线/进线</span>
<span>诊金单位 · 诊金/接诊诊单/复诊均按订单创建人 = 该医助归属与部门合计业绩 / 接诊诊单 / 复诊同口径 · 被指派数与部门业绩表同口径 · 接诊率 = 诊金 ÷ 进线/进线</span>
<span v-if="lb.er_center_subtree">
· 复诊列为二中心口径同一诊单业务单序列第 2 笔起点击数字查看对应业务订单
</span>
<span>
· 预约诊单预约日期在统计区间内状态含已预约/已完成/已过号不含已取消医助归属与接诊单数一致各科组展示全部医助
· 预约诊单预约日期在统计区间内状态含已预约/已完成/已过号不含已取消医助归属与接诊单数一致按挂号 assistant_id 优先否则诊单 assistant_id各科组展示全部医助
</span>
</div>
</div>
@@ -317,75 +317,7 @@
</el-tab-pane>
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
<p class="yeji-zyyt-intro">
各中心部门诊金业绩进线与 ROI 与顶部渠道展示部门日期筛选联动本月 / 本周 / 今日等多张业绩表中预约诊单列数字可点开查看每条挂号记录
</p>
<!-- <el-alert
v-if="channelFilterNote"
:title="channelFilterNote"
type="info"
show-icon
:closable="false"
class="filter-alert"
/> -->
<!-- 目标进度卡片 -->
<div
v-if="targetProgressCard || targetProgressLoading"
v-loading="targetProgressLoading"
class="tp-wrap"
>
<div v-if="targetProgressCard" class="tp-card">
<div class="tp-card__title">{{ targetProgressCard.title }}</div>
<div class="tp-card__body">
<div
v-for="sec in targetProgressCard.sections"
:key="sec.dept_id"
class="tp-section"
>
<div class="tp-section__head">
<div class="tp-section__title-col">
<span v-if="sec.parent_dept_name" class="tp-section__parent">{{
sec.parent_dept_name
}}</span>
<span class="tp-section__name">{{ sec.dept_name }}目标</span>
</div>
<span class="tp-section__target">{{ formatMoneyW(sec.target_amount) }}</span>
</div>
<table class="tp-table">
<tbody>
<tr>
<td class="tp-lbl">昨日诊金</td>
<td class="tp-val">{{ formatInt(sec.yesterday_amount) }}</td>
</tr>
<tr v-if="sec.douyin_amount !== null">
<td class="tp-lbl">抖音总诊金</td>
<td class="tp-val">{{ formatInt(sec.douyin_amount) }}</td>
</tr>
<tr v-if="sec.tuifen_amount !== null">
<td class="tp-lbl">推粉总诊金</td>
<td class="tp-val">{{ formatInt(sec.tuifen_amount) }}</td>
</tr>
<tr>
<td class="tp-lbl">总诊金</td>
<td class="tp-val tp-val--total">{{ formatInt(sec.total_amount) }}</td>
</tr>
<tr>
<td class="tp-lbl">目标进度</td>
<td class="tp-val">{{ formatMoney(sec.target_progress) }}</td>
</tr>
<tr>
<td class="tp-lbl">完成进度</td>
<td class="tp-val" :class="achievementClass(sec.completion_rate)">{{ formatAchievement(sec.completion_rate) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 业绩表4 /N -->
<!-- 业绩表4 /N 目标看板见目标看板卡片Tab -->
<div v-loading="loading" class="tables-wrap">
<div v-if="!loading && tables.length === 0" class="empty-tip">
<el-empty description="暂无数据" />
@@ -438,7 +370,7 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<b>名称含二中心</b>的部门及其组织下级与本表区间一致按患者业务单 <b>create_time</b> 排序<b> 1 笔不计</b> 2 笔起计入合计分列复诊2复诊三含本部门及下级汇总订单条件同合计业绩非取消非二中心子树部门为 0
<b>名称含二中心</b>的部门及其组织下级候选订单为有诊单医助 <b>fulfillment_status</b> 4/9/10<b>NULL 计入</b>的业务单按患者 <b>create_time</b> 排序<b> 1 笔不计</b> 2 笔起计入合计分列复诊2复诊三<b>归属</b>按订单创建人的人事部门<b>合计业绩</b>列同口径非二中心子树部门为 0
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
@@ -463,8 +395,9 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 300px; line-height: 1.7; font-size: 12px">
按订单<b>创建时间</b>统计剔除履约已取消金额与<b>接诊诊单</b>列一致诊单<b>医助</b>或订单<b>创建人</b>的人事部门落在本展示行含下级计入多行命中时取最深的展示部门<br />
<b>不受渠道筛选影响</b>与侧栏按部门打开的业务订单列表对齐
按订单<b>创建时间</b>统计剔除履约已取消/拒收/退款 4·9·10金额与<b>接诊诊单</b>列一致仅按<b>订单创建人</b>的人事部门落在本展示行含下级计入多行命中时取最深的展示部门<b>医助排行榜诊金</b>同口径<br />
<b>不受渠道筛选影响</b>与侧栏按部门打开的业务订单列表对齐<br />
点击金额可查看对应业务订单明细侧栏默认合计业绩全量列表
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
@@ -476,8 +409,9 @@
<template #content>
<div style="max-width: 340px; line-height: 1.7; font-size: 12px">
{{ tb.channel_name }}渠道业绩与卡片标题渠道一致处方订单列表业绩<b>同口径</b>按订单
<b>create_time</b> 归日剔除履约已取消(4)<br />
渠道判定用 EXISTS该患者有任一挂号 <b>channels 命中字典 + status=3</b>不限挂号时点
<b>create_time</b> 归日剔除履约状态 4已取消/9拒收/10退款<br />
渠道判定用 EXISTS该患者有任一挂号 <b>channels 命中字典 + status=3</b>不限挂号时点<br />
点击金额可查看该部门本渠道订单明细侧栏渠道筛选列表
</div>
</template>
<el-icon class="col-info" aria-label="说明"><InfoFilled /></el-icon>
@@ -511,8 +445,12 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<b>业务订单条数</b>非取消订单 <b>create_time</b> 落入区间<b>fulfillment_status 4</b>与列表筛选
<b>assistant_dept_id</b> 时一致 <b>医助</b>或订单 <b>创建人</b>的人事部门落在该部门子树即计入表格多行命中时取最深的展示部门侧栏勾选与表格业绩对齐时的集合可能略有差异选定渠道时本列仍为全量
<<<<<<< HEAD
<b>业务订单条数</b>计业绩 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b><b>NULL 计入</b><b>订单创建人</b>的人事部门落在该部门子树即计入表格多行命中时取最深的展示部门<b>合计业绩 / 医助排行榜接诊诊单</b>同口径选定渠道时本列仍为全量
=======
<b>业务订单条数</b>计业绩订单 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b>与列表筛选
<b>assistant_dept_id</b> 时一致<b>创建人</b>人事部门优先无创建人则诊单 <b>医助</b>落在该部门子树即计入表格多行命中时取最深的展示部门与侧栏勾选与表格业绩对齐时的集合可能略有差异选定渠道时本列仍为全量
>>>>>>> master
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
@@ -523,7 +461,7 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
{{ tb.channel_name }}渠道成交单数与渠道业绩同源区间内 <b>非取消业务订单</b> 条数按订单
{{ tb.channel_name }}渠道成交单数与渠道业绩同源区间内 <b>计业绩的业务订单</b> 条数剔除 4/9/10按订单
<b>create_time</b> 归日渠道判定同业绩列
</div>
</template>
@@ -611,8 +549,25 @@
>{{ formatInt(yejiRevisitSlotAt(r, slot)) }}</span>
<template v-else>{{ formatInt(yejiRevisitSlotAt(r, slot)) }}</template>
</td>
<td>{{ formatMoney(r.performance_amount) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatMoney(r.completed_performance_amount) }}</td>
<td>
<span
v-if="yejiPerfCellDrawerEligible(tb, r, 'total')"
class="yeji-lead-cell--link"
@click.stop="onYejiPerformanceCellClick(tb, r, 'total')"
>{{ formatMoney(r.performance_amount) }}</span>
<template v-else>{{ formatMoney(r.performance_amount) }}</template>
</td>
<td
v-if="tb.channel_name"
class="col-completed"
>
<span
v-if="yejiPerfCellDrawerEligible(tb, r, 'channel')"
class="yeji-lead-cell--link"
@click.stop="onYejiPerformanceCellClick(tb, r, 'channel')"
>{{ formatMoney(r.completed_performance_amount) }}</span>
<template v-else>{{ formatMoney(r.completed_performance_amount) }}</template>
</td>
<td @click.stop="onYejiDeptAppointmentBookedClick(tb, r)">
<span
v-if="
@@ -639,8 +594,25 @@
v-for="slot in yejiRevisitSlotColumns"
:key="'yjvt-' + idx + '-' + slot"
>{{ formatInt(yejiRevisitSlotAt(tb.total, slot)) }}</td>
<td>{{ formatMoney(tb.total.performance_amount) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatMoney(tb.total.completed_performance_amount) }}</td>
<td>
<span
v-if="yejiPerfTotalRowDrawerEligible(tb, 'total')"
class="yeji-lead-cell--link"
@click.stop="onYejiPerformanceTotalRowClick(tb, 'total')"
>{{ formatMoney(tb.total.performance_amount) }}</span>
<template v-else>{{ formatMoney(tb.total.performance_amount) }}</template>
</td>
<td
v-if="tb.channel_name"
class="col-completed"
>
<span
v-if="yejiPerfTotalRowDrawerEligible(tb, 'channel')"
class="yeji-lead-cell--link"
@click.stop="onYejiPerformanceTotalRowClick(tb, 'channel')"
>{{ formatMoney(tb.total.completed_performance_amount) }}</span>
<template v-else>{{ formatMoney(tb.total.completed_performance_amount) }}</template>
</td>
<td>{{ formatInt(tb.total.appointment_booked_count ?? 0) }}</td>
<td>{{ formatInt(tb.total.consult_count) }}</td>
<td>{{ formatInt(tb.total.deal_order_count) }}</td>
@@ -663,6 +635,72 @@
</div>
</el-tab-pane>
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy>
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
<template v-if="targetProgressCard">
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
<div class="tp-matrix" role="list">
<div
v-for="sec in targetProgressCard.sections"
:key="'tm-' + sec.dept_id"
class="tp-matrix-card"
role="listitem"
>
<div class="tp-matrix-card__head">
<div class="tp-matrix-card__titles">
<span v-if="sec.parent_dept_name" class="tp-matrix-card__parent">{{
sec.parent_dept_name
}}</span>
<span class="tp-matrix-card__name">{{ sec.dept_name }}目标</span>
</div>
<span class="tp-matrix-card__target">{{ formatMoneyW(sec.target_amount) }}</span>
</div>
<table class="tp-matrix-card__table">
<tbody>
<tr>
<td class="tp-matrix-card__lbl">昨日诊金</td>
<td class="tp-matrix-card__num">{{ formatInt(sec.yesterday_amount) }}</td>
</tr>
<tr v-if="sec.douyin_amount !== null">
<td class="tp-matrix-card__lbl">抖音总诊金</td>
<td class="tp-matrix-card__num">{{ formatInt(sec.douyin_amount) }}</td>
</tr>
<tr v-if="sec.tuifen_amount !== null">
<td class="tp-matrix-card__lbl">推粉总诊金</td>
<td class="tp-matrix-card__num">{{ formatInt(sec.tuifen_amount) }}</td>
</tr>
<tr>
<td class="tp-matrix-card__lbl">总诊金</td>
<td class="tp-matrix-card__num tp-matrix-card__num--hot">
{{ formatInt(sec.total_amount) }}
</td>
</tr>
<tr>
<td class="tp-matrix-card__lbl">目标进度</td>
<td class="tp-matrix-card__num">{{ formatMoney(sec.target_progress) }}</td>
</tr>
<tr>
<td class="tp-matrix-card__lbl">完成进度</td>
<td
class="tp-matrix-card__num tp-matrix-card__num--hot"
:class="achievementClass(sec.completion_rate)"
>
{{ formatAchievement(sec.completion_rate) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<el-empty
v-else-if="!targetProgressLoading"
class="tp-matrix-empty"
description="暂无目标数据,请先配置当月部门目标或为部门设置诊金目标后刷新页面"
/>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
@@ -936,7 +974,7 @@
>
<span class="yeji-order-drawer__stat-label">
业务订单金额合计
<span class="yeji-order-drawer__stat-note">(不含履约已取消,与上方业绩口径一致)</span>
<span class="yeji-order-drawer__stat-note">(不含履约 4·9·10,与上方业绩口径一致)</span>
</span>
<span class="yeji-order-drawer__stat-value yeji-order-drawer__stat-value--money">
¥{{ formatOrderDrawerMoney(orderDrawerTotalAmount) }}
@@ -1047,6 +1085,7 @@
</template>
</el-table-column>
<el-table-column prop="doctor_name" label="开方人" width="88" show-overflow-tooltip />
<el-table-column prop="assistant_name" label="诊单医助" width="88" show-overflow-tooltip />
<el-table-column prop="creator_name" label="创建人" width="88" show-overflow-tooltip />
</el-table>
<el-empty v-if="!orderDrawerLoading && orderDrawerLists.length === 0" description="暂无业务订单" />
@@ -1197,6 +1236,7 @@
<el-table-column prop="patient_name" label="患者" min-width="92" show-overflow-tooltip />
<el-table-column prop="patient_phone" label="手机" min-width="116" show-overflow-tooltip />
<el-table-column prop="doctor_name" label="接诊医生" width="92" show-overflow-tooltip />
<el-table-column prop="assistant_name" label="医助" width="88" show-overflow-tooltip />
<el-table-column label="诊单ID" width="84" align="right">
<template #default="{ row }">{{ formatInt(row.diagnosis_id ?? 0) }}</template>
</el-table-column>
@@ -1279,6 +1319,7 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import vCharts from 'vue-echarts'
@@ -1623,7 +1664,7 @@ const appointmentLinesShowChannelColumn = computed(() =>
)
/** 图表 / 数据表切换 */
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt'>('zyyt')
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt')
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
const userStore = useUserStore()
@@ -2040,7 +2081,9 @@ async function loadDoctorDailyStats() {
if (res?.start_date && res?.end_date) {
doctorDailyRange.value = { start: res.start_date, end: res.end_date }
}
} catch (_e) {
} catch (e: unknown) {
// 与业绩主接口并发时,同源请求键相同会触发 axios 重复请求取消;勿清空状态以免覆盖后到的成功结果
if (axios.isCancel(e)) return
doctorDailyRows.value = []
doctorDailyTotal.value = {}
doctorDailyRange.value = null
@@ -2069,7 +2112,15 @@ const orderDrawerSplitPerfCache = ref<{
} | null>(null)
type OrderDrawerFilter =
| { mode: 'dept'; deptId: number; deptName: string; start: string; end: string }
| {
mode: 'dept'
deptId: number
deptName: string
start: string
end: string
/** 当前业绩表各展示行 dept_id,用于侧栏与「接诊诊单」列对齐(创建人优先 + 最深展示行) */
yejiTableRowDeptIds?: number[]
}
| {
mode: 'assistant'
assistantId: number
@@ -2081,7 +2132,7 @@ type OrderDrawerFilter =
}
const orderDrawerFilter = ref<OrderDrawerFilter | null>(null)
/** 列表接口 extend,含 stats_order_amount_performance(与当前筛选、不含已取消口径一致) */
/** 列表接口 extend,含 stats_order_amount_performance(与当前筛选一致:剔除履约 4/9/10 */
const orderDrawerExtend = ref<Record<string, any> | null>(null)
const orderDrawerTotalAmount = computed(() => {
@@ -2429,7 +2480,8 @@ async function loadLeaderboard(range: { start: string; end: string }) {
range_note: res.range_note || '',
leaderboards: res.leaderboards || [],
}
} catch (_e) {
} catch (e: unknown) {
if (axios.isCancel(e)) return
leaderboardBlock.value = null
} finally {
leaderboardsLoading.value = false
@@ -2484,8 +2536,10 @@ async function loadData() {
}))
const ymd = formatLocalYmd(new Date())
await loadLeaderboard({ start: ymd, end: ymd })
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载失败')
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载失败')
} finally {
loading.value = false
if (canViewDoctorDailyStats.value) {
@@ -2586,40 +2640,82 @@ function formatOrderDrawerTime(t: unknown): string {
return s.length >= 16 ? s.slice(0, 16) : s
}
async function fetchOrderDrawerPage() {
/** 业务订单侧栏列表筛选(不含分页) */
function buildOrderDrawerListParams(): Record<string, any> | null {
const f = orderDrawerFilter.value
if (!f) return
if (!f) return null
const params: Record<string, any> = {
start_time: `${f.start} 00:00:00`,
end_time: `${f.end} 23:59:59`,
/** 与业绩列口径一致:不列出履约 4(已取消)/9(拒收)/10(退款) */
exclude_fulfillment_cancelled: 1,
/**
* 业绩看板入口标志:后端 PrescriptionOrderLists::applyDoctorAssistantFilters 看到此标志后,
* assistant_id / assistant_dept_id 一律按订单创建人收窄(与表格诊金/合计业绩/复诊同口径),
* 而非默认的「创建人 ∪ 诊单医助」并集。同时启用 extend 字段(约诊数、合计业绩对照)。
*/
yeji_order_drawer: 1,
}
if (f.mode === 'dept') {
params.assistant_dept_id = f.deptId
if (selectedDeptIds.value.length > 0) {
params.dept_ids = selectedDeptIds.value.join(',')
}
} else {
params.assistant_id = f.assistantId
if (f.erCenterRevisitSlot !== undefined) {
params.yeji_er_center_revisit_only = 1
params.yeji_er_center_revisit_slot = f.erCenterRevisitSlot
}
}
if (
selectedChannel.value &&
orderDrawerVisible.value &&
orderDrawerListTab.value === 'channel'
) {
params.channel_code = selectedChannel.value
}
return params
}
async function hydrateOrderDrawerSplitPerfCache() {
const base = buildOrderDrawerListParams()
if (!base || !selectedChannel.value) return
const params: Record<string, any> = {
...base,
channel_code: selectedChannel.value,
page_no: 1,
page_size: 1,
}
try {
const res: any = await prescriptionOrderLists(params)
const ex = res?.extend && typeof res.extend === 'object' ? res.extend : null
if (ex && ex.stats_yeji_drawer_total_performance_amount != null) {
orderDrawerSplitPerfCache.value = {
totalAmount: Number(ex.stats_yeji_drawer_total_performance_amount),
totalOrderCount: Number(ex.stats_yeji_drawer_total_performance_order_count ?? 0),
channelAmount: Number(ex.stats_order_amount_performance ?? 0),
channelOrderCount: Number(res?.count ?? 0),
}
}
} catch {
// 对照指标失败不打断列表
}
}
async function fetchOrderDrawerPage() {
const base = buildOrderDrawerListParams()
if (!base) return
if (selectedChannel.value && orderDrawerListTab.value === 'total') {
orderDrawerSplitPerfCache.value = null
}
orderDrawerLoading.value = true
try {
const params: Record<string, any> = {
...base,
page_no: orderDrawerPage.value,
page_size: orderDrawerPageSize.value,
start_time: `${f.start} 00:00:00`,
end_time: `${f.end} 23:59:59`,
/** 与业绩列口径一致:不列出履约已取消(4) */
exclude_fulfillment_cancelled: 1,
/** 侧栏展示约诊数等 extend 字段 */
yeji_order_drawer: 1,
}
if (f.mode === 'dept') {
params.assistant_dept_id = f.deptId
/** 与看板「接诊诊单」列一致:处方列表默认 assistant_dept_id(诊单医助或创建人∪子树),不用业绩摊行医助集合 */
if (selectedDeptIds.value.length > 0) {
params.dept_ids = selectedDeptIds.value.join(',')
}
} else {
params.assistant_id = f.assistantId
if (f.erCenterRevisitSlot !== undefined) {
params.yeji_er_center_revisit_only = 1
params.yeji_er_center_revisit_slot = f.erCenterRevisitSlot
}
}
if (
selectedChannel.value &&
orderDrawerChannelSplitActive.value &&
orderDrawerListTab.value === 'channel'
) {
params.channel_code = selectedChannel.value
}
const res: any = await prescriptionOrderLists(params)
orderDrawerLists.value = Array.isArray(res?.lists) ? res.lists : []
@@ -2640,8 +2736,17 @@ async function fetchOrderDrawerPage() {
channelOrderCount: Number(res?.count ?? 0),
}
}
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载业务订单失败')
if (
selectedChannel.value &&
orderDrawerVisible.value &&
orderDrawerListTab.value === 'total'
) {
await hydrateOrderDrawerSplitPerfCache()
}
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载业务订单失败')
orderDrawerLists.value = []
orderDrawerCount.value = 0
orderDrawerExtend.value = null
@@ -2650,8 +2755,9 @@ async function fetchOrderDrawerPage() {
}
}
function openOrderDrawerByDept(tb: YejiTable, row: YejiRow) {
orderDrawerListTab.value = 'channel'
function openOrderDrawerByDept(tb: YejiTable, row: YejiRow, opts?: { listTab?: 'total' | 'channel' }) {
const channelSplit = Boolean(selectedChannel.value && tb.channel_name)
orderDrawerListTab.value = channelSplit ? opts?.listTab ?? 'channel' : 'channel'
orderDrawerSplitPerfCache.value = null
orderDrawerFilter.value = {
mode: 'dept',
@@ -2659,14 +2765,79 @@ function openOrderDrawerByDept(tb: YejiTable, row: YejiRow) {
deptName: row.dept_name,
start: tb.start_date,
end: tb.end_date,
yejiTableRowDeptIds: tb.rows.map(r => r.dept_id).filter((id): id is number => typeof id === 'number' && id > 0),
}
orderDrawerTitle.value = `业务订单 · ${row.dept_name}`
orderDrawerHint.value = `创建时间:${tb.start_date} ${tb.end_date} · 与「接诊诊单」同口径:诊单医助或订单创建人落在本部门(含下级)· 不含履约已取消`
orderDrawerHint.value = `创建时间:${tb.start_date} ${tb.end_date} · 与「合计业绩 / 接诊诊单」同口径:订单创建人人事部门 ∈ 本部门子树 · 不含履约 4/9/10`
orderDrawerPage.value = 1
orderDrawerVisible.value = true
void fetchOrderDrawerPage()
}
/** 顶部「展示部门」仅选中 1 个根节点时:合计行业绩对应该子树下的订单汇总 */
function openOrderDrawerByYejiTableScope(
tb: YejiTable,
rootDeptId: number,
listTab: 'total' | 'channel'
) {
const channelSplit = Boolean(selectedChannel.value && tb.channel_name)
orderDrawerListTab.value = channelSplit ? listTab : 'channel'
orderDrawerSplitPerfCache.value = null
orderDrawerFilter.value = {
mode: 'dept',
deptId: rootDeptId,
deptName: '当前展示合计',
start: tb.start_date,
end: tb.end_date,
yejiTableRowDeptIds: tb.rows.map(r => r.dept_id).filter((id): id is number => typeof id === 'number' && id > 0),
}
orderDrawerTitle.value = '业务订单 · 当前展示合计'
orderDrawerHint.value = `创建时间:${tb.start_date} ${tb.end_date} · 已选展示部门 1 个根节点(含其下全部展示行)· 不含履约 4/9/10`
orderDrawerPage.value = 1
orderDrawerVisible.value = true
void fetchOrderDrawerPage()
}
function yejiPerfCellDrawerEligible(tb: YejiTable, row: YejiRow, kind: 'total' | 'channel'): boolean {
if (isUnassignedYejiRow(row) || row.dept_id <= 0) {
return false
}
if (kind === 'channel' && !tb.channel_name) {
return false
}
return true
}
function yejiPerfTotalRowDrawerEligible(tb: YejiTable, kind: 'total' | 'channel'): boolean {
if (selectedDeptIds.value.length !== 1) {
return false
}
if (kind === 'channel' && !tb.channel_name) {
return false
}
return true
}
function onYejiPerformanceCellClick(tb: YejiTable, row: YejiRow, kind: 'total' | 'channel') {
if (!yejiPerfCellDrawerEligible(tb, row, kind)) {
return
}
const listTab: 'total' | 'channel' = kind === 'total' ? 'total' : 'channel'
openOrderDrawerByDept(tb, row, { listTab })
}
function onYejiPerformanceTotalRowClick(tb: YejiTable, kind: 'total' | 'channel') {
if (!yejiPerfTotalRowDrawerEligible(tb, kind)) {
return
}
const rootDeptId = selectedDeptIds.value[0]
if (!rootDeptId) {
return
}
const listTab: 'total' | 'channel' = kind === 'total' ? 'total' : 'channel'
openOrderDrawerByYejiTableScope(tb, rootDeptId, listTab)
}
function openOrderDrawerByAssistant(row: LeaderboardPack['leaderboards'][0]['rows'][0], start: string, end: string) {
orderDrawerListTab.value = 'channel'
orderDrawerSplitPerfCache.value = null
@@ -2678,7 +2849,7 @@ function openOrderDrawerByAssistant(row: LeaderboardPack['leaderboards'][0]['row
end,
}
orderDrawerTitle.value = `业务订单 · ${row.name}`
orderDrawerHint.value = `创建时间:${start} ${end} · 诊单医助 · 不含履约已取消`
orderDrawerHint.value = `创建时间:${start} ${end} · 创建人=${row.name}(与排行榜诊金同口径)· 不含履约 4/9/10`
orderDrawerPage.value = 1
orderDrawerVisible.value = true
void fetchOrderDrawerPage()
@@ -2702,7 +2873,7 @@ function openOrderDrawerByAssistantErCenterRevisit(
}
const slotLbl = revisitSlot === 0 ? '复诊合计' : yejiRevisitSlotColumnTitle(revisitSlot)
orderDrawerTitle.value = `业务订单 · ${assistant.name} · ${slotLbl}`
orderDrawerHint.value = `创建时间:${start} ${end} · 诊单医助 · 仅二中心复诊口径 · ${slotLbl} · 不含履约已取消`
orderDrawerHint.value = `创建时间:${start} ${end} · 创建人=${assistant.name}(与部门表 / 排行榜复诊同口径)· 仅二中心复诊口径 · ${slotLbl} · 不含履约 4/9/10`
orderDrawerPage.value = 1
orderDrawerVisible.value = true
void fetchOrderDrawerPage()
@@ -2815,8 +2986,10 @@ async function openYejiRevisitDeptBreakdown(tb: YejiTable, row: YejiRow, revisit
const res: any = await yejiStatsRevisitBreakdown(p as any)
revisitBreakdownRows.value = Array.isArray(res?.rows) ? res.rows : []
revisitBreakdownNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载复诊拆解失败')
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载复诊拆解失败')
revisitBreakdownRows.value = []
revisitBreakdownNote.value = ''
} finally {
@@ -2855,8 +3028,10 @@ async function openUnassignedBreakdown(tb: YejiTable) {
const res: any = await yejiStatsUnassignedBreakdown(p as any)
unassignedDialogRows.value = Array.isArray(res?.rows) ? res.rows : []
unassignedDialogNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载未归属拆解失败')
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载未归属拆解失败')
unassignedDialogRows.value = []
unassignedDialogNote.value = ''
} finally {
@@ -2897,8 +3072,10 @@ async function fetchLeadLinesPage() {
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
leadLinesCount.value = Number(res?.count ?? 0)
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载进线明细失败')
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载进线明细失败')
leadLinesRows.value = []
leadLinesCount.value = 0
leadLinesApiNote.value = ''
@@ -2970,8 +3147,10 @@ async function fetchAppointmentLinesPage() {
appointmentLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
appointmentLinesCount.value = Number(res?.count ?? 0)
appointmentLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载挂号明细失败')
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载挂号明细失败')
appointmentLinesRows.value = []
appointmentLinesCount.value = 0
appointmentLinesApiNote.value = ''
@@ -3946,118 +4125,7 @@ onMounted(async () => {
}
}
/* ── 目标进度卡片 ── */
.tp-wrap {
min-height: 40px;
margin-bottom: 20px;
}
.tp-card {
display: inline-flex;
flex-direction: column;
max-width: 100%;
border-radius: var(--yj-radius);
overflow: hidden;
font-size: 13px;
background: var(--yj-surface);
border: 1px solid var(--yj-line);
box-shadow: var(--yj-shadow-md);
&__title {
background: var(--yj-surface-muted);
color: var(--yj-ink);
font-weight: 700;
font-size: 14px;
text-align: center;
padding: 14px 20px;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--yj-line);
}
&__body {
display: flex;
flex-wrap: wrap;
gap: 0;
}
}
.tp-section {
min-width: 200px;
flex: 1 1 200px;
border-right: 1px solid var(--yj-line);
&:last-child {
border-right: none;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: var(--yj-brand-soft);
padding: 11px 14px;
border-bottom: 1px solid var(--yj-line);
}
&__title-col {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
&__parent {
font-size: 11px;
font-weight: 600;
color: var(--yj-muted);
line-height: 1.25;
}
&__name {
font-weight: 700;
font-size: 13px;
color: var(--yj-ink);
}
&__target {
font-size: 13px;
font-weight: 700;
color: var(--yj-accent);
}
}
.tp-table {
width: 100%;
border-collapse: collapse;
.tp-lbl {
padding: 8px 12px;
color: var(--yj-muted);
font-size: 12px;
border-bottom: 1px solid var(--yj-line);
white-space: nowrap;
}
.tp-val {
padding: 8px 14px;
font-weight: 600;
text-align: right;
border-bottom: 1px solid var(--yj-line);
white-space: nowrap;
color: var(--yj-ink);
font-variant-numeric: tabular-nums;
&--total {
color: var(--yj-accent);
}
}
tr:last-child td {
border-bottom: none;
}
}
/* 目标完成率色条:目标看板(卡片)Tab 复用 */
.tp-good {
color: #15803d !important;
font-weight: 700;
@@ -4071,6 +4139,126 @@ onMounted(async () => {
font-weight: 700;
}
/* 目标看板卡片栅格(与顶部「甄养堂」条带数据同源,8 列铺满) */
.tp-matrix-wrap {
min-height: 120px;
padding: 8px 4px 16px;
}
.tp-matrix__title {
margin: 0 0 18px;
text-align: center;
font-size: 16px;
font-weight: 700;
color: var(--yj-ink);
letter-spacing: 0.02em;
}
.tp-matrix {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 12px;
align-items: stretch;
}
@media (max-width: 1600px) {
.tp-matrix {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.tp-matrix {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.tp-matrix-card {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--yj-line);
border-radius: var(--yj-radius-sm);
background: var(--yj-surface);
box-shadow: var(--yj-shadow-sm);
font-size: 12px;
overflow: hidden;
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
background: var(--yj-brand-soft);
border-bottom: 1px solid var(--yj-line);
}
&__titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__parent {
font-size: 11px;
font-weight: 600;
color: var(--yj-muted);
line-height: 1.2;
}
&__name {
font-weight: 700;
font-size: 12px;
color: var(--yj-ink);
line-height: 1.35;
}
&__target {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
color: var(--yj-accent);
font-variant-numeric: tabular-nums;
}
&__table {
width: 100%;
border-collapse: collapse;
}
&__lbl {
padding: 7px 10px;
color: var(--yj-muted);
font-size: 11px;
border-bottom: 1px solid var(--yj-line);
white-space: nowrap;
}
&__num {
padding: 7px 10px;
font-weight: 600;
text-align: right;
border-bottom: 1px solid var(--yj-line);
color: var(--yj-ink);
font-variant-numeric: tabular-nums;
&--hot {
color: var(--yj-accent);
font-weight: 700;
}
}
tr:last-child td {
border-bottom: none;
}
}
.tp-matrix-empty {
padding: 32px 16px;
}
.leaderboard-sub {
font-size: 13px;
font-weight: 500;
@@ -4640,6 +4828,10 @@ tbody tr.yeji-row--action:hover .yeji-lead-cell--link {
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
}
tbody tr.total-row:hover .yeji-lead-cell--link {
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
}
.yeji-leadlines-dialog__pager {
margin-top: 14px;
display: flex;
File diff suppressed because it is too large Load Diff
+72 -5
View File
@@ -49,15 +49,30 @@
v-model="form.channel_source"
placeholder="请选择渠道来源"
class="channel-source-select"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="item.value"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="needsChannelSourceDetail"
label="自媒体补充:"
required
>
<el-input
v-model="form.channel_source_detail"
clearable
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
class="channel-source-select"
/>
</el-form-item>
<!-- 预约医生 -->
<el-form-item label="预约医生:">
<div class="doctor-list">
@@ -233,9 +248,44 @@ const form = reactive({
date: '',
appointmentTime: '',
remark: '',
channel_source: '' as string
channel_source: '' as string,
channel_source_detail: '' as string
})
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
/** 当前选中渠道字典名称(用于判断是否需填「自媒体补充」) */
const selectedChannelDictName = computed(() => {
const v = form.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: any) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
/** 仅白名单内字典项需填写补充说明 */
const needsChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(selectedChannelDictName.value)
)
const onChannelSourceChange = (val: string) => {
const row = channelOptions.value.find((item: any) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
form.channel_source_detail = ''
}
}
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
const dateOptions = computed<DateOption[]>(() => {
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
@@ -321,7 +371,8 @@ const canSubmit = computed(() => {
selectedDoctorId.value &&
form.date &&
form.appointmentTime &&
!!form.channel_source
!!form.channel_source &&
(!needsChannelSourceDetail.value || form.channel_source_detail.trim() !== '')
)
})
@@ -344,6 +395,7 @@ const open = async (patient: any) => {
form.appointmentTime = ''
form.remark = ''
form.channel_source = ''
form.channel_source_detail = ''
timeSlots.value = []
doctorRosterDates.value = []
lastVisit.value = ''
@@ -412,7 +464,17 @@ const loadLastVisit = async () => {
const loadChannelOptions = async () => {
try {
const data = await getDictData({ type: 'channels' })
channelOptions.value = (data?.channels || []).filter((row: any) => row.status !== 0)
const rows = (data?.channels || []).filter((row: any) => row.status !== 0)
rows.sort((a: any, b: any) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
// 统一为字符串,避免字典 value 为数字时 el-select 与表单校验不一致导致看似选了但实际未绑定
channelOptions.value = rows.map((row: any) => ({
...row,
value: row?.value != null && row.value !== '' ? String(row.value) : ''
}))
} catch (e) {
console.error('加载渠道来源失败:', e)
channelOptions.value = []
@@ -612,6 +674,10 @@ const handleConfirm = async () => {
feedback.msgWarning('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
feedback.msgWarning('请填写自媒体渠道补充信息')
return
}
if (blockedByTodayDuplicate.value) {
feedback.msgWarning(
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
@@ -631,7 +697,8 @@ const handleConfirm = async () => {
appointment_time: form.appointmentTime,
appointment_type: form.appointmentType,
remark: form.remark,
channel_source: form.channel_source
channel_source: String(form.channel_source ?? ''),
channel_source_detail: form.channel_source_detail.trim()
}
console.log('提交预约参数:', params)
@@ -105,7 +105,9 @@ function channelLabel(row: Record<string, any>) {
const raw = row.channel_source ?? row.channels ?? ''
if (raw === '' || raw === null || raw === undefined) return '—'
const key = String(raw)
return channelMap.value[key] || key
const base = channelMap.value[key] || key
const extra = String(row.channel_source_detail ?? '').trim()
return extra !== '' ? `${base}${extra}` : base
}
function sortRows(list: any[]) {
@@ -141,6 +143,8 @@ const load = async () => {
await loadChannels()
const res = await appointmentLists({
patient_id: props.diagnosisId,
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
diag_scope_relax: 1,
page_no: 1,
page_size: 500
})
@@ -1,6 +1,6 @@
<template>
<div class="call-record-panel">
<div class="call-record-toolbar">
<div v-if="!readOnly" class="call-record-toolbar">
<upload
ref="toolbarUploadRef"
type="video"
@@ -19,10 +19,19 @@
<div class="call-record-empty">
<div class="call-record-empty__title">暂无通话记录</div>
<div class="call-record-empty__desc">
现在可以直接点击上方上传视频系统会自动生成一条默认通话记录来承载回放
{{
readOnly
? '暂无录制回放数据。'
: '现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。'
}}
</div>
</div>
</template>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
</template>
</el-table-column>
<el-table-column label="开始时间" width="170" prop="start_time_text" />
<el-table-column label="结束时间" width="170" prop="end_time_text" />
<el-table-column label="通话类型" width="100">
@@ -47,7 +56,7 @@
{{ row.recording_status_text || '—' }}
</template>
</el-table-column>
<el-table-column label="上传回放" width="180">
<el-table-column v-if="!readOnly" label="上传回放" width="180">
<template #default="{ row }">
<upload
type="video"
@@ -61,34 +70,12 @@
</upload>
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
<div
v-for="(url, idx) in row.recording_urls_list"
:key="`${row.id}-${idx}`"
class="recording-item"
>
<video
v-if="isPlayableVideo(url)"
:src="url"
controls
preload="metadata"
class="recording-video"
/>
<el-link v-else :href="url" target="_blank" type="primary">
打开链接 {{ idx + 1 }}
</el-link>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
@@ -106,9 +93,14 @@ type CallRecordRow = {
duration_text?: string
}
const props = defineProps<{
diagnosisId: number
}>()
const props = withDefaults(
defineProps<{
diagnosisId: number
/** 只读场景(如诊单只读详情):不展示任何上传入口 */
readOnly?: boolean
}>(),
{ readOnly: false }
)
const loading = ref(false)
const rows = ref<CallRecordRow[]>([])
@@ -147,11 +139,6 @@ function statusText(status: number) {
return m[status] ?? '—'
}
function isPlayableVideo(url: string) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg|mov|mkv)(\?|$)/i.test(url)
}
async function handleToolbarUploadSuccess(response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
@@ -223,24 +210,5 @@ function normalizeUploadUrl(response: any): string {
font-size: 12px;
line-height: 1.6;
}
.recording-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.recording-video {
max-width: 100%;
max-height: 180px;
border-radius: 4px;
background: #000;
}
}
</style>
@@ -683,7 +683,7 @@ const bloodTrendOption = computed(() => ({
name: '空腹血糖',
type: 'line',
smooth: true,
connectNulls: false,
connectNulls: true,
data: bloodTrendSeries.value.fasting,
symbolSize: 7,
itemStyle: {
@@ -698,7 +698,7 @@ const bloodTrendOption = computed(() => ({
name: '餐后血糖',
type: 'line',
smooth: true,
connectNulls: false,
connectNulls: true,
data: bloodTrendSeries.value.postprandial,
symbolSize: 7,
itemStyle: {
@@ -719,7 +719,8 @@ function formatColumnDate(date: string) {
function parseTrendNumber(value: unknown): number | null {
if (value === '' || value == null) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
if (!Number.isFinite(parsed) || parsed === 0) return null
return parsed
}
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean } {
@@ -3,7 +3,7 @@
<div class="toolbar">
<el-button
type="primary"
:disabled="!props.diagnosisId || !!props.readOnly || !hasDailyRecordPermission"
:disabled="!props.diagnosisId || !hasDailyRecordPermission"
@click="handleAdd"
v-perms="DAILY_RECORD_PERMS"
>
@@ -226,7 +226,7 @@ const disablePastDate = (date: Date) => {
}
const handleAdd = () => {
if (!props.diagnosisId || props.readOnly || !hasDailyRecordPermission.value) {
if (!props.diagnosisId || !hasDailyRecordPermission.value) {
feedback.msgWarning('诊单未保存,无法添加待办')
return
}
@@ -39,19 +39,313 @@
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['tcm.prescriptionOrder/detail']"
type="primary"
link
@click="openDetail(row.id)"
>查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<pagination v-model="pager" @change="getLists" />
</div>
</template>
<!-- 业务订单详情抽屉只读 order_list.vue 详情保持一致 -->
<el-drawer
v-model="detailVisible"
size="80%"
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<template #header>
<div class="flex items-center">
<span class="font-semibold text-lg">业务订单详情</span>
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
</template>
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
<!-- 顶部数据概览 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">总金额</div>
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付总额</div>
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">需代收</div>
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单不含测试价格改患者信息药材明细 -->
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">处方详情</span>
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
开方人<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
</span>
</div>
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
</div>
</template>
<template v-if="detailPrescription">
<el-descriptions :column="2" border size="small" class="bg-white">
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
</el-descriptions-item>
<el-descriptions-item label="处方金额">
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="消费者审核">
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
</div>
<div>
<span class="text-gray-500">订单设置</span>
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="无处方数据" :image-size="72" />
</el-card>
<!-- 右侧未关联 + 已关联支付单 -->
<div class="flex flex-col gap-4 h-full">
<el-card shadow="never" class="border-gray-100">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-[15px]">未关联的收款记录</span>
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
</div>
</template>
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" 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-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="无未关联的收款单" :image-size="60" />
</el-card>
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">关联收款记录</span>
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
</div>
</template>
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" 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-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
</div>
</div>
<!-- 履约与收货信息 -->
<el-card shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="业务订单创建人" :span="2">
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="创建人所属部门" :span="3">
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
<el-descriptions-item label="收货手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 物流轨迹只读 -->
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" 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" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
</div>
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
<span class="text-gray-800">状态{{ logisticsTracePayload.state_text }}</span>
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
</div>
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
</div>
</el-card>
<!-- 操作日志 -->
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
</div>
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, watch, ref } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm'
import {
prescriptionOrderLists,
prescriptionOrderDetail,
prescriptionOrderLogs,
prescriptionOrderLogisticsTrace,
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
import useUserStore from '@/stores/modules/user'
const userStore = useUserStore()
const props = defineProps<{
diagnosisId: number
@@ -83,11 +377,171 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
// ─── 详情抽屉 ───
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
return p && typeof p === 'object' ? p : null
})
const detailLinkedPayOrders = computed(() => {
const arr = detailData.value?.linked_pay_orders
return Array.isArray(arr) ? arr : []
})
const detailAgencyToCollect = computed(() => {
const d = detailData.value
if (!d) return 0
const total = Number(d.amount) || 0
const paid = Number(d.linked_pay_paid_total) || 0
return Math.round((total - paid) * 100) / 100
})
const detailRecipientRxPhoneMismatch = computed(() => {
const rx = normalizeBizPhone(detailPrescription.value?.phone)
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
if (!rx || !biz) return false
return rx !== biz
})
const detailFullAddress = computed(() => {
const d = detailData.value
if (!d) return '—'
const parts = []
if (d.shipping_province) parts.push(d.shipping_province)
if (d.shipping_city) parts.push(d.shipping_city)
if (d.shipping_district) parts.push(d.shipping_district)
if (d.shipping_address) parts.push(d.shipping_address)
return parts.length > 0 ? parts.join(' ') : '—'
})
const detailOrderCreatorMetaLine = computed(() => {
const d = detailData.value
if (!d) return ''
const account = String(d.creator_account ?? '').trim()
const mobile = String(d.creator_mobile ?? '').trim()
const parts: string[] = []
if (account) parts.push(`登录账号:${account}`)
if (mobile) parts.push(`手机:${mobile}`)
return parts.join(' · ')
})
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
if (!raw) return [] as string[][]
return raw.split('').map((s) => s.trim()).filter(Boolean)
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
.filter((segs) => segs.length > 0)
})
// ─── 物流轨迹 ───
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const logisticsTraceList = computed(() => {
const p = logisticsTracePayload.value
if (!p || !Array.isArray(p.traces)) return []
return p.traces as Array<{ time: string; context: string }>
})
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
logisticsTraceLoading.value = true
try {
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
} finally {
logisticsTraceLoading.value = false
}
}
// ─── 打开详情 ───
async function openDetail(id: number) {
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
detailVisible.value = true
detailLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id })
const d = res?.data ?? res ?? null
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()
fetchLogs(id)
if (d.diagnosis_id) {
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
}
}
} catch (e: any) {
feedback.msgError(e?.message || '加载详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
try {
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
const lists = res?.lists ?? res?.data?.lists ?? []
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
} catch { detailUnlinkedPayOrders.value = [] }
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
}
async function fetchLogs(id: number) {
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
try {
const res: any = await prescriptionOrderLogs({ id })
detailLogs.value = res?.data ?? res ?? []
} catch { detailLogs.value = [] }
}
// ─── 工具函数 ───
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
const formatAmount = (value: unknown) => {
const n = Number(value)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
function formatMoney(v: unknown) {
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const formatTime = (value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
@@ -98,21 +552,53 @@ const formatTime = (value: unknown) => {
return String(value)
}
function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
function feeTypeText(t: number | undefined) {
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
return m[Number(t)] ?? '—'
}
function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
function orderStatusText(s: number | undefined) {
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
return m[Number(s)] ?? '—'
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
}
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
return m[Number(s)] ?? '—'
}
@@ -126,14 +612,22 @@ const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'dan
return 'info'
}
function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', complete: '完成订单'
}
return m[act] || act
}
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
buildParams()
resetPage()
},
@@ -147,8 +641,25 @@ defineExpose({ refresh: () => getLists() })
.patient-order-list {
padding: 4px 0;
}
.po-empty-tip {
padding: 24px 0;
}
.audit-stamp {
position: absolute;
top: 12px;
right: 16px;
transform: rotate(15deg);
opacity: 0.15;
pointer-events: none;
z-index: 1;
}
.stamp-inner {
border: 3px solid currentColor;
border-radius: 8px;
padding: 4px 12px;
font-size: 18px;
font-weight: bold;
}
.stamp-pass { color: #22c55e; }
.stamp-reject { color: #ef4444; }
</style>
@@ -0,0 +1,152 @@
<template>
<div v-if="normalized.length" class="recording-list">
<div class="recording-item">
<template v-if="preferred">
<RecordingVideoPlayer
v-if="shouldInlineRecording(preferred)"
:key="`${recordId}-${preferred}`"
:src="preferred"
/>
<el-link v-else :href="resolveRecordingLink(preferred)" target="_blank" type="primary">
打开回放
</el-link>
</template>
<div v-if="alternates.length" class="recording-alternates">
<span class="recording-alternates__label">备用地址</span>
<div class="recording-alternates__links">
<el-link
v-for="(url, linkIndex) in alternates"
:key="`${recordId}-alt-${linkIndex}-${url.slice(-32)}`"
:href="resolveRecordingLink(url)"
target="_blank"
type="primary"
class="recording-alternates__link"
>
{{ alternateLinkLabel(url, linkIndex) }}
</el-link>
</div>
</div>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
<script setup lang="ts">
import RecordingVideoPlayer from './RecordingVideoPlayer.vue'
import useAppStore from '@/stores/modules/app'
import { computed } from 'vue'
const props = defineProps<{
recordId: number
/** getCallRecords 返回的 recording_urls_list(可多来源:COS/VOD、m3u8/mp4 */
urls?: string[]
}>()
const appStore = useAppStore()
/** 去重、trim */
const normalized = computed(() => {
const raw = props.urls || []
const seen = new Set<string>()
const out: string[] = []
for (const u of raw) {
const s = String(u ?? '').trim()
if (!s || seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
})
/** 优选一条内嵌:mp4 > 点播 m3u8 > COS m3u8 > 其它 m3u8 > 首个 */
const preferred = computed((): string | null => {
const list = normalized.value
if (!list.length) return null
const mp4 = list.find((u) => /\.mp4(\?|#|$)/i.test(u))
if (mp4) return mp4
const vodHls = list.find(
(u) => /\.m3u8(\?|#|$)/i.test(u) && /vod-qcloud\.com/i.test(u)
)
if (vodHls) return vodHls
const cosHls = list.find(
(u) =>
/\.m3u8(\?|#|$)/i.test(u) && /\.cos\.[^/]+\.myqcloud\.com/i.test(u)
)
if (cosHls) return cosHls
const anyHls = list.find((u) => /\.m3u8(\?|#|$)/i.test(u))
if (anyHls) return anyHls
return list[0]
})
const alternates = computed(() => {
const list = normalized.value
const p = preferred.value
if (!p) return list.slice(1)
return list.filter((u) => u !== p)
})
function shouldInlineRecording(url: string) {
if (!url || typeof url !== 'string') return false
const u = url.trim()
if (/^https?:\/\//i.test(u)) {
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u)
}
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u) || u.startsWith('/uploads/')
}
function resolveRecordingLink(url: string) {
return appStore.getImageUrl(String(url || '').trim())
}
function alternateLinkLabel(url: string, index: number): string {
const u = url.trim()
if (/\.mp4(\?|#|$)/i.test(u)) return `MP4 ${index + 1}`
if (/vod-qcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `点播 ${index + 1}`
if (/\.cos\.[^/]+\.myqcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `COS HLS ${index + 1}`
if (/\.m3u8/i.test(u)) return `HLS ${index + 1}`
return `链接 ${index + 1}`
}
</script>
<style scoped lang="scss">
.recording-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 6px;
transform: translateZ(0);
}
.recording-alternates {
margin-top: 4px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.recording-alternates__label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.recording-alternates__links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.recording-alternates__link {
font-size: 12px;
}
</style>
@@ -0,0 +1,379 @@
<template>
<div ref="wrapRef" class="recording-playback">
<div class="recording-playback__shell">
<video
v-show="!failed"
ref="videoRef"
class="recording-video"
controls
playsinline
webkit-playsinline
:preload="progressivePreload"
@error="onVideoError"
/>
<div
v-if="!failed && showBusyOverlay"
class="recording-playback__overlay"
role="button"
tabindex="0"
@click="forceEnable"
@keydown.enter.prevent="forceEnable"
>
<template v-if="!loadEnabled">
<span class="recording-playback__overlay-title">预览待加载</span>
<span class="recording-playback__overlay-desc">滚动至此处将自动加载减轻并发卡顿也可点击立即加载</span>
</template>
<template v-else>
<el-icon class="recording-playback__spin"><Loading /></el-icon>
<span>正在加载</span>
</template>
</div>
</div>
<template v-if="failed">
<el-link :href="playbackUrl || '#'" target="_blank" type="primary">在新窗口打开</el-link>
<div class="recording-playback__hint">
内嵌播放失败编码不支持跨域或私有存储等时可尝试在新窗口打开
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { Loading } from '@element-plus/icons-vue'
import useAppStore from '@/stores/modules/app'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
src: string
}>()
const appStore = useAppStore()
const wrapRef = ref<HTMLElement | null>(null)
const videoRef = ref<HTMLVideoElement | null>(null)
const failed = ref(false)
/** 已进入可视区域或用户点击,允许发起请求 */
const loadEnabled = ref(false)
/** 已开始拉流但未就绪 */
const loadingMedia = ref(false)
let hlsInstance: import('hls.js').default | null = null
let intersectionObserver: IntersectionObserver | null = null
/** 防止 tearDown / 上一次 attach 的异步事件误结束 loading */
let attachGeneration = 0
let loadingSlowTimer: ReturnType<typeof setTimeout> | null = null
function clearLoadingSlowTimer(): void {
if (loadingSlowTimer !== null) {
clearTimeout(loadingSlowTimer)
loadingSlowTimer = null
}
}
const playbackUrl = computed(() => {
const raw = String(props.src || '').trim()
if (!raw) return ''
return normalizeTencentRecordingUrl(appStore.getImageUrl(raw))
})
/** COS/VOD 直连 mp4:允许浏览器优先拉元数据;HLS 仍 none,交给 hls.js */
const progressivePreload = computed(() => {
if (!loadEnabled.value) return 'none'
const u = playbackUrl.value
return u && !isHlsUrl(u) ? 'metadata' : 'none'
})
const showBusyOverlay = computed(() => !loadEnabled.value || loadingMedia.value)
/** 腾讯云录制路径里常见未编码的 “+”,部分环境下会导致视频长时间拿不到元数据 */
function normalizeTencentRecordingUrl(url: string): string {
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const u = new URL(url)
const h = u.hostname.toLowerCase()
if (
h.includes('myqcloud.com') ||
h.includes('vod-qcloud.com') ||
h.includes('vod-qcloud')
) {
if (u.pathname.includes('+')) {
u.pathname = u.pathname.replace(/\+/g, '%2B')
}
return u.href
}
} catch {
/* ignore */
}
return url
}
function isHlsUrl(url: string): boolean {
return /\.m3u8(\?|#|$)/i.test(url)
}
function onVideoError(): void {
loadingMedia.value = false
clearLoadingSlowTimer()
const el = videoRef.value
const code = el?.error?.code
if (code === MediaError.MEDIA_ERR_ABORTED || code === 1) {
return
}
failed.value = true
}
function tearDown(): void {
if (hlsInstance) {
hlsInstance.destroy()
hlsInstance = null
}
const el = videoRef.value
if (el) {
el.pause()
el.removeAttribute('src')
el.load()
}
}
function bindProgressiveLoadingEnd(el: HTMLVideoElement, gen: number): void {
let settled = false
const done = () => {
if (settled || gen !== attachGeneration) return
settled = true
clearLoadingSlowTimer()
loadingMedia.value = false
}
el.addEventListener('loadedmetadata', () => done('loadedmetadata'), { once: true })
el.addEventListener('loadeddata', () => done('loadeddata'), { once: true })
el.addEventListener('canplay', () => done('canplay'), { once: true })
el.addEventListener('playing', () => done('playing'), { once: true })
el.addEventListener(
'progress',
() => {
try {
if (el.buffered.length > 0 && el.buffered.end(0) > 0) {
done('progress-buffered')
}
} catch {
/* ignore */
}
},
{ passive: true }
)
}
async function attachSource(): Promise<void> {
if (!loadEnabled.value) return
await nextTick()
const el = videoRef.value
const url = playbackUrl.value
if (!el) return
if (!url) {
loadingMedia.value = false
clearLoadingSlowTimer()
return
}
const gen = ++attachGeneration
loadingMedia.value = true
clearLoadingSlowTimer()
tearDown()
failed.value = false
if (isHlsUrl(url)) {
try {
const mod = await import('hls.js')
const Hls = mod.default
if (Hls.isSupported()) {
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: false,
// 列表里多条回放时减小初始缓冲压力,首帧稍快露出
maxBufferLength: 20,
maxMaxBufferLength: 120
})
hlsInstance.on(mod.Events.MANIFEST_PARSED, () => {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
})
hlsInstance.on(mod.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
tearDown()
}
})
hlsInstance.loadSource(url)
hlsInstance.attachMedia(el)
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 45000)
return
}
if (el.canPlayType('application/vnd.apple.mpegurl')) {
bindProgressiveLoadingEnd(el, gen)
el.src = url
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 45000)
return
}
} catch {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
return
}
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
return
}
bindProgressiveLoadingEnd(el, gen)
el.src = url
// moov 在尾部的大 mp4 可能迟迟不触发 loadedmetadata;超时后收起遮罩以免挡住原生控件
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 25000)
}
function setupLazyLoad(): void {
const root = wrapRef.value
if (!root || typeof IntersectionObserver === 'undefined') {
loadEnabled.value = true
void attachSource()
return
}
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (en.isIntersecting) {
intersectionObserver?.disconnect()
intersectionObserver = null
loadEnabled.value = true
void attachSource()
break
}
}
},
{ root: null, rootMargin: '240px 0px', threshold: 0 }
)
intersectionObserver.observe(root)
}
function forceEnable(): void {
intersectionObserver?.disconnect()
intersectionObserver = null
if (!loadEnabled.value) {
loadEnabled.value = true
}
void attachSource()
}
onMounted(() => {
void nextTick(() => {
setupLazyLoad()
})
})
watch(playbackUrl, () => {
if (!loadEnabled.value) return
void attachSource()
})
onUnmounted(() => {
intersectionObserver?.disconnect()
intersectionObserver = null
clearLoadingSlowTimer()
attachGeneration++
tearDown()
})
</script>
<style scoped lang="scss">
.recording-playback {
width: 100%;
min-height: 1px;
}
.recording-playback__shell {
position: relative;
width: 100%;
}
.recording-video {
display: block;
width: 100%;
max-width: 100%;
max-height: 180px;
height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
border-radius: 4px;
background: #000;
transform: translateZ(0);
}
.recording-playback__overlay {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 12px;
line-height: 1.45;
text-align: center;
cursor: pointer;
user-select: none;
}
.recording-playback__overlay-title {
font-weight: 600;
}
.recording-playback__overlay-desc {
opacity: 0.92;
font-size: 11px;
}
.recording-playback__spin {
font-size: 24px;
animation: recording-spin 0.9s linear infinite;
}
@keyframes recording-spin {
to {
transform: rotate(360deg);
}
}
.recording-playback__hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>
+34 -15
View File
@@ -151,12 +151,14 @@
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="ID" min-width="50">
<el-table-column label="ID" min-width="70">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
<div class="patient-name">
{{ row.id }}
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
</div>
</div>
</div>
</template>
@@ -795,6 +797,32 @@ function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Reco
}
}
/**
* 待分配「订单月份」:未选月份时,未进入 Tab 前默认当月(与 handlePendingAssignTabClick 一致);
* 在 Tab 内清空月份表示不限月份。
*/
function resolvePendingAssignOrderMonthForRequest(): string {
const trimmed = String(formData.pending_assign_order_month ?? '').trim()
if (trimmed !== '') return trimmed
if (formData.pending_assign === '1') return ''
return dayjs().format('YYYY-MM')
}
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
function buildPendingAssignCountPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload({
...formData,
page_no: 1,
page_size: 1,
pending_assign: 1,
appointment_date: '',
has_appointment: '',
pending_booking: '',
completed_appointment: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
} as Record<string, unknown>) as Record<string, unknown>
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
@@ -923,16 +951,7 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists(
buildTcmDiagnosisListRequestPayload({
pending_assign: 1,
page_no: 1,
page_size: 1,
pending_assign_order_month: formData.pending_assign_order_month,
pending_assign_keyword: formData.pending_assign_keyword,
keyword: formData.keyword
}) as any
)
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -960,7 +979,7 @@ function scheduleDeferredDateCounts() {
}
}
const SILENT_LIST_POLL_MS = 10_000
const SILENT_LIST_POLL_MS = 20_000
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
@@ -1352,7 +1371,7 @@ const handleSingleAssign = (row: any) => {
currentRow.value = row
batchAssignIds.value = []
// 确保类型一致,转换为数字
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
@@ -130,6 +130,7 @@
</div>
<div class="dh5-card-sub">
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
<span v-if="row.assign_read_at === null && row.assistant_id > 0" class="dh5-tag-new">NEW</span>
</div>
</div>
<div class="dh5-card-tags">
@@ -1672,6 +1673,18 @@ $dh5-card-bg: #ffffff;
flex-wrap: wrap;
}
.dh5-tag-new {
display: inline-block;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
margin-left: 4px;
vertical-align: middle;
}
.dh5-meta-gender,
.dh5-meta-age {
font-size: 12px;
+144 -12
View File
@@ -40,23 +40,110 @@
<PatientCaseCard :apt="apt" :diag="diag" />
<!-- 日常记录只读 -->
<div class="card tracking-card">
<div v-if="hasDailyRecordPermission" class="card tracking-card">
<div class="card-title">日常记录</div>
<DailyMatrix
:diagnosis-id="Number(diagnosisIdNum)"
:patient-id="Number(diag?.patient_id ?? 0)"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
<div class="card-body">
<DailyMatrix
:diagnosis-id="diagnosisNumericId"
:patient-id="patientNumericId"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
</div>
</div>
<!-- 医生备注复用 reception NoteTimeline只读 -->
<div v-if="doctorNotes.length" class="card note-card">
<!-- 医生备注舌苔照片检查报告只读与编辑页医生备注同源 -->
<div class="card note-card">
<div class="card-title">医生备注 & 舌苔照片 & 检查报告</div>
<NoteTimeline :notes="doctorNotes" :diagnosis-id="diag?.id" />
<div class="card-body card-body--matrix-align">
<el-empty v-if="diagnosisNumericId <= 0" description="诊单数据不完整" />
<el-empty
v-else-if="!doctorNotesLocal.length"
description="暂无医生备注"
/>
<NoteTimeline
v-else
:notes="doctorNotesLocal"
:diagnosis-id="diagnosisNumericId"
:readonly="true"
/>
</div>
</div>
<!-- 业务订单 -->
<div
v-if="hasPermission(['tcm.diagnosis/patientOrders'])"
class="card section-card"
>
<div class="card-title">业务订单</div>
<div class="card-body card-body--matrix-align">
<patient-order-list
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
:patient-id="patientNumericId || 0"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 视频录制回放记录 -->
<div v-if="hasPermission(['tcm.diagnosis/huifang'])" class="card section-card">
<div class="card-title">视频录制回放记录</div>
<div class="card-body card-body--matrix-align">
<call-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
:read-only="true"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 聊天记录 -->
<div v-if="hasPermission(['tcm.diagnosis/chat'])" class="card section-card">
<div class="card-title">聊天记录</div>
<div class="card-body card-body--matrix-align">
<im-chat-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 指派医助记录 -->
<div
v-if="
hasPermission(['tcm.diagnosis/assign']) ||
hasPermission(['tcm.diagnosis/detail'])
"
class="card section-card"
>
<div class="card-title">指派医助记录</div>
<div class="card-body card-body--matrix-align">
<assign-log-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 挂号记录 -->
<div
v-if="hasPermission(['doctor.appointment/lists'])"
class="card section-card"
>
<div class="card-title">挂号记录</div>
<div class="card-body card-body--matrix-align">
<appointment-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
</template>
</div>
</template>
@@ -66,10 +153,17 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { diagnosisReadonlyDetail } from '@/api/tcm'
import { getDoctorNotes } from '@/api/patient'
import { formatGender } from '@/utils/diag-display'
import { hasPermission } from '@/utils/perm'
import PatientInfoCard from './components/PatientInfoCard.vue'
import PatientCaseCard from './components/PatientCaseCard.vue'
import DailyMatrix from './components/DailyMatrix.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
import PatientOrderList from './components/PatientOrderList.vue'
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
const route = useRoute()
@@ -80,10 +174,19 @@ const diagnosisIdNum = computed(() => Number(route.query.id || 0))
const loading = ref(false)
const errorMsg = ref('')
const detail = ref<any>(null)
/** 医生备注:readonlyDetail 返回 + getDoctorNotes 对齐接诊台结构 */
const doctorNotesLocal = ref<any[]>([])
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const diagnosisNumericId = computed(() =>
Number(diag.value?.id ?? diagnosisIdNum.value ?? 0)
)
const patientNumericId = computed(() => Number(diag.value?.patient_id ?? 0))
const unservedDays = computed<number | null>(() => {
const v = detail.value?.unserved_days
return v === null || v === undefined ? null : Number(v)
@@ -103,11 +206,23 @@ const unservedDaysClass = (n: unknown) => {
return 'unserved-green'
}
const fetchDoctorNotes = async () => {
const id = diagnosisNumericId.value
if (!id) return
try {
const data = (await getDoctorNotes({ diagnosis_id: id })) || []
doctorNotesLocal.value = data
} catch {
/* 失败时保留 readonlyDetail 已下发的 doctor_notes,避免整块备注空白 */
}
}
const fetchDetail = async () => {
const id = diagnosisIdNum.value
if (!id || id <= 0) {
errorMsg.value = '诊单 id 缺失'
detail.value = null
doctorNotesLocal.value = []
return
}
loading.value = true
@@ -115,9 +230,12 @@ const fetchDetail = async () => {
try {
const res: any = await diagnosisReadonlyDetail({ id })
detail.value = res || null
doctorNotesLocal.value = Array.isArray(res?.doctor_notes) ? res.doctor_notes : []
await fetchDoctorNotes()
} catch (e: any) {
errorMsg.value = e?.message || ''
detail.value = null
doctorNotesLocal.value = []
} finally {
loading.value = false
}
@@ -276,4 +394,18 @@ onMounted(fetchDetail)
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.card-body {
min-width: 0;
}
/* 与 DailyMatrix 根节点 .daily-matrix { padding: 16px } 对齐,使下方区块与「日常记录 / 待办」主内容区左右留白一致 */
.card-body--matrix-align {
padding: 16px;
box-sizing: border-box;
}
.section-card .card-title {
margin-bottom: 0;
}
</style>
+6 -4
View File
@@ -97,12 +97,14 @@
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="ID" min-width="50">
<el-table-column label="ID" min-width="70">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
<div class="patient-name">
{{ row.id }}
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
</div>
</div>
</div>
</template>
@@ -1088,7 +1090,7 @@ const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
//
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
File diff suppressed because one or more lines are too long
+16 -2
View File
@@ -6,7 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
return stylePaths
}
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const rawBase = (env.VITE_APP_BASE_URL || '').trim().replace(/\/+$/, '')
const uploadsProxyTarget =
rawBase.startsWith('http://') || rawBase.startsWith('https://')
? rawBase
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
'/uploads': {
target: uploadsProxyTarget,
changeOrigin: true
},
'/api-proxy': {
target: 'https://admin.zhenyangtang.com.cn',
changeOrigin: true,
@@ -112,4 +125,5 @@ export default defineConfig({
__VUE_PROD_HYDRATION__: true,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
}
}
})
+1
View File
@@ -0,0 +1 @@
open_basedir=/www/wwwroot/zyt/server/:/tmp/
@@ -64,6 +64,40 @@ class AppointmentController extends BaseAdminController
return $this->dataLists(new AppointmentLists());
}
/**
* 后台编辑挂号记录(消费者处方-挂号列表等,perms: doctor.appointment/edit
*/
public function edit()
{
$params = (new AppointmentValidate())->post()->goCheck('adminEdit');
$post = $this->request->post();
if (array_key_exists('assistant_id', $post)) {
$params['assistant_id'] = $post['assistant_id'];
}
$result = AppointmentLogic::adminEdit($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
}
return $this->success('保存成功');
}
/** 批量修改挂号渠道来源(消费者处方-挂号列表) */
public function batchEditChannel()
{
$params = (new AppointmentValidate())->post()->goCheck('batchEditChannel');
$post = $this->request->post();
if (\array_key_exists('channel_source_detail', $post)) {
$params['channel_source_detail'] = $post['channel_source_detail'];
}
$result = AppointmentLogic::adminBatchEditChannel($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
}
return $this->success((string) ($result['msg'] ?? '操作成功'), $result);
}
/**
* @notes 预约详情
* @return \think\response\Json
@@ -557,6 +557,24 @@ class OrderController extends BaseAdminController
}
}
public function setExempt()
{
$id = (int) $this->request->post('id', 0);
$isExempt = (int) $this->request->post('is_exempt', 0);
if ($id <= 0) {
return $this->fail('参数错误');
}
if (!in_array($isExempt, [0, 1], true)) {
return $this->fail('is_exempt 值无效');
}
$result = OrderLogic::setExempt($id, $isExempt);
if (!$result) {
return $this->fail(OrderLogic::getError());
}
$this->logOrderAction($id, 'set_exempt', $isExempt === 1 ? '设置豁免权' : '取消豁免权');
return $this->success($isExempt === 1 ? '已设置豁免权' : '已取消豁免权');
}
private function logOrderAction(int $orderId, string $action, string $summary = ''): void
{
OrderActionLogLogic::record($orderId, (int) $this->adminId, $this->adminInfo, $action, $summary);
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\YejiStatsLogic;
/**
* 提成结算业绩(独立于业绩看板 yejiStats 接口)
*
* - GET stats.commissionSettlement/overview
* - GET stats.commissionSettlement/orderLines
* - GET stats.commissionSettlement/confirmStatus
* - POST stats.commissionSettlement/saveReconcile
* - POST stats.commissionSettlement/confirmFinalize
* - POST stats.commissionSettlement/confirmRevoke
* - GET stats.commissionSettlement/deptOptions
* - GET stats.commissionSettlement/channelOptions
*/
class CommissionSettlementController extends BaseAdminController
{
public function overview()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::commissionSettlementOverview($params, $this->adminId, $this->adminInfo));
}
public function orderLines()
{
@set_time_limit(120);
return $this->data(YejiStatsLogic::commissionSettlementOrderLines($this->request->get(), $this->adminId, $this->adminInfo));
}
public function confirmStatus()
{
return $this->data(YejiStatsLogic::commissionSettlementConfirmStatus($this->request->get(), $this->adminId, $this->adminInfo));
}
public function saveReconcile()
{
return $this->data(YejiStatsLogic::commissionSettlementSaveReconcile($this->request->post(), $this->adminId, $this->adminInfo));
}
public function confirmFinalize()
{
@set_time_limit(120);
return $this->data(YejiStatsLogic::commissionSettlementConfirmFinalize($this->request->post(), $this->adminId, $this->adminInfo));
}
public function confirmRevoke()
{
return $this->data(YejiStatsLogic::commissionSettlementConfirmRevoke($this->request->post(), $this->adminId, $this->adminInfo));
}
public function deptOptions()
{
return $this->data(YejiStatsLogic::deptOptions($this->adminId, $this->adminInfo));
}
public function channelOptions()
{
return $this->data(YejiStatsLogic::channelOptions(false));
}
}
@@ -117,6 +117,7 @@ class DiagnosisController extends BaseAdminController
$params = (new DiagnosisValidate())->goCheck('id');
$result = DiagnosisLogic::detail($params);
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
return $this->data($result);
}
@@ -135,7 +136,7 @@ class DiagnosisController extends BaseAdminController
if (empty($result)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
return $this->data($result);
}
@@ -20,6 +20,14 @@ class PrescriptionOrderController extends BaseAdminController
return $this->dataLists(new PrescriptionOrderLists());
}
/**
* 处方业务订单导出(与 lists 相同筛选条件,Excel;参数 export=1 预估 export=2 下载)
*/
public function export()
{
return $this->dataLists(new PrescriptionOrderLists());
}
/**
* 指定诊单下可关联的支付单(待支付/已支付,创建/编辑业务订单时多选关联)
*/
@@ -109,17 +109,30 @@ class AuthMiddleware
*/
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
if (!in_array('tcm.diagnosis/dailyrecord', $adminUris, true)) {
return false;
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
&& in_array($accessUri, [
'tcm.diagnosistodo/lists',
'tcm.diagnosistodo/add',
'tcm.diagnosistodo/cancel',
'tcm.diagnosis/trackingnotes',
'tcm.diagnosis/addtrackingnote',
], true)) {
return true;
}
return in_array($accessUri, [
'tcm.diagnosistodo/lists',
'tcm.diagnosistodo/add',
'tcm.diagnosistodo/cancel',
'tcm.diagnosis/trackingnotes',
'tcm.diagnosis/addtrackingnote',
], true);
// 导出与列表共用 PrescriptionOrderLists 数据域;角色漏勾「导出订单」子权限时仍返回 权限不足
if ($accessUri === 'tcm.prescriptionorder/export'
&& in_array('tcm.prescriptionorder/lists', $adminUris, true)) {
return true;
}
// 挂号列表批量改渠道:与单条编辑同一数据域,复用 doctor.appointment/edit
if ($accessUri === 'doctor.appointment/batcheditchannel'
&& in_array('doctor.appointment/edit', $adminUris, true)) {
return true;
}
return false;
}
/**
@@ -28,12 +28,9 @@ abstract class BaseAdminDataLists extends BaseDataLists
protected array $adminInfo;
protected int $adminId;
public function __construct()
protected function initAdminIdentity(): void
{
parent::__construct();
$this->adminInfo = $this->request->adminInfo;
$this->adminId = $this->request->adminId;
}
}
@@ -7,9 +7,11 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
use app\common\model\auth\AdminRole;
use app\common\model\dict\DictData;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use think\facade\Db;
/**
* 医生预约列表
@@ -20,6 +22,16 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
{
use HasDataScopeFilter;
/**
* 诊单编辑/详情「挂号记录」Tab:按诊单 ID 拉全量挂号,不做医生/医助角色收窄与数据范围过滤。
* 须同时传 patient_id(挂号表存的是诊单 id)与本开关,避免列表页被滥用拓宽可见范围。
*/
private function appointmentListsScopeRelaxedForDiagnosis(): bool
{
return (int) ($this->params['diag_scope_relax'] ?? 0) === 1
&& (int) ($this->params['patient_id'] ?? 0) > 0;
}
/**
* 数据隔离:医生或医助(u.assistant_id = diag.assistant_id)命中可见集合;progress_board 场景放开(看板跨医生查看)
*/
@@ -28,6 +40,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
if ($progressBoard) {
return;
}
if ($this->appointmentListsScopeRelaxedForDiagnosis()) {
return;
}
if (!$this->dataScopeShouldApply()) {
return;
}
@@ -43,6 +58,63 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$inList = implode(',', $ids);
$query->whereRaw("(a.doctor_id IN ({$inList}) OR u.assistant_id IN ({$inList}))");
}
/**
* 按医助筛选:诊单指派医助或挂号记录上的医助任一命中即可
*
* @param mixed $query
*/
private function applyAssistantIdFilter($query): void
{
$aid = (int) ($this->params['assistant_id'] ?? 0);
if ($aid <= 0) {
return;
}
$query->where(function ($q) use ($aid): void {
$q->where('u.assistant_id', $aid)->whereOr('a.assistant_id', $aid);
});
}
/**
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
*
* @param mixed $query
*/
private function applyChannelSourceFilter($query, string $chFilter): void
{
if ($chFilter === '') {
return;
}
$tblFields = Db::name('doctor_appointment')->getTableFields();
$cols = \is_array($tblFields) ? $tblFields : [];
$hasChannelSource = \in_array('channel_source', $cols, true);
$hasChannels = \in_array('channels', $cols, true);
if (!$hasChannelSource && !$hasChannels) {
return;
}
$query->where(function ($q) use ($chFilter, $hasChannelSource, $hasChannels): void {
if ($hasChannelSource && $hasChannels) {
$q->where('a.channel_source', '=', $chFilter)
->whereOr('a.channels', '=', $chFilter);
if (is_numeric($chFilter)) {
$q->whereOr('a.channels', '=', (int) $chFilter);
}
return;
}
if ($hasChannelSource) {
$q->where('a.channel_source', '=', $chFilter);
return;
}
$q->where('a.channels', '=', $chFilter);
if (is_numeric($chFilter)) {
$q->whereOr('a.channels', '=', (int) $chFilter);
}
});
}
/**
* @notes 设置搜索条件
* @return array
@@ -76,6 +148,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->searchWhere[] = ['a.status', '=', $this->params['status']];
}
// 渠道字典 value(命中 channel_source 或 legacy channels
$chFilter = isset($this->params['channel_source']) ? trim((string) $this->params['channel_source']) : '';
// 处理日期范围搜索
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$this->searchWhere[] = ['a.appointment_date', 'between', [$this->params['start_date'], $this->params['end_date']]];
@@ -109,11 +184,15 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin ad', 'a.doctor_id = ad.id')
->leftJoin('admin asst', 'u.assistant_id = asst.id')
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id');
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
if ($this->searchWhere !== []) {
$query->where($this->searchWhere);
}
$this->applyAssistantIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
// 是否确认诊单:1=已确认 0=未确认
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
$confirmed = (int)$this->params['diagnosis_confirmed'];
@@ -128,7 +207,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
// 面诊进度看板:可切换查看各医生挂号,不按当前账号角色收窄
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
if (!$progressBoard) {
$diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
if (!$progressBoard && !$diagScopeRelax) {
// 如果是医生角色(role_id=1),只显示挂自己号的预约
if (in_array(1, $roleIds)) {
$query->where('a.doctor_id', $this->adminId);
@@ -198,6 +278,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
}
}
}
$channelNameByValue = DictData::where('type_value', 'channels')->column('name', 'value');
foreach ($lists as &$item) {
$statusMap = [
1 => '已预约',
@@ -214,6 +297,24 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
];
$item['appointment_type_desc'] = $typeMap[$item['appointment_type']] ?? '未知';
$periodRaw = (string) ($item['period'] ?? ($item['type'] ?? ''));
$periodMap = [
'morning' => '上午',
'afternoon' => '下午',
'all' => '全天',
];
$item['period_desc'] = $periodMap[$periodRaw] ?? ($periodRaw !== '' ? $periodRaw : '—');
$srcKey = trim((string) ($item['channel_source'] ?? ''));
if ($srcKey === '' && isset($item['channels']) && $item['channels'] !== '' && $item['channels'] !== null) {
$srcKey = trim((string) $item['channels']);
}
if ($srcKey !== '') {
$item['channel_source_desc'] = (string) ($channelNameByValue[$srcKey] ?? $channelNameByValue[(string) (int) $srcKey] ?? $srcKey);
} else {
$item['channel_source_desc'] = '—';
}
$item['diagnosis_confirmed'] = isset($confirmedMap[$item['diagnosis_id'] ?? 0]) ? 1 : 0;
$item['has_prescription'] = isset($prescribedDiagnosisIds[$item['diagnosis_id'] ?? 0]) ? 1 : 0;
@@ -253,6 +354,7 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
if ($applyStatusFilter && isset($this->params['status']) && $this->params['status'] !== '') {
$query->where('a.status', '=', $this->params['status']);
}
$chFilter = isset($this->params['channel_source']) ? trim((string) $this->params['channel_source']) : '';
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$query->whereBetween('a.appointment_date', [$this->params['start_date'], $this->params['end_date']]);
} elseif (!empty($this->params['start_date'])) {
@@ -269,6 +371,10 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$query->where('a.doctor_id', '=', (int) $this->params['doctor_id']);
}
$this->applyAssistantIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
$sf = $this->params['status'] ?? '';
if ($sf === '' || (int) $sf !== 2) {
@@ -288,7 +394,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
}
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
if (!$progressBoard) {
$diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
if (!$progressBoard && !$diagScopeRelax) {
if (in_array(1, $roleIds)) {
$query->where('a.doctor_id', $this->adminId);
}
@@ -77,6 +77,23 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
}
}
// 添加时间:与 lists 排序口径一致(external_first_add_time 优先,0 则 create_time
$addStart = trim((string) ($this->params['add_time_start'] ?? ''));
$addEnd = trim((string) ($this->params['add_time_end'] ?? ''));
$effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)';
if ($addStart !== '') {
$t = strtotime($addStart . ' 00:00:00');
if ($t !== false) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$t]);
}
}
if ($addEnd !== '') {
$t = strtotime($addEnd . ' 23:59:59');
if ($t !== false) {
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$t]);
}
}
return $query;
}
@@ -177,14 +177,14 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
if ($isCompletedTab) {
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
$orderRaw = 'IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
} else {
$orderRaw = 'CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
}
$lists = $query
->with(['DiagnosisViewRecord'])
->field(['id', 'patient_id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'assistant_id', 'status', 'create_time', 'update_time'])
->field(['id', 'patient_id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'assistant_id', 'assign_read_at', 'status', 'create_time', 'update_time'])
->append(['gender_desc', 'status_desc', 'diagnosis_date_text'])
->orderRaw($orderRaw)
->limit($this->limitOffset, $this->limitLength)
@@ -8,6 +8,8 @@ use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\enum\ExportEnum;
use app\common\lists\ListsExcelInterface;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
@@ -23,7 +25,7 @@ use think\facade\Config;
use think\facade\Db;
use think\db\Query;
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
{
use HasDataScopeFilter;
@@ -102,12 +104,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyPatientIdFilter($query);
$this->applyExpressCompanyFilter($query);
$this->applyExpressKeywordFilter($query);
$this->applyServiceChannelFilter($query);
$this->applySupplyModeFilter($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
$this->applyDataScopeForPrescriptionOrder($query);
}
// 业绩看板侧栏等:不展示履约已取消(4),与 stats 业绩统计口径一致
// 业绩看板侧栏等:不展示履约 4/9/10,与 stats 业绩口径一致
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
}
@@ -117,6 +120,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
/**
* 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_timerevisit_slot=0 为全部复诊分项合计)。
* admin 维度按订单创建人 o.creator_id(与部门表复诊列、排行榜复诊列同口径)。
*/
private function applyYejiErCenterRevisitOrderFilter($query): void
{
@@ -138,7 +142,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($t0 === false || $t1 === false) {
return;
}
$ids = DeptLogic::listErCenterRevisitOrderIdsForAssistant(
$ids = DeptLogic::listErCenterRevisitOrderIdsForCreator(
$assistantId,
$slot,
(int) $t0,
@@ -197,6 +201,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$query->whereRaw("(($po) OR ($et))");
}
/**
* 服务渠道:未指派传 service_channel=0 时命中「空串」或「0」(varchar)
*/
private function applyServiceChannelFilter(Query $query): void
{
$raw = $this->params['service_channel'] ?? '';
if ($raw === '' || $raw === null) {
return;
}
$v = trim((string) $raw);
if ($v === '0') {
$query->whereRaw("(TRIM(IFNULL(`service_channel`,'')) = '' OR `service_channel` = '0')");
return;
}
$query->where('service_channel', '=', $v);
}
/**
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
*
@@ -377,7 +399,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyDataScopeForPrescriptionOrder($query);
}
}
// 与看板「合计业绩」一致:NULL 安全剔除履约已取消(4),勿用 `<>` 以免误丢 NULL 状态行
// 与看板「合计业绩」一致:NULL 安全剔除履约 4/9/10
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
}
@@ -551,8 +573,14 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
unset($item);
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
PrescriptionOrderLogic::appendLinkedAppointmentsToListRows($lists);
PrescriptionOrderLogic::appendLinkedAppointmentsToListRows($lists, $this->params);
}
if ((int) $this->export === ExportEnum::EXPORT) {
PrescriptionOrderLogic::appendPrescriptionOrderListExportRows($lists, $this->adminInfo);
}
return $lists;
@@ -569,13 +597,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'stats_order_amount' => $s['order_amount'],
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
/** 业绩:业务订单金额,剔除履约已取消(fulfillment_status=4),其余状态全部计入 */
'stats_order_amount_performance' => $s['order_amount_not_cancelled'],
/** 业绩:业务订单金额,剔除履约已取消/拒收/退款(4/9/10)NULL 与其它状态计入 */
'stats_order_amount_performance' => $s['order_amount_performance'] ?? $s['order_amount_not_cancelled'],
'stats_linked_pay_amount' => $s['linked_pay_amount'],
'stats_linked_pay_amount_not_cancelled' => $s['linked_pay_amount_not_cancelled'],
'stats_linked_pay_amount_cancelled' => $s['linked_pay_amount_cancelled'],
/** 业绩-关联实付:剔除履约已取消订单的关联支付金额 */
'stats_linked_pay_amount_performance' => $s['linked_pay_amount_not_cancelled'],
/** 业绩-关联实付:剔除履约 4/9/10 订单的关联支付金额 */
'stats_linked_pay_amount_performance' => $s['linked_pay_amount_performance'] ?? $s['linked_pay_amount_not_cancelled'],
'stats_time_start' => $s['label_start'],
'stats_time_end' => $s['label_end'],
/** 统计口径:all=支付审核白名单全量;assistant=医助仅诊单协助业绩;restricted=与列表同域 */
@@ -598,6 +626,40 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return $base;
}
public function setFileName(): string
{
return '处方业务订单';
}
public function setExcelFields(): array
{
return [
'export_fulfillment_status_text' => '履约状态',
'export_order_time' => '创建时间',
'doctor_name' => '医生姓名',
'export_patient_name' => '患者姓名',
'export_patient_gender' => '性别',
'export_patient_age' => '年龄',
'export_patient_phone' => '手机号',
'assistant_name' => '医助名字',
'export_center_label' => '一中心还是二中心',
'export_assistant_dept' => '医助部门',
'export_channel' => '渠道',
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
'export_medication_form' => '药品形态',
'export_medication_days' => '天数',
'export_amount' => '总金额',
'export_paid_amount' => '已付金额',
'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号',
'export_supply_mode' => '甘草还是自营',
'export_gancao_prescription_cost' => '处方成本',
'export_first_visit_assistant' => '初诊医助',
'export_rx_audit_time' => '审核时间',
'export_rx_auditor' => '审核人',
];
}
/**
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
* doctor_appointment.status=3appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
@@ -692,7 +754,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$t1 = $tmp;
}
$q = $this->buildBaseQueryForStats(false)->whereBetween('create_time', [$t0, $t1]);
// 全量「合计业绩」与看板列同口径:始终剔除履约已取消(4),不依赖 exclude 参数
// 全量「合计业绩」与看板列同口径:始终剔除履约 4/9/10,不依赖 exclude 参数
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
$amount = round((float) (clone $q)->sum('amount'), 2);
$count = (int) (clone $q)->count();
@@ -714,7 +776,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($pendingRxQuery, '');
return [
// 处方审核待处理(不含履约已取消
// 处方审核待处理(不含履约 4/9/10
'pending_rx' => (int) $pendingRxQuery->where('prescription_audit_status', 0)->count(),
// 支付审核待处理(前提:处方已通过)
'pending_pay' => (int) (clone $query)->where('prescription_audit_status', 1)->where('payment_slip_audit_status', 0)->count(),
@@ -760,8 +822,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
$q = $this->buildBaseQueryForStats()->whereBetween('create_time', [$t0, $t1]);
$orderTotal = round((float) (clone $q)->sum('amount'), 2);
$orderCancelled = round((float) (clone $q)->where('fulfillment_status', 4)->sum('amount'), 2);
$orderTotal = round((float) (clone $q)->sum('amount'), 2);
$orderCancelled = round((float) (clone $q)->where('fulfillment_status', 4)->sum('amount'), 2);
$orderNotCancelled = round($orderTotal - $orderCancelled, 2);
$idsAll = (clone $q)->column('id');
@@ -769,7 +831,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return $id > 0;
}));
if ($idsAll === []) {
return $this->statsExtendPayload(
$empty = $this->statsExtendPayload(
$orderTotal,
$orderNotCancelled,
$orderCancelled,
@@ -779,6 +841,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$l0,
$l1
);
$qPerfEmpty = clone $q;
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($qPerfEmpty, '');
$empty['order_amount_performance'] = round((float) $qPerfEmpty->sum('amount'), 2);
$empty['linked_pay_amount_performance'] = 0.0;
return $empty;
}
$idsCancel = (clone $q)->where('fulfillment_status', 4)->column('id');
$idsCancel = array_values(array_filter(array_map('intval', $idsCancel), static function (int $id): bool {
@@ -790,7 +858,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$linkedNotCancelled = $this->sumLinkedPayForPrescriptionOrderIds($idsNotCancelled);
$linkedCancelled = $this->sumLinkedPayForPrescriptionOrderIds($idsCancel);
return $this->statsExtendPayload(
$base = $this->statsExtendPayload(
$orderTotal,
$orderNotCancelled,
$orderCancelled,
@@ -800,6 +868,21 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$l0,
$l1
);
$qPerf = clone $q;
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($qPerf, '');
$base['order_amount_performance'] = round((float) $qPerf->sum('amount'), 2);
$idsExcludedPerf = (clone $q)
->whereIn('fulfillment_status', YejiStatsLogic::PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE)
->column('id');
$idsExcludedPerf = array_values(array_filter(array_map('intval', $idsExcludedPerf), static function (int $id): bool {
return $id > 0;
}));
$idsPerf = array_values(array_diff($idsAll, $idsExcludedPerf));
$base['linked_pay_amount_performance'] = $this->sumLinkedPayForPrescriptionOrderIds($idsPerf);
return $base;
}
/**
@@ -807,7 +890,11 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
*/
private function statsZeroPayload(): array
{
return $this->statsExtendPayload(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, '', '');
$z = $this->statsExtendPayload(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, '', '');
$z['order_amount_performance'] = 0.0;
$z['linked_pay_amount_performance'] = 0.0;
return $z;
}
/**
@@ -870,16 +957,23 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.id = {$poTbl}.prescription_id AND rx.delete_time IS NULL AND rx.creator_id = {$doctorId}");
}
/** 业绩看板入口(yeji_order_drawer=1)所有筛选统一按订单创建人;其它入口(处方订单列表 / 患者跟进等)保留旧口径(创建人 ∪ 诊单医助) */
$yejiPanel = (int) ($this->params['yeji_order_drawer'] ?? 0) === 1;
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
$assistantId = (int) $this->params['assistant_id'];
$diagTbl = (new Diagnosis())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) {
$q->where('creator_id', $assistantId);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
);
});
if ($yejiPanel) {
$query->where('creator_id', $assistantId);
} else {
$diagTbl = (new Diagnosis())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) {
$q->where('creator_id', $assistantId);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
);
});
}
}
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
@@ -898,6 +992,25 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return;
}
$yejiTableRowDeptIds = [];
if (isset($this->params['yeji_table_row_dept_ids']) && trim((string) $this->params['yeji_table_row_dept_ids']) !== '') {
foreach (explode(',', (string) $this->params['yeji_table_row_dept_ids']) as $p) {
$v = (int) trim((string) $p);
if ($v > 0) {
$yejiTableRowDeptIds[] = $v;
}
}
}
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1 && $yejiTableRowDeptIds !== []) {
YejiStatsLogic::applyPrescriptionOrderListAlignedWinnerDeptFilter(
$query,
(int) $this->params['assistant_dept_id'],
$yejiTableRowDeptIds,
$this->params['dept_ids'] ?? null
);
return;
}
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
@@ -909,23 +1022,31 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return;
}
$inList = implode(',', $deptIds);
$diagTbl = (new Diagnosis())->getTable();
$adTbl = (new AdminDept())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) {
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
if ($yejiPanel) {
/** 业绩看板部门侧栏:仅订单创建人人事部门 ∈ 子树(与「合计业绩」/「接诊诊单」/「复诊」聚合同口径) */
$query->whereExists(
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})"
);
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})",
'OR'
);
});
} else {
$diagTbl = (new Diagnosis())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) {
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
);
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})",
'OR'
);
});
}
}
}
/**
* 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单(与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致)。
* 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单
* (与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致——按订单创建人)。
*
* @param int[] $adminIds
*/
@@ -939,18 +1060,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return;
}
$poTbl = (new PrescriptionOrder())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$rxTbl = (new Prescription())->getTable();
$inList = implode(',', $adminIds);
$query->whereRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg "
. "LEFT JOIN `{$rxTbl}` rx ON rx.`id` = `{$poTbl}`.`prescription_id` AND rx.`delete_time` IS NULL "
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND dg.`delete_time` IS NULL AND `{$poTbl}`.`diagnosis_id` > 0 AND "
. '(CASE WHEN dg.`assistant_id` > 0 THEN dg.`assistant_id` '
. "WHEN `{$poTbl}`.`creator_id` > 0 AND (rx.`id` IS NULL OR NOT (`{$poTbl}`.`creator_id` <=> rx.`creator_id`)) THEN `{$poTbl}`.`creator_id` "
. 'ELSE 0 END) IN (' . $inList . '))'
);
$query->whereIn('creator_id', $adminIds);
}
/**
@@ -1020,4 +1130,118 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
. ")"
);
}
/**
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
*
* @param array<int, array<string, mixed>> $lists
*/
private function appendPrescriptionOrderAssignSnapshotErCenterFlags(array &$lists): void
{
if ($lists === []) {
return;
}
$diagIds = [];
foreach ($lists as $row) {
$d = (int) ($row['diagnosis_id'] ?? 0);
if ($d > 0) {
$diagIds[$d] = true;
}
}
$diagIdList = array_keys($diagIds);
$latestSnapLogByTriple = [];
if ($diagIdList !== []) {
$logRows = Db::name('tcm_diagnosis_assign_log')
->whereIn('diagnosis_id', $diagIdList)
->field(['id', 'diagnosis_id', 'to_assistant_id', 'related_po_creator_id', 'related_po_create_time'])
->order('id', 'desc')
->select()
->toArray();
foreach ($logRows as $log) {
$d = (int) ($log['diagnosis_id'] ?? 0);
$rcp = (int) ($log['related_po_creator_id'] ?? 0);
$rpct = (int) ($log['related_po_create_time'] ?? 0);
if ($d <= 0 || $rcp <= 0 || $rpct <= 0) {
continue;
}
$tripleKey = $d . ':' . $rcp . ':' . $rpct;
if (!isset($latestSnapLogByTriple[$tripleKey])) {
$latestSnapLogByTriple[$tripleKey] = $log;
}
}
}
$toAssistantIdsHit = [];
foreach ($lists as $item) {
$diagId = (int) ($item['diagnosis_id'] ?? 0);
$creatorId = (int) ($item['creator_id'] ?? 0);
$poCt = (int) ($item['create_time'] ?? 0);
$tripleKey = $diagId . ':' . $creatorId . ':' . $poCt;
$log = $latestSnapLogByTriple[$tripleKey] ?? null;
if (\is_array($log)) {
$tid = (int) ($log['to_assistant_id'] ?? 0);
if ($tid > 0) {
$toAssistantIdsHit[$tid] = true;
}
}
}
$toAssistantIdList = array_keys($toAssistantIdsHit);
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$adminErCenter = [];
if ($toAssistantIdList !== [] && $erDeptSet !== []) {
$adRows = AdminDept::whereIn('admin_id', $toAssistantIdList)
->field(['admin_id', 'dept_id'])
->select()
->toArray();
foreach ($adRows as $ad) {
$aid = (int) ($ad['admin_id'] ?? 0);
$did = (int) ($ad['dept_id'] ?? 0);
if ($aid > 0 && $did > 0 && isset($erDeptSet[$did])) {
$adminErCenter[$aid] = true;
}
}
}
$nameMap = [];
if ($toAssistantIdList !== []) {
$nameMap = \app\common\model\auth\Admin::whereIn('id', $toAssistantIdList)->column('name', 'id');
}
foreach ($lists as &$item) {
$diagId = (int) ($item['diagnosis_id'] ?? 0);
$creatorId = (int) ($item['creator_id'] ?? 0);
$poCt = (int) ($item['create_time'] ?? 0);
$item['po_assign_snapshot_hit'] = 0;
$item['po_assign_to_assistant_id'] = 0;
$item['po_assign_to_assistant_name'] = '';
$item['po_assign_to_er_center'] = 0;
$item['po_assign_is_release'] = 0;
if ($diagId <= 0 || $creatorId <= 0 || $poCt <= 0) {
continue;
}
$tripleKey = $diagId . ':' . $creatorId . ':' . $poCt;
$log = $latestSnapLogByTriple[$tripleKey] ?? null;
if (!\is_array($log)) {
continue;
}
$item['po_assign_snapshot_hit'] = 1;
$toId = (int) ($log['to_assistant_id'] ?? 0);
$item['po_assign_to_assistant_id'] = $toId;
if ($toId <= 0) {
$item['po_assign_is_release'] = 1;
continue;
}
$item['po_assign_to_assistant_name'] = (string) ($nameMap[$toId] ?? '');
$item['po_assign_to_er_center'] = isset($adminErCenter[$toId]) ? 1 : 0;
}
unset($item);
}
}
+56 -72
View File
@@ -14,6 +14,7 @@
namespace app\adminapi\logic\dept;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
@@ -212,28 +213,31 @@ class DeptLogic extends BaseLogic
->join('tcm_diagnosis dg', 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw('NOT (o.fulfillment_status <=> ?)', [4]);
->where('dg.assistant_id', '>', 0);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
if ($orderStartTs !== null && $orderEndTs !== null) {
$q->where('o.create_time', 'between', [$orderStartTs, $orderEndTs]);
}
return $q
->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id'])
->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id', 'o.creator_id'])
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
}
/**
* 二中心子树:各部门复诊 rollup;同时返回按诊单医助聚合的分项(供医助排行榜)。
* 二中心子树:各部门复诊 rollup(按订单创建人 creator_id 归属)。
* 候选订单仍要求 dg.assistant_id > 0(保持「er-center 案例 = 有医助的诊单」的定义),
* **复诊归属**改为订单创建人——leaf 部门归属取 creator_id 在二中心子树内的 canonical 部门,
* 并产出 by_creator_slots 供医助排行榜复诊列复用,与诊金 / 接诊诊单口径完全一致。
*
* @param list<int> $subtreeDeptIds
*
* @return array{
* totals: array<int, int>,
* slots: array<int, array<int, int>>,
* by_assistant_slots: array<int, array<int, int>>
* by_creator_slots: array<int, array<int, int>>
* }
*/
private static function computeErCenterRevisitFullPack(
@@ -249,12 +253,12 @@ class DeptLogic extends BaseLogic
$emptyTotals[$did] = 0;
}
if ($subtreeDeptIds === []) {
return ['totals' => [], 'slots' => [], 'by_assistant_slots' => []];
return ['totals' => [], 'slots' => [], 'by_creator_slots' => []];
}
$subtreeSet = array_fill_keys($subtreeDeptIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_assistant_slots' => []];
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_creator_slots' => []];
}
$byPatient = [];
@@ -266,29 +270,30 @@ class DeptLogic extends BaseLogic
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
$creatorNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid > 0) {
$creatorNeed[$cid] = true;
}
}
}
$assistantIds = array_keys($assistantNeed);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($assistantIds, $subtreeSet);
$creatorIds = array_keys($creatorNeed);
/** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byAssistantSlot */
$byAssistantSlot = [];
/** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
$byCreatorSlot = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
$deptId = $canonicalDept[$aid] ?? null;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
$deptId = $canonicalDept[$cid] ?? null;
if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue;
}
@@ -296,11 +301,11 @@ class DeptLogic extends BaseLogic
$leafByDeptSlot[$deptId] = [];
}
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
if ($aid > 0) {
if (!isset($byAssistantSlot[$aid])) {
$byAssistantSlot[$aid] = [];
if ($cid > 0) {
if (!isset($byCreatorSlot[$cid])) {
$byCreatorSlot[$cid] = [];
}
$byAssistantSlot[$aid][$slot] = ($byAssistantSlot[$aid][$slot] ?? 0) + 1;
$byCreatorSlot[$cid][$slot] = ($byCreatorSlot[$cid][$slot] ?? 0) + 1;
}
}
}
@@ -313,16 +318,16 @@ class DeptLogic extends BaseLogic
$rollupTotals[$id] = array_sum($rollupSlots[$id] ?? []);
}
foreach ($byAssistantSlot as $aid => $sm) {
foreach ($byCreatorSlot as $cid => $sm) {
if ($sm !== []) {
ksort($byAssistantSlot[$aid], SORT_NUMERIC);
ksort($byCreatorSlot[$cid], SORT_NUMERIC);
}
}
return [
'totals' => $rollupTotals,
'slots' => $rollupSlots,
'by_assistant_slots' => $byAssistantSlot,
'by_creator_slots' => $byCreatorSlot,
];
}
@@ -345,11 +350,11 @@ class DeptLogic extends BaseLogic
}
/**
* 二中心子树内:各医助成交的复诊业务单笔数(合计 + 复诊2/3/…分项),口径与部门 rollup 同源
* 二中心子树复诊:按订单创建人 creator_id 聚合(与部门 rollup 同源,与诊金/接诊诊单口径一致)
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitCountsByAssistant(?int $orderStartTs = null, ?int $orderEndTs = null): array
public static function getErCenterRevisitCountsByCreator(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
@@ -357,42 +362,36 @@ class DeptLogic extends BaseLogic
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$by = $pack['by_assistant_slots'];
$by = $pack['by_creator_slots'];
$totals = [];
$slots = [];
foreach ($by as $aid => $slotMap) {
$aid = (int) $aid;
$totals[$aid] = (int) array_sum($slotMap);
$slots[$aid] = $slotMap;
foreach ($by as $cid => $slotMap) {
$cid = (int) $cid;
$totals[$cid] = (int) array_sum($slotMap);
$slots[$cid] = $slotMap;
}
return ['totals' => $totals, 'slots' => $slots];
}
/**
* 某医助在区间内、计入二中心复诊的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* 二中心子树复诊:列出某「订单创建人」在区间内、复诊序列(slot≥2)命中的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
*
* @return list<int>
*/
public static function listErCenterRevisitOrderIdsForAssistant(
int $assistantId,
public static function listErCenterRevisitOrderIdsForCreator(
int $creatorId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
): array {
if ($assistantId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
if ($creatorId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return [];
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
return [];
}
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return [];
}
$subtreeSet = array_fill_keys($subtreeIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return [];
@@ -405,31 +404,16 @@ class DeptLogic extends BaseLogic
}
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
}
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $subtreeSet);
$ids = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid !== $assistantId) {
continue;
}
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$dcan = $canonicalDept[$aid] ?? null;
if ($dcan === null || !isset($subtreeSet[$dcan])) {
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid !== $creatorId) {
continue;
}
$oid = (int) ($orders[$i]['id'] ?? 0);
@@ -443,8 +427,8 @@ class DeptLogic extends BaseLogic
}
/**
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按诊单医助拆解笔数。
* 与看板行内「复诊」列口径一致:诊单下订单序列第 2 笔起;医助 canonical 部门须落在二中心子树且在该部门行的子树内。
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按**订单创建人**拆解笔数。
* 与看板行内「复诊」列口径一致:订单序列第 2 笔起;创建人 canonical 部门须落在二中心子树且在该部门行的子树内。
*
* @return array{rows: list<array{admin_id: int, name: string, order_count: int}>}
*/
@@ -484,17 +468,17 @@ class DeptLogic extends BaseLogic
}
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
$creatorNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid > 0) {
$creatorNeed[$cid] = true;
}
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $erSubtreeSet);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
/** @var array<int, int> $counts */
$counts = [];
foreach ($byPatient as $orders) {
@@ -504,15 +488,15 @@ class DeptLogic extends BaseLogic
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid <= 0) {
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid <= 0) {
continue;
}
$canon = $canonicalDept[$aid] ?? null;
$canon = $canonicalDept[$cid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue;
}
$counts[$aid] = ($counts[$aid] ?? 0) + 1;
$counts[$cid] = ($counts[$cid] ?? 0) + 1;
}
}
if ($counts === []) {
@@ -527,10 +511,10 @@ class DeptLogic extends BaseLogic
->column('name', 'id');
}
$rows = [];
foreach ($counts as $aid => $cnt) {
foreach ($counts as $cid => $cnt) {
$rows[] = [
'admin_id' => (int) $aid,
'name' => (string) ($nameRows[$aid] ?? ('#' . $aid)),
'admin_id' => (int) $cid,
'name' => (string) ($nameRows[$cid] ?? ('#' . $cid)),
'order_count' => (int) $cnt,
];
}
@@ -9,6 +9,8 @@ use app\common\service\doctor\RosterSegmentService;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisGuahaoLog;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\service\DataScope\DataScopeService;
use app\api\logic\ChatNotifyLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
@@ -28,6 +30,75 @@ use think\facade\Log;
*/
class AppointmentLogic extends BaseLogic
{
/** 仅这些字典名称需填写 channel_source_detail(与 admin 预约弹窗白名单一致,按 name 全等) */
private const CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL = [
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q',
];
private static function channelDictNameRequiresSourceDetail(string $name): bool
{
return in_array($name, self::CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL, true);
}
/**
* @param array<int|string, mixed> $cols Db::name('doctor_appointment')->getTableFields()
*/
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
{
$hasChannelSource = in_array('channel_source', $cols, true);
$hasChannels = in_array('channels', $cols, true);
if (!$hasChannelSource && !$hasChannels) {
return '挂号表缺少渠道字段(channel_source 或 channels),无法保存渠道来源';
}
if (!$hasChannelSource && $hasChannels && $chSrc !== '' && !is_numeric($chSrc)) {
return '当前挂号表仅有数值型渠道字段 channels,无法写入该渠道;请在数据库增加 channel_source 列';
}
return null;
}
/**
* @param array<string, mixed> $row
* @param array<int|string, mixed> $cols
*/
private static function mergeAppointmentChannelIntoRow(array &$row, array $cols, string $chSrc, string $chDetail): void
{
$hasChannelSource = in_array('channel_source', $cols, true);
$hasChannelDetail = in_array('channel_source_detail', $cols, true);
$hasChannels = in_array('channels', $cols, true);
if ($hasChannelSource) {
$row['channel_source'] = $chSrc;
}
if ($hasChannelDetail) {
$row['channel_source_detail'] = $chDetail;
}
if ($hasChannels && is_numeric($chSrc)) {
$row['channels'] = (int) $chSrc;
}
}
/**
* @param array<string, mixed> $row
* @param array<int|string, mixed> $cols
*
* @return array<string, mixed>
*/
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
{
$out = [];
foreach ($row as $k => $v) {
if (in_array((string) $k, $cols, true)) {
$out[$k] = $v;
}
}
return $out;
}
/**
* @notes 获取可用时间段
* @param array $params
@@ -233,24 +304,64 @@ class AppointmentLogic extends BaseLogic
Db::rollback();
return false;
}
$data = [
'patient_id' => $params['patient_id'],
'assistant_id'=>$params['assistant_id'],
'doctor_id' => $params['doctor_id'],
'roster_id' => 0, // 不再关联排班表
$chSrc = trim((string) ($params['channel_source'] ?? ''));
$chDetail = trim((string) ($params['channel_source_detail'] ?? ''));
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
->where('value', $chSrc)
->value('name') ?? ''));
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
if ($chDetail === '') {
self::setError('请填写自媒体渠道补充信息');
Db::rollback();
return false;
}
}
$cols = Db::name('doctor_appointment')->getTableFields();
$cols = is_array($cols) ? $cols : [];
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
if ($channelErr !== null) {
self::setError($channelErr);
Db::rollback();
return false;
}
$insert = [
'patient_id' => (int) $params['patient_id'],
'doctor_id' => (int) $params['doctor_id'],
'appointment_date' => $params['appointment_date'],
'period' => $params['period'] ?? 'all',
'appointment_time' => $appointmentTime,
'appointment_type' => $params['appointment_type'] ?? 'video',
'remark' => $params['remark'] ?? '',
// 表字段为 channel_source(与字典 type_value=channels 的 value 一致);勿再写入不存在的 channels 列
'channel_source' => trim((string) ($params['channel_source'] ?? '')),
'status' => 1,
'create_time' => time(),
'update_time' => time(),
];
if (in_array('roster_id', $cols, true)) {
$insert['roster_id'] = 0;
}
if (in_array('assistant_id', $cols, true)) {
$insert['assistant_id'] = (int) ($params['assistant_id'] ?? 0);
}
$periodVal = $params['period'] ?? 'all';
if (in_array('period', $cols, true)) {
$insert['period'] = $periodVal;
} elseif (in_array('type', $cols, true)) {
$insert['type'] = $periodVal;
}
self::mergeAppointmentChannelIntoRow($insert, $cols, $chSrc, $chDetail);
$appointment = Appointment::create($data);
$insert = self::filterAppointmentRowByExistingColumns($insert, $cols);
$newId = (int) Db::name('doctor_appointment')->insertGetId($insert);
if ($newId <= 0) {
self::setError('预约创建失败');
Db::rollback();
return false;
}
Db::commit();
@@ -258,21 +369,21 @@ class AppointmentLogic extends BaseLogic
$hm = substr((string) $appointmentTime, 0, 5);
$summary = sprintf(
'挂号 #%d:医生「%s」,%s %s',
(int) $appointment->id,
$newId,
$doctorName !== '' ? $doctorName : ('ID:' . (int) $params['doctor_id']),
(string) $params['appointment_date'],
$hm
);
self::appendDiagnosisGuahaoLog(
(int) $params['patient_id'],
(int) $appointment->id,
$newId,
$operatorAdminId,
$operatorAdminInfo,
'create',
$summary
);
return ['id' => $appointment->id];
return ['id' => $newId];
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
@@ -467,11 +578,14 @@ class AppointmentLogic extends BaseLogic
// 面诊结束:通知对应医助(从诊单 assistant_id 获取)
try {
$diagnosis = Diagnosis::where('id', $appointment->patient_id)->find();
if ($diagnosis && !empty($diagnosis->assistant_id)) {
$assistantId = (int) $diagnosis->assistant_id;
$patientName = $diagnosis->patient_name ?? '患者';
$doctor = Admin::where('id', $appointment->doctor_id)->find();
$doctorName = $doctor ? $doctor->name : '医生';
ChatNotifyLogic::addConsultationCompleteNotify(
$assistantId,
$patientName,
@@ -757,6 +871,368 @@ class AppointmentLogic extends BaseLogic
}
}
/**
* AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax
*/
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true)) {
$asst = $diag ? (int) $diag->assistant_id : 0;
if ($asst !== $adminId) {
return false;
}
}
if (!DataScopeService::isEnabled()) {
return true;
}
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($ids === []) {
return false;
}
if ($ids === null) {
return true;
}
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
return in_array($docId, $ids, true)
|| ($asstId > 0 && in_array($asstId, $ids, true));
}
/**
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
*
* @param array<string, mixed> $params
*/
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
{
try {
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
self::setError('参数错误');
return false;
}
$appointment = Appointment::findOrEmpty($id);
if ($appointment->isEmpty()) {
self::setError('预约记录不存在');
return false;
}
$diag = Diagnosis::where('id', (int) $appointment->patient_id)->whereNull('delete_time')->find();
if (!self::appointmentRowManageableByAdmin($appointment, $diag ?: null, $adminId, $adminInfo)) {
self::setError('无权限编辑');
return false;
}
$period = trim((string) ($params['period'] ?? ''));
if (!in_array($period, ['morning', 'afternoon', 'all'], true)) {
self::setError('时段无效');
return false;
}
$appointmentDate = trim((string) ($params['appointment_date'] ?? ''));
if ($appointmentDate === '') {
self::setError('预约日期不能为空');
return false;
}
$rawTime = trim((string) ($params['appointment_time'] ?? ''));
if ($rawTime === '') {
self::setError('预约时间不能为空');
return false;
}
$appointmentTime = strlen($rawTime) === 5 ? $rawTime . ':00' : $rawTime;
$status = (int) ($params['status'] ?? 0);
if ($status < 1 || $status > 4) {
self::setError('状态无效');
return false;
}
$appointmentType = trim((string) ($params['appointment_type'] ?? ''));
if (!in_array($appointmentType, ['video', 'text', 'phone'], true)) {
self::setError('预约类型无效');
return false;
}
$remark = isset($params['remark']) ? trim((string) $params['remark']) : '';
if (mb_strlen($remark) > 500) {
self::setError('备注过长');
return false;
}
$chSrc = trim((string) ($params['channel_source'] ?? ''));
if ($chSrc === '') {
self::setError('请选择渠道来源');
return false;
}
$chDetail = trim((string) ($params['channel_source_detail'] ?? ''));
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
->where('value', $chSrc)
->value('name') ?? ''));
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
if ($chDetail === '') {
self::setError('请填写自媒体渠道补充信息');
return false;
}
}
if (mb_strlen($chDetail) > 128) {
self::setError('渠道补充说明过长');
return false;
}
$postAssistant = array_key_exists('assistant_id', $params);
$assistantId = null;
if ($postAssistant) {
$assistantRaw = $params['assistant_id'];
if ($assistantRaw !== null && $assistantRaw !== '') {
$aid = (int) $assistantRaw;
$assistantId = $aid > 0 ? $aid : null;
}
}
$patientId = (int) $appointment->patient_id;
$doctorId = (int) $appointment->doctor_id;
if (in_array($status, [1, 4], true)) {
$patientDup = Appointment::where('patient_id', $patientId)
->where('appointment_date', $appointmentDate)
->whereIn('status', [1, 4])
->where('id', '<>', $id)
->find();
if ($patientDup) {
self::setError('该诊单在所选日期已有其他有效挂号(已预约/已过号)');
return false;
}
}
if ($status === 1) {
$slotDup = Appointment::where('doctor_id', $doctorId)
->where('appointment_date', $appointmentDate)
->where('appointment_time', $appointmentTime)
->where('status', 1)
->where('id', '<>', $id)
->find();
if ($slotDup) {
self::setError('该医生在所选时段已被占用');
return false;
}
}
$beforeDate = (string) ($appointment->appointment_date ?? '');
$beforeTimeRaw = (string) ($appointment->appointment_time ?? '');
$beforeHm = strlen($beforeTimeRaw) >= 5 ? substr($beforeTimeRaw, 0, 5) : $beforeTimeRaw;
$snap = $appointment->toArray();
$beforePeriod = (string) ($snap['period'] ?? $snap['type'] ?? '');
$beforeStatus = (int) $appointment->status;
$tblFields = Db::name('doctor_appointment')->getTableFields();
$cols = is_array($tblFields) ? $tblFields : [];
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
if ($channelErr !== null) {
self::setError($channelErr);
return false;
}
Db::startTrans();
$saveData = [
'appointment_date' => $appointmentDate,
'appointment_time' => $appointmentTime,
'appointment_type' => $appointmentType,
'status' => $status,
'remark' => $remark,
'update_time' => time(),
];
if (in_array('assistant_id', $cols, true) && $postAssistant) {
$saveData['assistant_id'] = $assistantId;
}
if (in_array('period', $cols, true)) {
$saveData['period'] = $period;
} elseif (in_array('type', $cols, true)) {
$saveData['type'] = $period;
}
self::mergeAppointmentChannelIntoRow($saveData, $cols, $chSrc, $chDetail);
Db::name('doctor_appointment')->where('id', $id)->update($saveData);
Db::commit();
$hm = substr($appointmentTime, 0, 5);
$summary = sprintf(
'后台修改挂号 #%d:日期 %s %s→%s %s,时段 %s→%s,状态 %d→%d',
$id,
$beforeDate,
$beforeHm,
$appointmentDate,
$hm,
$beforePeriod !== '' ? $beforePeriod : '—',
$period,
$beforeStatus,
$status
);
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
return true;
} catch (\Throwable $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* 后台批量修改挂号渠道(仅渠道号与可选补充)
*
* @param array<string, mixed> $params
* @return array{updated:int}|false
*/
public static function adminBatchEditChannel(array $params, int $adminId, array $adminInfo)
{
try {
$idsRaw = $params['ids'] ?? [];
if (!\is_array($idsRaw) || $idsRaw === []) {
self::setError('请选择要修改的挂号记录');
return false;
}
$ids = [];
foreach ($idsRaw as $raw) {
$id = (int) $raw;
if ($id > 0) {
$ids[$id] = $id;
}
}
$ids = array_values($ids);
$maxBatch = 300;
if (\count($ids) === 0) {
self::setError('预约ID无效');
return false;
}
if (\count($ids) > $maxBatch) {
self::setError('单次最多修改 ' . $maxBatch . ' 条挂号');
return false;
}
$chSrc = trim((string) ($params['channel_source'] ?? ''));
if ($chSrc === '') {
self::setError('请选择渠道来源');
return false;
}
$chDetail = isset($params['channel_source_detail']) ? trim((string) $params['channel_source_detail']) : '';
$channelDictName = trim((string) (DictData::where('type_value', 'channels')
->where('value', $chSrc)
->value('name') ?? ''));
if ($channelDictName !== '' && self::channelDictNameRequiresSourceDetail($channelDictName)) {
if ($chDetail === '') {
self::setError('请填写自媒体渠道补充信息');
return false;
}
}
if (mb_strlen($chDetail) > 128) {
self::setError('渠道补充说明过长');
return false;
}
$tblFields = Db::name('doctor_appointment')->getTableFields();
$cols = \is_array($tblFields) ? $tblFields : [];
$channelErr = self::assertAppointmentChannelWritable($cols, $chSrc);
if ($channelErr !== null) {
self::setError($channelErr);
return false;
}
Db::startTrans();
$updated = 0;
foreach ($ids as $id) {
$appointment = Appointment::findOrEmpty((int) $id);
if ($appointment->isEmpty()) {
Db::rollback();
self::setError('预约记录不存在(ID ' . $id . '');
return false;
}
$diag = Diagnosis::where('id', (int) $appointment->patient_id)->whereNull('delete_time')->find();
if (!self::appointmentRowManageableByAdmin($appointment, $diag ?: null, $adminId, $adminInfo)) {
Db::rollback();
self::setError('无权限修改挂号 #' . $id);
return false;
}
$saveData = [
'update_time' => time(),
];
self::mergeAppointmentChannelIntoRow($saveData, $cols, $chSrc, $chDetail);
$saveData = self::filterAppointmentRowByExistingColumns($saveData, $cols);
Db::name('doctor_appointment')->where('id', $id)->update($saveData);
$patientId = (int) $appointment->patient_id;
self::appendDiagnosisGuahaoLog(
$patientId,
(int) $id,
$adminId,
$adminInfo,
'admin_batch_channel',
sprintf('批量修改挂号 #%d:渠道设为 %s', $id, $chSrc !== '' ? $chSrc : '—')
);
++$updated;
}
Db::commit();
return [
'updated' => $updated,
'msg' => sprintf('已更新 %d 条挂号渠道', $updated),
];
} catch (\Throwable $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* 诊单列表维度:记录谁在后台发起挂号 / 取消挂号(写入失败不影响主流程)
*/
+13 -1
View File
@@ -1213,7 +1213,7 @@ class OrderLogic
}
$rows = $q
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark'])
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
->order('id', 'desc')
->select()
->toArray();
@@ -1400,4 +1400,16 @@ class OrderLogic
'errors' => $errors,
];
}
public static function setExempt(int $id, int $isExempt): bool
{
$order = Order::find($id);
if (!$order) {
self::setError('订单不存在');
return false;
}
$order->is_exempt = $isExempt;
$order->save();
return true;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\finance\AccountCost;
use app\common\service\DataScope\DataScopeService;
@@ -1041,8 +1042,9 @@ class ConversionLogic
->alias('po')
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp])
->where('po.fulfillment_status', '<>', 4)
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($businessQuery, 'po');
$businessQuery
->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
->group($businessSourceExpr);
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
@@ -14,7 +15,7 @@ use think\facade\Db;
* 医生范围:<b>admin_role.role_id = 1</b> 且管理员 <b>未软删</b>delete_time 为空);再按账号「数据范围」收窄。
*
* - 系统/手动开方:tcm_prescription.prescription_date [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签
* - 成交:订单 create_time、排除履约(4),按处方 creator_id;渠道/标签同 sumPerformance 口径
* - 成交:订单 create_time、排除履约 4/9/10,按处方 creator_id;渠道/标签同 sumPerformance 口径
* - 挂号:appointment_date [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id 标签诊单集
*/
class DoctorDailyStatsLogic
@@ -300,9 +301,9 @@ class DoctorDailyStatsLogic
->alias('o')
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereBetween('o.create_time', [$startTs, $endTs])
->whereRaw('NOT (o.fulfillment_status <=> 4)')
->whereIn('rx.creator_id', $doctorIds)
->whereBetween('o.create_time', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
$q->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
File diff suppressed because it is too large Load Diff
@@ -535,6 +535,19 @@ class DiagnosisLogic extends BaseLogic
return 0;
}
public static function markAssignRead(int $diagnosisId, int $adminId): void
{
if ($diagnosisId <= 0 || $adminId <= 0) {
return;
}
Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->where('assistant_id', $adminId)
->whereNull('assign_read_at')
->whereNull('delete_time')
->update(['assign_read_at' => time()]);
}
/**
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)。
* **原医助**:优先诊单 `assistant_id`;若为 0 且本次为指派到某人,则从**最近一条**指派日志推断(覆盖发货释放后库为 0、业务上仍有「前任」医助的情况)。
@@ -588,7 +601,10 @@ class DiagnosisLogic extends BaseLogic
Db::name('tcm_diagnosis')
->where('id', $id)
->whereNull('delete_time')
->update(['assistant_id' => $toAssistantId]);
->update([
'assistant_id' => $toAssistantId,
'assign_read_at' => $toAssistantId > 0 ? null : 0,
]);
$req = request();
$admin = $req->adminInfo ?? [];
@@ -10,6 +10,7 @@ use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\Diagnosis;
use app\common\service\wechat\WechatWorkAppMessageService;
use app\adminapi\logic\auth\AuthLogic;
use think\facade\Config;
use think\facade\Log;
@@ -122,6 +123,12 @@ class PrescriptionLogic
return true;
}
// 有业务订单详情权限的用户可以查看关联处方(只读)
$perms = AuthLogic::getAuthByAdminId($adminId);
if (in_array('tcm.prescriptionOrder/detail', $perms, true)) {
return true;
}
return false;
}
@@ -766,6 +773,7 @@ class PrescriptionLogic
}
$diagnosisId = (int) ($appointment->patient_id ?? 0);
if ($diagnosisId <= 0) {
Log::info('createSystemAutoFromCompletedAppointment: no diagnosis (patient_id), skip appt=' . $appointmentId);
@@ -819,6 +827,18 @@ class PrescriptionLogic
$visitNo = '1K' . str_pad((string) $diagnosisId, 8, '0', STR_PAD_LEFT);
$herbs = [];
/** 与手动开方一致:存一份当前诊单详情快照(/admin 开方时用 tcmDiagnosisDetail → 同源 DiagnosisLogic::detail */
$caseSnapshot = [];
try {
$snap = DiagnosisLogic::detail(['id' => $diagnosisId]);
$caseSnapshot = is_array($snap) ? $snap : [];
} catch (\Throwable $e) {
Log::warning(
'createSystemAutoFromCompletedAppointment: case_record snapshot failed diagnosis='
. $diagnosisId . ' ' . $e->getMessage()
);
}
$data = [
'sn' => $sn,
@@ -843,7 +863,7 @@ class PrescriptionLogic
'tongue' => (string) ($diagnosis->tongue ?? ''),
'tongue_image' => '',
'clinical_diagnosis' => $clinical,
'case_record' => [],
'case_record' => $caseSnapshot,
'herbs' => $herbs,
'dose_count' => 1,
'dose_unit' => '剂',
@@ -869,7 +889,7 @@ class PrescriptionLogic
'creator_id' => $doctorId,
'assistant_id' => $assistantIdForRx,
];
$prescription = new Prescription();
$prescription->save($data);
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\order\OrderLogic;
use app\common\model\Order;
@@ -13,11 +14,13 @@ use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\dict\DictData;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use app\common\service\ExpressTrackService;
use app\common\service\gancao\GancaoScmRecipelService;
use think\db\Query;
use think\facade\Config;
use think\facade\Db;
use think\facade\Log;
@@ -267,6 +270,166 @@ class PrescriptionOrderLogic
return self::roleIntersect($adminInfo, $allow);
}
/**
* 与业务订单列表金额统计一致:命中「统计医助」角色且非农单支付审核档时,业务侧按「仅限本人关联诊单的业绩域」收口。
* 提成结算等对业绩归因 SQL 收窄时需与之对齐。
*/
public static function statsRestrictedToOwnAssistantPerformanceDomain(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return false;
}
if (self::canViewOrderListStatsAllScope($adminInfo)) {
return false;
}
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
return $assistantRid > 0 && self::roleIntersect($adminInfo, [$assistantRid]);
}
/**
* 提成结算等与列表对齐:按开方医生 / 诊单医助 订单创建人 筛选(与 PrescriptionOrderLists::applyDoctorAssistantFilters 在非业绩抽屉场景一致)。
*
* @param array<string, mixed> $params
*/
public static function applyCommissionOrderDoctorAssistantFilters(Query $query, string $alias, array $params): void
{
if (isset($params['doctor_id']) && (int) $params['doctor_id'] > 0) {
$doctorId = (int) $params['doctor_id'];
$rxTbl = (new Prescription())->getTable();
$query->whereExists(
"SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$alias}`.`prescription_id` "
. "AND rx.`delete_time` IS NULL AND rx.`creator_id` = {$doctorId}"
);
}
if (isset($params['assistant_id']) && (int) $params['assistant_id'] > 0) {
$assistantId = (int) $params['assistant_id'];
$diagTbl = (new Diagnosis())->getTable();
$query->where(function ($q) use ($alias, $diagTbl, $assistantId): void {
$q->where("{$alias}.creator_id", $assistantId);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
);
});
}
}
/**
* 与处方订单列表 HasDataScopeFilter + applyCreatorOrOwnPrescriptionVisibility 等价;主业务订单别名 $alias。
*
* @param array<string, mixed> $params 可含 assistant_id(显式收窄时校验数据域)
*/
public static function applyPrescriptionOrderListAccessForCommissionQuery(
Query $query,
string $alias,
int $viewerAdminId,
array $viewerAdminInfo,
array $params = []
): void {
if ($viewerAdminId <= 0) {
return;
}
self::applyCommissionOrderDoctorAssistantFilters($query, $alias, $params);
if (self::canSeeAllPrescriptionOrders($viewerAdminInfo)) {
self::applyCommissionOrderDataScope($query, $alias, $viewerAdminId, $viewerAdminInfo);
return;
}
$rxTbl = (new Prescription())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$explicitAssistant = (int) ($params['assistant_id'] ?? 0);
$allowExplicitAssistantVisibility = false;
if ($explicitAssistant > 0) {
if (!DataScopeService::isEnabled()) {
$allowExplicitAssistantVisibility = true;
} else {
$visibleIds = DataScopeService::getVisibleAdminIds($viewerAdminId, $viewerAdminInfo);
if ($visibleIds === null || in_array($explicitAssistant, $visibleIds, true)) {
$allowExplicitAssistantVisibility = true;
}
}
}
$query->where(function ($q) use (
$alias,
$rxTbl,
$diagTbl,
$viewerAdminId,
$explicitAssistant,
$allowExplicitAssistantVisibility,
$viewerAdminInfo
): void {
if (self::canViewOrdersForOwnPrescription($viewerAdminInfo)) {
$q->where(function ($qq) use ($alias, $rxTbl, $diagTbl, $viewerAdminId): void {
$qq->where("{$alias}.creator_id", $viewerAdminId);
$qq->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$alias}`.`prescription_id`"
. " AND rx.`delete_time` IS NULL AND rx.`creator_id` = {$viewerAdminId})"
);
$qq->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$viewerAdminId})"
);
});
} else {
$q->where(function ($qq) use ($alias, $diagTbl, $viewerAdminId): void {
$qq->where("{$alias}.creator_id", $viewerAdminId);
$qq->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$viewerAdminId})"
);
});
}
if ($allowExplicitAssistantVisibility) {
$q->whereOr("{$alias}.creator_id", $explicitAssistant);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$explicitAssistant})"
);
}
});
self::applyCommissionOrderDataScope($query, $alias, $viewerAdminId, $viewerAdminInfo);
}
/**
* 列表数据域:创建人 可见 诊单医助 可见。
*/
private static function applyCommissionOrderDataScope(
Query $query,
string $alias,
int $viewerAdminId,
array $viewerAdminInfo
): void {
if (!DataScopeService::isEnabled()) {
return;
}
$ids = DataScopeService::getVisibleAdminIds($viewerAdminId, $viewerAdminInfo);
if ($ids === null) {
return;
}
if ($ids === []) {
$query->whereRaw('0 = 1');
return;
}
$diagTbl = (new Diagnosis())->getTable();
$inList = implode(',', array_map('intval', $ids));
$query->where(function ($q) use ($alias, $diagTbl, $inList): void {
$q->whereIn("{$alias}.creator_id", explode(',', $inList));
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id` "
. "AND dg.`delete_time` IS NULL AND dg.`assistant_id` IN ({$inList}))"
);
});
}
/**
* @param array<string,mixed> $row
*/
@@ -323,7 +486,16 @@ class PrescriptionOrderLogic
if ($payOrderIds === []) {
return '未关联支付单,关联支付单总金额须大于等于定金门槛 ¥' . $min;
}
// 豁免权:关联的支付单中有豁免单则跳过金额校验
$hasExempt = Order::whereIn('id', $payOrderIds)
->whereNull('delete_time')
->where('is_exempt', 1)
->count();
if ($hasExempt > 0) {
return null;
}
// 检查关联支付单的总金额
$rows = Order::whereIn('id', $payOrderIds)->whereNull('delete_time')->column('amount', 'id');
$totalAmount = 0.0;
@@ -503,7 +675,7 @@ class PrescriptionOrderLogic
return [];
}
$rows = Order::whereIn('id', $ids)->whereNull('delete_time')
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark'])
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
->order('id', 'asc')
->select()
->toArray();
@@ -1049,11 +1221,13 @@ class PrescriptionOrderLogic
}
/**
* 业务订单列表行批量附加 linked_appointment(解析规则与详情一致:优先处方 appointment_id,否则诊单最近一条预约
* 业务订单列表行批量附加 linked_appointment(解析规则与详情一致:优先处方 appointment_id,否则诊单最近一条预约
* 业绩侧栏且带 channel_code 时优先展示与渠道收窄同源的已完成挂号,避免列表行属自媒体2Q 但展示关联为自媒体1
*
* @param array<int, array<string, mixed>> $lists
* @param array<string, mixed> $listsParams PrescriptionOrderLists 请求参数(可选)
*/
public static function appendLinkedAppointmentsToListRows(array &$lists): void
public static function appendLinkedAppointmentsToListRows(array &$lists, array $listsParams = []): void
{
if ($lists === []) {
return;
@@ -1063,6 +1237,23 @@ class PrescriptionOrderLogic
}
unset($item);
$yejiChannelHighlightByDiag = [];
if (
(int) ($listsParams['yeji_order_drawer'] ?? 0) === 1
&& trim((string) ($listsParams['channel_code'] ?? '')) !== ''
) {
$allDiagIdsForYeji = array_values(array_unique(array_filter(
array_map(static fn ($x): int => (int) ($x['diagnosis_id'] ?? 0), $lists),
static fn (int $id): bool => $id > 0
)));
if ($allDiagIdsForYeji !== []) {
$yejiChannelHighlightByDiag = YejiStatsLogic::batchResolveYejiChannelDrawerHighlightAppointmentIds(
$allDiagIdsForYeji,
$listsParams
);
}
}
$rxIds = array_values(array_unique(array_filter(array_map(
static fn ($x) => (int) ($x['prescription_id'] ?? 0),
$lists
@@ -1113,6 +1304,10 @@ class PrescriptionOrderLogic
if ($resolved <= 0 && $dgId > 0) {
$resolved = (int) ($latestApptByDiag[$dgId] ?? 0);
}
$preferredYeji = $dgId > 0 ? (int) ($yejiChannelHighlightByDiag[$dgId] ?? 0) : 0;
if ($preferredYeji > 0) {
$resolved = $preferredYeji;
}
if ($resolved > 0) {
$resolvedPairs[$idx] = [$resolved, $rxApptId];
}
@@ -1247,16 +1442,16 @@ class PrescriptionOrderLogic
return false;
}
// 已成功提交甘草 SCM(生成了甘草处方单号 或 submit_time>0),再改本地信息会与甘草侧不一致,禁止编辑
if ($fs === 6) {
self::$error = '已签收订单不可修改快递单号';
return false;
}
// 已成功提交甘草 SCM:仅允许更新快递单号与承运商(与甘草侧地址/药方等仍以取消流程为准)
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no);
$gcSubmitTime = (int) $order->gancao_submit_time;
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
self::$error = sprintf(
'订单已提交甘草(处方单号:%s),不可再编辑;如需修改请先走取消流程',
$gcOrderNo !== '' ? $gcOrderNo : '—'
);
return false;
return self::editGancaoLogisticsOnly($order, $params, $adminId, $adminInfo);
}
$medDays = $params['medication_days'] ?? null;
@@ -1390,6 +1585,47 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 甘草已提交后仅更新物流字段(tracking_number、express_company),忽略金额/地址等其它请求参数。
*
* @param array<string,mixed> $params
* @return array<string,mixed>|false
*/
private static function editGancaoLogisticsOnly(
PrescriptionOrder $order,
array $params,
int $adminId,
array $adminInfo
) {
$order->tracking_number = mb_substr(trim((string) ($params['tracking_number'] ?? '')), 0, 80);
if (array_key_exists('express_company', $params)) {
$order->express_company = self::normalizeExpressCompany($params['express_company']);
}
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
self::writeLog(
(int) $order->id,
$adminId,
$adminInfo,
'edit',
'甘草订单已提交,仅更新快递信息(单号/承运商)'
);
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 确认发货:更新快递信息并将 fulfillment_status 2(履约中)推进到 5(已发货)
*
@@ -2230,7 +2466,7 @@ class PrescriptionOrderLogic
return $out;
}
private static function fulfillmentStatusLabel(int $fs): string
public static function fulfillmentStatusLabel(int $fs): string
{
$m = [
1 => '待双审通过',
@@ -2250,6 +2486,301 @@ class PrescriptionOrderLogic
return $m[$fs] ?? ('状态' . (string) $fs);
}
/**
* 导出用:诊单医助在 admin_dept 中的部门路径(多部门「;」、路径内「 / 」)
*/
public static function formatAssistantDeptPathForExport(int $assistantAdminId): string
{
if ($assistantAdminId <= 0) {
return '';
}
$deptIds = Db::name('admin_dept')->where('admin_id', $assistantAdminId)->column('dept_id');
$pathSegments = [];
foreach ($deptIds as $did) {
$p = self::buildDeptPath((int) $did);
if ($p !== '') {
$pathSegments[] = $p;
}
}
$pathSegments = array_values(array_unique($pathSegments));
return $pathSegments === [] ? '' : implode('', $pathSegments);
}
/**
* 导出用:药品形态(丸剂 / 饮片+代煎|自煎等)
*
* @param array<string, mixed> $rx
*/
public static function formatMedicationFormForExport(array $rx): string
{
$type = trim((string) ($rx['prescription_type'] ?? ''));
if ($type === '') {
return '';
}
if ($type === '饮片') {
$nd = (int) ($rx['need_decoction'] ?? 0);
return $type . ($nd === 1 ? '(代煎)' : '(自煎)');
}
return $type;
}
/**
* 导出列:挂号表渠道来源展示(与 AppointmentLists channel_source_desc 同字典口径)
*
* @param array<string, mixed> $apRow doctor_appointment 一行
* @param array<string, string> $channelNameByValue DictData channels value=>name
*/
private static function formatGuahaoChannelSourceForExport(array $apRow, array $channelNameByValue): string
{
$srcKey = trim((string) ($apRow['channel_source'] ?? ''));
if ($srcKey === '' && isset($apRow['channels']) && $apRow['channels'] !== '' && $apRow['channels'] !== null) {
$srcKey = trim((string) $apRow['channels']);
}
$label = '';
if ($srcKey !== '') {
$label = (string) ($channelNameByValue[$srcKey] ?? $channelNameByValue[(string) (int) $srcKey] ?? $srcKey);
}
$detail = trim((string) ($apRow['channel_source_detail'] ?? ''));
if ($label === '' && $detail === '') {
return '';
}
if ($detail !== '') {
return $label !== '' ? "{$label}{$detail}" : $detail;
}
return $label;
}
/**
* 挂号表渠道相关列是否存在(未加 channel_source 的老库仅查 channels 或整列留空)
*
* @return array{channel_source: bool, channel_source_detail: bool, channels: bool}
*/
private static function doctorAppointmentChannelColumnsForExport(): array
{
$out = [
'channel_source' => false,
'channel_source_detail' => false,
'channels' => false,
];
try {
$cols = Db::name('doctor_appointment')->getTableFields();
if (!is_array($cols)) {
return $out;
}
foreach (array_keys($out) as $k) {
$out[$k] = in_array($k, $cols, true);
}
} catch (\Throwable) {
// keep false
}
return $out;
}
/**
* 业务订单列表导出:写入与 PrescriptionOrderLists::setExcelFields 对应的字段(export=2 时由列表类调用)
*
* @param array<int, array<string, mixed>> $lists
*/
public static function appendPrescriptionOrderListExportRows(array &$lists, array $adminInfo): void
{
if ($lists === []) {
return;
}
$diagIds = [];
$rxIds = [];
$poIds = [];
foreach ($lists as $row) {
$poIds[] = (int) ($row['id'] ?? 0);
$d = (int) ($row['diagnosis_id'] ?? 0);
if ($d > 0) {
$diagIds[$d] = true;
}
$r = (int) ($row['prescription_id'] ?? 0);
if ($r > 0) {
$rxIds[$r] = true;
}
}
$poIds = array_values(array_filter(array_unique($poIds), static fn (int $id): bool => $id > 0));
$diagIdList = array_keys($diagIds);
$rxIdList = array_keys($rxIds);
$diagById = [];
if ($diagIdList !== []) {
$diagRows = Diagnosis::whereIn('id', $diagIdList)->whereNull('delete_time')
->field(['id', 'patient_name', 'gender', 'age', 'phone', 'assistant_id'])
->select()
->toArray();
foreach ($diagRows as $dr) {
$diagById[(int) $dr['id']] = $dr;
}
}
/** 诊单 ID => 该患者最近一条挂号(按预约日、id 倒序),用于导出「挂号渠道来源」 */
$apptChannelByDiagId = [];
$channelNameByValue = [];
$chCols = self::doctorAppointmentChannelColumnsForExport();
$needGuahaoChannel = $chCols['channel_source'] || $chCols['channels'] || $chCols['channel_source_detail'];
if ($diagIdList !== [] && $needGuahaoChannel) {
$selectFields = ['patient_id', 'appointment_date', 'id'];
if ($chCols['channel_source']) {
$selectFields[] = 'channel_source';
}
if ($chCols['channel_source_detail']) {
$selectFields[] = 'channel_source_detail';
}
if ($chCols['channels']) {
$selectFields[] = 'channels';
}
$apptRows = Appointment::whereIn('patient_id', $diagIdList)
->field($selectFields)
->order('appointment_date', 'desc')
->order('id', 'desc')
->select()
->toArray();
foreach ($apptRows as $ar) {
$p = (int) ($ar['patient_id'] ?? 0);
if ($p > 0 && !isset($apptChannelByDiagId[$p])) {
$apptChannelByDiagId[$p] = $ar;
}
}
if ($chCols['channel_source'] || $chCols['channels']) {
$channelNameByValue = DictData::where('type_value', 'channels')->column('name', 'value');
}
}
$rxById = [];
if ($rxIdList !== []) {
$rxRows = Prescription::whereIn('id', $rxIdList)->whereNull('delete_time')
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit'])
->select()
->toArray();
foreach ($rxRows as $xr) {
$rxById[(int) $xr['id']] = $xr;
}
}
$logRows = PrescriptionOrderLog::whereIn('prescription_order_id', $poIds)
->whereIn('action', ['audit_rx_approve', 'audit_rx_reject', 'revoke_rx_audit'])
->field(['prescription_order_id', 'id', 'action', 'admin_name', 'create_time'])
->order('id', 'asc')
->select()
->toArray();
$auditEffectiveByPo = [];
foreach ($logRows as $lg) {
$pid = (int) ($lg['prescription_order_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$act = (string) ($lg['action'] ?? '');
if ($act === 'revoke_rx_audit') {
$auditEffectiveByPo[$pid] = null;
continue;
}
if ($act === 'audit_rx_approve' || $act === 'audit_rx_reject') {
$auditEffectiveByPo[$pid] = $lg;
}
}
$assistantDeptCache = [];
foreach ($lists as &$item) {
$poId = (int) ($item['id'] ?? 0);
$fs = (int) ($item['fulfillment_status'] ?? 0);
$item['export_fulfillment_status_text'] = self::fulfillmentStatusLabel($fs);
$ct = (int) ($item['create_time'] ?? 0);
$item['export_order_time'] = $ct > 0 ? date('Y-m-d H:i:s', $ct) : '';
$diagId = (int) ($item['diagnosis_id'] ?? 0);
$dg = $diagById[$diagId] ?? null;
$item['export_patient_name'] = \is_array($dg) ? (string) ($dg['patient_name'] ?? '') : '';
if (\is_array($dg)) {
$g = (int) ($dg['gender'] ?? 0);
$item['export_patient_gender'] = $g === 1 ? '男' : '女';
$item['export_patient_age'] = (string) ($dg['age'] ?? '');
$item['export_patient_phone'] = (string) ($dg['phone'] ?? '');
$astId = (int) ($dg['assistant_id'] ?? 0);
if ($astId > 0) {
if (!isset($assistantDeptCache[$astId])) {
$assistantDeptCache[$astId] = self::formatAssistantDeptPathForExport($astId);
}
$item['export_assistant_dept'] = $assistantDeptCache[$astId];
} else {
$item['export_assistant_dept'] = '';
}
} else {
$item['export_patient_gender'] = '';
$item['export_patient_age'] = '';
$item['export_patient_phone'] = '';
$item['export_assistant_dept'] = '';
}
$rxId = (int) ($item['prescription_id'] ?? 0);
$rx = $rxById[$rxId] ?? [];
$item['export_medication_form'] = self::formatMedicationFormForExport(\is_array($rx) ? $rx : []);
$item['export_channel'] = (string) ($item['service_channel'] ?? '');
$apCh = $apptChannelByDiagId[$diagId] ?? null;
$item['export_guahao_channel_source'] = \is_array($apCh)
? self::formatGuahaoChannelSourceForExport($apCh, $channelNameByValue)
: '';
$md = $item['medication_days'] ?? null;
$item['export_medication_days'] = $md !== null && $md !== '' ? (string) $md : '';
$amt = round((float) ($item['amount'] ?? 0), 2);
$paid = round((float) ($item['linked_pay_paid_total'] ?? 0), 2);
$item['export_amount'] = number_format($amt, 2, '.', '');
$item['export_paid_amount'] = number_format($paid, 2, '.', '');
$item['export_agency_collect'] = number_format(round($amt - $paid, 2), 2, '.', '');
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
$isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '';
$item['export_supply_mode'] = $isGc ? '甘草' : '自营';
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
if (self::canViewInternalCost($adminInfo)) {
$rawCost = $item['internal_cost'] ?? null;
$item['export_gancao_prescription_cost'] = $rawCost !== null && $rawCost !== ''
? number_format(round((float) $rawCost, 2), 2, '.', '')
: '';
} else {
$item['export_gancao_prescription_cost'] = '';
}
$isFu = (int) ($item['is_follow_up'] ?? 0) === 1;
$item['export_first_visit_assistant'] = $isFu ? (string) ($item['prev_staff'] ?? '') : '';
$hit = (int) ($item['po_assign_snapshot_hit'] ?? 0);
$rel = (int) ($item['po_assign_is_release'] ?? 0);
$er = (int) ($item['po_assign_to_er_center'] ?? 0);
if ($hit !== 1) {
$item['export_center_label'] = '—';
} elseif ($rel === 1) {
$item['export_center_label'] = '已释放';
} elseif ($er === 1) {
$item['export_center_label'] = '二中心';
} else {
$item['export_center_label'] = '一中心';
}
$paSt = (int) ($item['prescription_audit_status'] ?? 0);
$aud = $auditEffectiveByPo[$poId] ?? null;
if (($paSt === 1 || $paSt === 2) && \is_array($aud)) {
$at = (int) ($aud['create_time'] ?? 0);
$item['export_rx_audit_time'] = $at > 0 ? date('Y-m-d H:i:s', $at) : '';
$item['export_rx_auditor'] = (string) ($aud['admin_name'] ?? '');
} else {
$item['export_rx_audit_time'] = '';
$item['export_rx_auditor'] = '';
}
}
unset($item);
}
/**
* 完成订单时把关联诊单的 assistant_id 清空,让患者回到「待分配」状态
* 仅当该患者没有其它进行中的处方订单且无指派日志时才清空,避免误覆盖
@@ -21,10 +21,14 @@ class AppointmentValidate extends BaseValidate
'doctor_id' => 'require|integer',
'diagnosis_id' => 'require|integer',
'appointment_date' => 'require|date',
'period' => 'in:morning,afternoon,all',
'appointment_time' => 'require',
'appointment_type' => 'in:video,text,phone',
'period' => 'in:morning,afternoon,all',
'appointment_type' => 'require|in:video,text,phone',
'status' => 'require|integer|between:1,4',
'remark' => 'max:500',
'channel_source' => 'require',
'channel_source_detail' => 'max:128',
'ids' => 'require|array',
'note_id' => 'require|integer',
'image_type' => 'require|in:tongue_images,report_files',
'image_path' => 'require',
@@ -40,10 +44,15 @@ class AppointmentValidate extends BaseValidate
'doctor_id' => '医生ID',
'diagnosis_id' => '诊单ID',
'appointment_date' => '预约日期',
'period' => '时段',
'appointment_time' => '预约时间',
'period' => '时段',
'appointment_type' => '预约类型',
'status' => '状态',
'remark' => '备注',
'assistant_id' => '医助',
'channel_source' => '渠道来源',
'channel_source_detail' => '渠道补充说明',
'ids' => '预约ID列表',
];
/**
@@ -52,7 +61,7 @@ class AppointmentValidate extends BaseValidate
*/
public function sceneCreate()
{
return $this->only(['patient_id', 'doctor_id', 'appointment_date', 'period', 'appointment_time', 'appointment_type', 'channel_source']);
return $this->only(['patient_id', 'doctor_id', 'appointment_date', 'period', 'appointment_time', 'appointment_type', 'channel_source', 'channel_source_detail']);
}
/**
@@ -123,4 +132,28 @@ class AppointmentValidate extends BaseValidate
{
return $this->only(['note_id', 'image_type', 'image_path']);
}
/**
* 后台挂号编辑(消费者处方-挂号列表等)
*/
public function sceneAdminEdit()
{
return $this->only([
'id',
'appointment_date',
'appointment_time',
'period',
'appointment_type',
'status',
'remark',
'channel_source',
'channel_source_detail',
]);
}
/** 批量修改挂号渠道(权限同 doctor.appointment/edit */
public function sceneBatchEditChannel()
{
return $this->only(['ids', 'channel_source', 'channel_source_detail']);
}
}
@@ -392,7 +392,7 @@ class GancaoCallbackController extends BaseApiController
$nu = (string) ($ext['nu'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
if ($nu !== '') {
if ($nu !== '' && trim((string) ($order->tracking_number ?? '')) === '') {
$order->tracking_number = mb_substr($nu, 0, 80);
}
if ($shippingName !== '') {
+3 -3
View File
@@ -18,7 +18,7 @@ use think\console\Output;
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关)
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
*
* 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
*/
class ExpressAutoUpdate extends Command
@@ -36,8 +36,8 @@ class ExpressAutoUpdate extends Command
$startTime = microtime(true);
try {
$result = ExpressTrackingService::autoUpdateBatch(500);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(500);
$result = ExpressTrackingService::autoUpdateBatch(1000);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(1000);
$duration = round(microtime(true) - $startTime, 2);
@@ -29,7 +29,7 @@ use think\console\Output;
* 建议 crontab(每 30 分钟执行一次):
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> runtime/log/gancao_sync.log 2>&1
*
* 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 已对「业务订单创建人非二中心」(发货/签收自动释放规则见 ExpressTrackingService的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
*/
class GancaoSyncLogisticsRoute extends Command
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\service\gancao\GancaoScmRecipelService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 历史 internal_cost 回填:对每条业务订单调用甘草预下单(CTM_PREVIEW),
* 将返回 fee 的「药材成本 + 制作费 + 物流费」合计写入 zyt_tcm_prescription_order.internal_cost。
*
* php think tcm:backfill-internal-cost --dry-run
* php think tcm:backfill-internal-cost --limit=30 --sleep-ms=400
* php think tcm:backfill-internal-cost --order-id=123
* php think tcm:backfill-internal-cost --force
* php think tcm:backfill-internal-cost --null-or-zero --min-id=1000
* php think tcm:backfill-internal-cost --fulfillment-status=5,6,3
*
* 履约状态代码(fulfillment_status):1待双审通过 2待发货 3已完成 4已取消 5已发货 6已签收
* 7进行中 8暂不制药 9拒收 10退款 11保留药方 12制药缓发
*
* 说明:使用 root 上下文仅用于绕过后台「谁能看哪张处方/订单」校验;不落管理员登录态。
* 甘草未配置、药材无法匹配、规则拦截、缺药等会跳过并在末尾汇总。
*/
class TcmBackfillPrescriptionOrderInternalCost extends Command
{
protected function configure(): void
{
$this->setName('tcm:backfill-internal-cost')
->setDescription('批量用甘草预报价回填处方业务订单 internal_cost')
->addOption('dry-run', null, Option::VALUE_NONE, '只演练不写库')
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '最多处理条数', 100)
->addOption('order-id', null, Option::VALUE_OPTIONAL, '仅处理指定 prescription_order.id', null)
->addOption('force', 'f', Option::VALUE_NONE, '覆盖已有 internal_cost(默认只填 NULL')
->addOption('null-or-zero', null, Option::VALUE_NONE, '与默认一致且同时处理 internal_cost=0 的行(仍可用 --force 覆盖任意值)')
->addOption('min-id', null, Option::VALUE_OPTIONAL, '仅 id>=该值', null)
->addOption('max-id', null, Option::VALUE_OPTIONAL, '仅 id<=该值', null)
->addOption(
'fulfillment-status',
null,
Option::VALUE_OPTIONAL,
'仅处理指定履约状态,逗号分隔数字,如 5,6,3=已发货/已签收/已完成;不传则不限',
null
)
->addOption('sleep-ms', null, Option::VALUE_OPTIONAL, '每条成功调用后休眠毫秒,减轻开放平台压力', 250)
->addOption('detail', 'd', Option::VALUE_NONE, '打印每条明细');
}
/**
* @return array{admin_id:int, root:int, name:string, role_id:int[]}
*/
private static function cliAdminInfo(): array
{
return [
'admin_id' => 0,
'root' => 1,
'name' => 'CLI-internal-cost',
'role_id' => [],
];
}
/**
* @param array<string, mixed> $fee
*/
private static function totalFeeFromPreview(array $fee): float
{
$m = (float) ($fee['m_cost'] ?? 0);
$p = (float) ($fee['proces_cost'] ?? 0);
$l = (float) ($fee['lis_cost'] ?? 0);
return round($m + $p + $l, 2);
}
/**
* 解析 CLI 传入的履约状态列表(112,与 PrescriptionOrderLogic::fulfillmentStatusLabel 一致)
*
* @return int[] 去重后的状态码;$raw 非空但解析不到合法值时返回 null 表示调用方应报错
*/
private static function parseFulfillmentStatusOption(?string $raw): ?array
{
if ($raw === null) {
return [];
}
$raw = trim($raw);
if ($raw === '') {
return [];
}
$seen = [];
foreach (explode(',', $raw) as $part) {
$n = (int) trim($part);
if ($n >= 1 && $n <= 12) {
$seen[$n] = true;
}
}
$ids = array_keys($seen);
sort($ids);
return $ids !== [] ? $ids : null;
}
protected function execute(Input $input, Output $output): int
{
$dryRun = (bool) $input->getOption('dry-run');
$verbose = (bool) $input->getOption('detail');
$force = (bool) $input->getOption('force');
$nullOrZero = (bool) $input->getOption('null-or-zero');
$limit = max(1, (int) $input->getOption('limit'));
$sleepMs = max(0, (int) $input->getOption('sleep-ms'));
$onlyOrderId = $input->getOption('order-id');
$onlyOrderId = $onlyOrderId !== null && $onlyOrderId !== '' ? (int) $onlyOrderId : null;
$minId = $input->getOption('min-id');
$minId = $minId !== null && $minId !== '' ? (int) $minId : null;
$maxId = $input->getOption('max-id');
$maxId = $maxId !== null && $maxId !== '' ? (int) $maxId : null;
$fsRaw = $input->getOption('fulfillment-status');
$fsRaw = $fsRaw !== null ? (string) $fsRaw : null;
$fulfillmentStatuses = self::parseFulfillmentStatusOption($fsRaw);
if ($fulfillmentStatuses === null) {
$output->error('--fulfillment-status 格式无效,请传入 1–12 的数字,英文逗号分隔,例如:5,6,3');
return 1;
}
$adminInfo = self::cliAdminInfo();
$adminId = 0;
$output->writeln('========================================');
$output->writeln('批量回填 internal_cost(甘草 CTM_PREVIEW');
$output->writeln('========================================');
if (!GancaoScmRecipelService::isConfigured()) {
$output->error('甘草 SCM 未配置:' . GancaoScmRecipelService::whyNotConfigured());
return 1;
}
$q = PrescriptionOrder::whereNull('delete_time')->where('prescription_id', '>', 0)->order('id', 'asc');
if ($fulfillmentStatuses !== []) {
$q->whereIn('fulfillment_status', $fulfillmentStatuses);
}
if ($onlyOrderId !== null && $onlyOrderId > 0) {
$q->where('id', $onlyOrderId);
} else {
if (!$force) {
if ($nullOrZero) {
$q->whereRaw('(internal_cost IS NULL OR internal_cost = 0)');
} else {
$q->whereNull('internal_cost');
}
}
if ($minId !== null && $minId > 0) {
$q->where('id', '>=', $minId);
}
if ($maxId !== null && $maxId > 0) {
$q->where('id', '<=', $maxId);
}
$q->limit($limit);
}
$rows = $q->field(['id', 'order_no', 'dose_count', 'medication_days', 'internal_cost', 'fulfillment_status'])->select()->toArray();
if ($rows === []) {
$output->warning('没有符合条件的订单。');
return 0;
}
$fsLabel = '';
if ($fulfillmentStatuses !== []) {
$parts = [];
foreach ($fulfillmentStatuses as $code) {
$parts[] = (string) $code . '=' . PrescriptionOrderLogic::fulfillmentStatusLabel((int) $code);
}
$fsLabel = ' fulfillment-status=[' . implode(',', $parts) . ']';
}
$output->writeln(sprintf(
'待处理 %d 条(dry-run=%s force=%s sleep-ms=%d%s',
count($rows),
$dryRun ? 'yes' : 'no',
$force ? 'yes' : 'no',
$sleepMs,
$fsLabel
));
$ok = 0;
$fail = 0;
$skipped = 0;
$reasons = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!$force && !$nullOrZero && $row['internal_cost'] !== null && $row['internal_cost'] !== '') {
$skipped++;
continue;
}
$params = [];
$dc = (int) ($row['dose_count'] ?? 0);
if ($dc > 0) {
$params['dose_count'] = $dc;
}
$md = $row['medication_days'] ?? null;
if ($md !== null && $md !== '' && (int) $md > 0) {
$params['medication_days'] = (int) $md;
}
$ret = PrescriptionOrderLogic::previewGancaoRecipel($id, $adminId, $adminInfo, $params);
if ($ret === false) {
$fail++;
$err = PrescriptionOrderLogic::getError();
$reasons[$err] = ($reasons[$err] ?? 0) + 1;
if ($verbose) {
$output->writeln("<error>#{$id} 失败:{$err}</error>");
}
continue;
}
$fee = is_array($ret['fee'] ?? null) ? $ret['fee'] : [];
$total = self::totalFeeFromPreview($fee);
if ($verbose) {
$ono = (string) ($row['order_no'] ?? '');
$fs = (int) ($row['fulfillment_status'] ?? 0);
$fst = PrescriptionOrderLogic::fulfillmentStatusLabel($fs);
$output->writeln(sprintf('#%d %s [%s] → internal_cost=%.2f', $id, $ono, $fst, $total));
}
if (!$dryRun) {
PrescriptionOrder::where('id', $id)->whereNull('delete_time')->update([
'internal_cost' => $total,
]);
$log = new PrescriptionOrderLog();
$log->prescription_order_id = $id;
$log->admin_id = 0;
$log->admin_name = 'CLI';
$log->action = 'cli_backfill_internal_cost';
$log->summary = mb_substr(sprintf('甘草预报价回填 internal_cost=%.2f', $total), 0, 500);
$log->create_time = time();
try {
$log->save();
} catch (\Throwable $e) {
// 忽略
}
}
$ok++;
if ($sleepMs > 0 && !$dryRun) {
usleep($sleepMs * 1000);
}
}
$output->writeln('');
$output->writeln(sprintf('完成:成功 %d,失败 %d,跳过 %d', $ok, $fail, $skipped));
if ($reasons !== []) {
$output->writeln('失败原因统计:');
foreach ($reasons as $msg => $cnt) {
$output->writeln(' [' . $cnt . 'x] ' . $msg);
}
}
return $fail > 0 ? 2 : 0;
}
}
@@ -58,6 +58,12 @@ abstract class BaseDataLists implements ListsInterface
public string $export;
/**
* 管理端列表:在 request 就绪后写入 adminId/adminInfo(见 BaseAdminDataLists
*/
protected function initAdminIdentity(): void
{
}
public function __construct()
{
@@ -66,6 +72,8 @@ abstract class BaseDataLists implements ListsInterface
//请求参数设置
$this->request = request();
// admin 列表子类在 initExport 中可能触发 count/lists,须先于 initPage/initExport 写入身份
$this->initAdminIdentity();
$this->params = $this->request->param();
//分页初始化
@@ -249,9 +249,7 @@ class ExpressTrackingService
/**
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)。
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。
* 例外:指派日志中若存在「从某医助改派到另一医助」(原、新医助 ID 均大于 0)的记录,视为人工指派链,自动释放跳过
* 例外:患者当前医助在「二中心」及其全部子部门(与后台部门树一致)下任职时,不自动清空医助(按 admin_dept dept 树判定)。
* 非二中心且已自动释放:诊单写入 shipped_non_er_assistant_cleared_atassistant_id 已为 0 时后续履约核对不再重复扫描(重新指派医助后可再次处理)。
* 不自动清空的情形:(1)按业务订单 creator_id(代建场景)或回退身份判断是否在「二中心」部门子树;(2)诊单已有 shipped_non_er_assistant_cleared_at assistant_id 已为 0(视为已处理);(3)指派日志存在后台人工指派:`operator_admin_id>0` `to_assistant_id>0`(系统任务为 operator_admin_id=0);或 related_po_creator_id=from_assistant_id to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
@@ -290,47 +288,55 @@ class ExpressTrackingService
if (!$diag) {
return null;
}
// 后台曾人工指派到新医助(operator_admin_id>0 且 to>0,与系统写入的 operator=0 区分)时不再自动清空;另保留快照 related_po=from 的旧数据兼容
if (self::diagnosisShouldSkipAutoAssistantReleaseDueToManualAssignLog($diagnosisId)) {
Log::info('Skipped auto assistant release (shipped/signed): manual assign history exists (human operator + to_assistant_id>0, or snapshot-tied pattern)', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
]);
return null;
}
$shippedClearedAt = (int) ($diag->getAttr('shipped_non_er_assistant_cleared_at') ?? 0);
$diagAssistantIdNow = (int) ($diag->getAttr('assistant_id') ?? 0);
if ($shippedClearedAt > 0 && $diagAssistantIdNow === 0) {
return null;
}
$poCreatorId = (int) ($order->getAttr('creator_id') ?? 0);
$rxId = (int) ($order->getAttr('prescription_id') ?? 0);
$rxCreator = 0;
if ($rxId > 0) {
$rxCreator = (int) Prescription::where('id', $rxId)->whereNull('delete_time')->value('creator_id');
}
// 诊单 assistant_id 与列表「医助」筛选一致:医助常代建单但诊单未写 assistant_id,此时用创建人作为待释放身份(创建人即开方医生时不采用,避免误记指派日志)
$fromAssistantId = (int) ($diag->getAttr('assistant_id') ?? 0);
if ($fromAssistantId <= 0) {
$creatorId = (int) ($order->getAttr('creator_id') ?? 0);
if ($creatorId > 0) {
$rxId = (int) ($order->getAttr('prescription_id') ?? 0);
$rxCreator = 0;
if ($rxId > 0) {
$rxCreator = (int) Prescription::where('id', $rxId)->whereNull('delete_time')->value('creator_id');
}
if ($rxId <= 0 || $creatorId !== $rxCreator) {
$fromAssistantId = $creatorId;
}
if ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator)) {
$fromAssistantId = $poCreatorId;
}
}
if ($fromAssistantId <= 0) {
return null;
}
if (self::diagnosisHasFormalAssistantReassignInAssignLog($diagnosisId)) {
Log::info('Skipped auto assistant release (shipped/signed): formal assistant reassign in assign log', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
'current_assistant_id' => $fromAssistantId,
]);
/** 是否因「二中心」跳过释放:优先看业务订单创建人 creator_id(代建场景);创建人即开方医生时用 fromAssistantId */
$erZhongxinCheckAdminId = ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator))
? $poCreatorId
: $fromAssistantId;
return null;
}
if (self::currentAssistantBelongsToErzhongxinDeptTree($fromAssistantId)) {
Log::info('Skipped auto assistant release (shipped/signed): assistant under 二中心 dept tree', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
'current_assistant_id' => $fromAssistantId,
if ($erZhongxinCheckAdminId > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($erZhongxinCheckAdminId)) {
Log::info('Skipped auto assistant release (shipped/signed): order creator (or fallback from-id) under 二中心 dept tree', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
'er_zhongxin_check_admin_id' => $erZhongxinCheckAdminId,
'prescription_order_creator_id' => $poCreatorId,
'from_assistant_id_for_log' => $fromAssistantId,
]);
return null;
@@ -454,18 +460,36 @@ class ExpressTrackingService
}
/**
* 是否存在「由原医助改派到新医助」的指派记录(两端 admin 均大于 0)。有此记录则已发货自动释放医助不执行
* 若诊单曾被后台人工指派到具体医助,则不再执行发运/签收自动清空(避免「系统·已发货履约核对」覆盖人工链)
* 判定:operator_admin_id>0 to_assistant_id>0(人工);系统写入的释放/同步为 operator_admin_id=0
* 另保留 related_po_creator_id=from_assistant_id to>0 的窄条件,兼容早期数据。
*/
private static function diagnosisHasFormalAssistantReassignInAssignLog(int $diagnosisId): bool
private static function diagnosisShouldSkipAutoAssistantReleaseDueToManualAssignLog(int $diagnosisId): bool
{
if ($diagnosisId <= 0) {
return false;
}
return DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->where('from_assistant_id', '>', 0)
$tbl = 'tcm_diagnosis_assign_log';
$humanReassign = (int) Db::name($tbl)
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', '>', 0)
->count() > 0;
->where('operator_admin_id', '>', 0)
->count();
if ($humanReassign > 0) {
return true;
}
$snapshotTied = (int) Db::name($tbl)
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', '>', 0)
->where('from_assistant_id', '>', 0)
->where('related_po_creator_id', '>', 0)
->whereRaw('`related_po_creator_id` = `from_assistant_id`')
->count();
return $snapshotTied > 0;
}
private static function maybeClearDiagnosisAssistantWhenShippedPrescription(ExpressTracking $tracking): ?array
@@ -646,9 +670,8 @@ class ExpressTrackingService
}
/**
* 按处方订单履约核对:已发货(5)/已签收(6) 诊单仍挂在医助的,一律释放并写指派日志
* 与快递 100 / 甘草来自哪个物流渠道无关,保障「已发货」后患者侧医助被清掉
* 已对非二中心医助做过发货/签收释放且 assistant_id=0 的诊单(shipped_non_er_assistant_cleared_at>0)不再纳入本扫描,避免重复命中 creator 分支。
* 按处方订单履约核对:已发货(5)/已签收(6) 符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)
*
* @return array{scanned: int, cleared: int, lines: list<string>}
*/
@@ -714,7 +737,7 @@ class ExpressTrackingService
* assistant_cleared: int,
* assistant_skipped_assign_log: int,
* assistant_lines: list<string>
* } assistant_skipped_assign_log 保留兼容恒为 0(已发货/签收一律写指派日志并清空医助)
* } assistant_skipped_assign_log 保留兼容恒为 0
*/
public static function autoUpdateBatch(int $limit = 50): array
{
@@ -839,4 +862,237 @@ class ExpressTrackingService
return $data;
}
/**
* PrescriptionOrderLogic::logisticsTrace 一致:优先库表 express_tracking + express_trace
* 库内算不出签收时(或库无记录),可选走快递100(与详情「刷新轨迹」同源)。
*
* 提成结算 orderLines 等对 SQL JOIN 无法覆盖「仅 API 有轨迹」的运单做补算。
*
* @param bool $queryKuaidiWhenDbUnusable true 时与 logisticsTrace 完全同路径;false 时仅数据库推导(overview 大批量避免 HTTP
*/
public static function resolveSignUnixTimeForCommission(
string $trackingNumber,
string $expressCompany,
string $recipientPhone,
bool $queryKuaidiWhenDbUnusable = true
): int {
$num = trim($trackingNumber);
if ($num === '') {
return 0;
}
$cacheKey = $num . '|' . ($queryKuaidiWhenDbUnusable ? '1' : '0');
static $memo = [];
if (isset($memo[$cacheKey])) {
return $memo[$cacheKey];
}
$detail = self::loadTrackingDetailNormalized($num);
if (is_array($detail)) {
$fromDb = self::effectiveSignUnixFromTrackingDetail($detail);
if ($fromDb > 0) {
$memo[$cacheKey] = $fromDb;
return $fromDb;
}
}
if (!$queryKuaidiWhenDbUnusable) {
$memo[$cacheKey] = 0;
return 0;
}
$ec = strtolower(trim($expressCompany));
if ($ec === '') {
$ec = 'auto';
}
$api = ExpressTrackService::query($ec, $num, $recipientPhone, '');
$fromApi = self::effectiveSignUnixFromKuaidiPayload($api);
$memo[$cacheKey] = $fromApi;
return $fromApi;
}
/**
* 单号精确匹配失败时尝试 TRIM(TrackingNumber)(历史数据首尾空格)。
*
* @return array<string, mixed>|null
*/
private static function loadTrackingDetailNormalized(string $trackingNumber): ?array
{
$n = trim($trackingNumber);
if ($n === '') {
return null;
}
$first = self::getDetailByTrackingNumber($n);
if ($first !== null) {
return $first;
}
$tracking = ExpressTracking::with(['traces' => function ($query) {
$query->order('trace_time_stamp', 'desc');
}])
->whereRaw('TRIM(`tracking_number`) = ?', [$n])
->whereNull('delete_time')
->find();
if (!$tracking) {
return null;
}
$data = $tracking->toArray();
$data['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
return $data;
}
/**
* YejiStatsLogic 物流子查询中每条 express_tracking GREATEST 语义对齐。
*
* @param array<string, mixed> $detail getDetail* / loadTrackingDetailNormalized 结果
*/
private static function effectiveSignUnixFromTrackingDetail(array $detail): int
{
$a = (int) ($detail['sign_time'] ?? 0);
$traces = $detail['traces'] ?? [];
if (!is_array($traces)) {
$traces = [];
}
$b = 0;
foreach ($traces as $tr) {
if (!is_array($tr)) {
continue;
}
if (!self::dbTraceRowLooksSigned($tr)) {
continue;
}
$ts = self::traceRowToUnix($tr);
if ($ts > 0) {
$b = max($b, $ts);
}
}
$c = 0;
$stateOk = ((string) ($detail['current_state'] ?? '') === ExpressTracking::STATE_SIGNED
|| (int) ($detail['is_signed'] ?? 0) === 1);
if ($stateOk) {
foreach ($traces as $tr) {
if (!is_array($tr)) {
continue;
}
$ts = self::traceRowToUnix($tr);
if ($ts > 0) {
$c = max($c, $ts);
}
}
}
return max($a, $b, $c);
}
/**
* @param array<string, mixed> $tr express_trace
*/
private static function dbTraceRowLooksSigned(array $tr): bool
{
$code = (string) ($tr['status_code'] ?? '');
if (in_array($code, ['3', '301', '302', '304'], true)) {
return true;
}
$st = (string) ($tr['status'] ?? '');
$ctx = (string) ($tr['trace_context'] ?? '');
$hay = $st . ' ' . $ctx;
foreach (['签收', '妥投', '已送达', '本人签收'] as $k) {
if (mb_stripos($hay, $k) !== false) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $tr
*/
private static function traceRowToUnix(array $tr): int
{
$ts = (int) ($tr['trace_time_stamp'] ?? 0);
if ($ts > 0) {
return $ts;
}
$raw = trim((string) ($tr['trace_time'] ?? ''));
if ($raw === '') {
return 0;
}
$p = strtotime($raw);
return $p !== false ? (int) $p : 0;
}
/**
* @param array<string, mixed> $api ExpressTrackService::query 返回值
*/
private static function effectiveSignUnixFromKuaidiPayload(array $api): int
{
$traces = $api['traces'] ?? [];
if (!is_array($traces) || $traces === []) {
return 0;
}
$state = (string) ($api['state'] ?? '');
$best = 0;
foreach ($traces as $row) {
if (!is_array($row)) {
continue;
}
$tstr = (string) ($row['time'] ?? '');
$ctx = (string) ($row['context'] ?? '');
$p = strtotime($tstr);
$ts = $p !== false ? (int) $p : 0;
if ($ts <= 0) {
continue;
}
$signedLike = self::plainContextLooksSigned($ctx);
if ($signedLike) {
$best = max($best, $ts);
}
}
if ($best > 0) {
return $best;
}
if ($state === '3') {
foreach ($traces as $row) {
if (!is_array($row)) {
continue;
}
$tstr = (string) ($row['time'] ?? '');
$p = strtotime($tstr);
$ts = $p !== false ? (int) $p : 0;
if ($ts > 0) {
$best = max($best, $ts);
}
}
}
return $best;
}
private static function plainContextLooksSigned(string $context): bool
{
foreach (['签收', '妥投', '已送达', '本人签收', '快件已送达'] as $k) {
if (mb_stripos($context, $k) !== false) {
return true;
}
}
return false;
}
}
+22
View File
@@ -21,6 +21,7 @@ use app\common\enum\ExportEnum;
use app\common\lists\BaseDataLists;
use app\common\lists\ListsExcelInterface;
use app\common\lists\ListsExtendInterface;
use think\facade\Config;
use think\Response;
use think\response\Json;
use think\exception\HttpResponseException;
@@ -120,12 +121,16 @@ class JsonService
{
//获取导出信息
if ($lists->export == ExportEnum::INFO && $lists instanceof ListsExcelInterface) {
self::relaxLimitsForExcelExport();
return self::data($lists->excelInfo());
}
//获取导出文件的下载链接
if ($lists->export == ExportEnum::EXPORT && $lists instanceof ListsExcelInterface) {
self::relaxLimitsForExcelExport();
$exportDownloadUrl = $lists->createExcel($lists->setExcelFields(), $lists->lists());
return self::success('', ['url' => $exportDownloadUrl], 2);
}
@@ -141,4 +146,21 @@ class JsonService
}
return self::success('', $data, 1, 0);
}
/**
* Excel 导出:拉数 + PhpSpreadsheet 易超过默认 max_execution_time=30
*/
private static function relaxLimitsForExcelExport(): void
{
@set_time_limit(0);
$max = Config::get('project.lists.export_max_execution_time', 600);
$max = is_numeric($max) ? (int) $max : 600;
if ($max > 0) {
@ini_set('max_execution_time', (string) $max);
}
$mem = Config::get('project.lists.export_memory_limit', '512M');
if (is_string($mem) && $mem !== '') {
@ini_set('memory_limit', $mem);
}
}
}
@@ -74,7 +74,14 @@ final class GancaoLogisticsRouteService
}
$result = $resp['result'];
$trackingNumber = trim((string) ($result['sp_order_no'] ?? ($order->tracking_number ?? '')));
$apiTracking = trim((string) ($result['sp_order_no'] ?? ''));
$localTracking = trim((string) ($order->tracking_number ?? ''));
// 业务订单已录快递单号时:不改用甘草返回的 sp_order_no,轨迹仍写入本地单号对应的运单记录
if ($localTracking !== '') {
$trackingNumber = mb_substr($localTracking, 0, 80);
} else {
$trackingNumber = mb_substr($apiTracking, 0, 80);
}
$spName = trim((string) ($result['sp_name'] ?? ($order->gancao_shipping_name ?? '')));
$routeList = is_array($result['route_list'] ?? null) ? $result['route_list'] : [];
@@ -87,7 +94,7 @@ final class GancaoLogisticsRouteService
// 回写订单的快递单号 / 物流公司(如果之前空着)+ 签收时升级 fulfillment_status
$orderDirty = false;
if (trim((string) $order->tracking_number) === '') {
if ($localTracking === '') {
$order->tracking_number = mb_substr($trackingNumber, 0, 80);
$orderDirty = true;
}
+2
View File
@@ -32,6 +32,8 @@ return [
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
// 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW
'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost',
// 迁移诊单图片到医生备注表
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
+13
View File
@@ -47,6 +47,9 @@ return [
'lists' => [
'page_size_max' => 25000,//列表页查询数量限制(列表页每页数量、导出每页数量)
'page_size' => 25, //默认每页数量
/** Excel 导出:接口内放宽 PHP 限制(JsonService::relaxLimitsForExcelExport */
'export_max_execution_time' => 600,
'export_memory_limit' => '512M',
],
// 各种默认图片
@@ -137,6 +140,16 @@ return [
'exempt_roles' => [],
],
/*
* 提成结算 stats.commissionSettlementYejiStatsLogic
* require_system_auto_prescriptiontrue(默认)仅系统代开口径(is_system_auto=1),常与订单列表「全部已完成」条数不一致。
* false 时与列表一致纳入手动开方单(仍需履约完成、业绩归因等其它条件)。
* .env 可选:commission_settlement.require_system_auto = 1|0 ,未配置时读本项。
*/
'commission_settlement' => [
'require_system_auto_prescription' => (int) env('commission_settlement.require_system_auto', 1) === 1,
],
// 腾讯云实时音视频(TRTC)配置
'trtc' => [
'sdkAppId' => (int)env('trtc.sdk_app_id', 1600127710),
@@ -0,0 +1,7 @@
-- 二诊医助:新指派患者已读标记
ALTER TABLE `zyt_tcm_diagnosis`
ADD COLUMN `assign_read_at` int(10) unsigned DEFAULT NULL
COMMENT '医助查看指派时间戳,NULL 未读(新指派红旗)' AFTER `assistant_id`;
-- 已有数据标记为已读,避免全部显示红旗
UPDATE `zyt_tcm_diagnosis` SET `assign_read_at` = UNIX_TIMESTAMP() WHERE `assistant_id` > 0;
@@ -0,0 +1,13 @@
-- 支付单:设置/取消豁免权(权限节点)
SET @order_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'order.order/lists' LIMIT 1);
INSERT INTO zyt_system_menu (
pid, type, name, icon, sort, perms, paths, component,
selected, params, is_cache, is_show, is_disable, create_time, update_time
)
SELECT
@order_menu_id, 'A', '设置豁免权', '', 0, 'order.order/setExempt', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @order_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'order.order/setExempt');
@@ -0,0 +1,3 @@
ALTER TABLE `zyt_order`
ADD COLUMN `is_exempt` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT '豁免权:1=关联此单时跳过定金门槛校验' AFTER `status`;
@@ -1 +1 @@
import r from"./error-BMS1qEWQ.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-tF_2qJHK.js";import"./element-plus-kM-bcufB.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-BjeWxBI0.js";import"./index-BIddbE7W.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
import r from"./error-Drkso1hy.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-DFTWCWyi.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-3gMrVgPp.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -1 +1 @@
import o from"./error-BMS1qEWQ.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-tF_2qJHK.js";import"./element-plus-kM-bcufB.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-BjeWxBI0.js";import"./index-BIddbE7W.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
import o from"./error-Drkso1hy.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-DFTWCWyi.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-3gMrVgPp.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
@@ -1 +1 @@
import{H as u}from"../highlight.js-Bxt7hFFy.js";import{f as c,i as g,w as s,A as n}from"../@vue/runtime-core-tF_2qJHK.js";import{n as h}from"../@vue/reactivity-BmZ-20sQ.js";var i=c({props:{code:{type:String,required:!0},language:{type:String,default:""},autodetect:{type:Boolean,default:!0},ignoreIllegals:{type:Boolean,default:!0}},setup:function(e){var t=h(e.language);s((function(){return e.language}),(function(a){t.value=a}));var r=n((function(){return e.autodetect||!t.value})),o=n((function(){return!r.value&&!u.getLanguage(t.value)}));return{className:n((function(){return o.value?"":"hljs "+t.value})),highlightedCode:n((function(){var a;if(o.value)return console.warn('The language "'+t.value+'" you specified could not be found.'),e.code.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;");if(r.value){var l=u.highlightAuto(e.code);return t.value=(a=l.language)!==null&&a!==void 0?a:"",l.value}return(l=u.highlight(e.code,{language:t.value,ignoreIllegals:e.ignoreIllegals})).value}))}},render:function(){return g("pre",{},[g("code",{class:this.className,innerHTML:this.highlightedCode})])}}),v={install:function(e){e.component("highlightjs",i)},component:i};export{v as o};
import{H as u}from"../highlight.js-Bxt7hFFy.js";import{f as c,i as g,w as s,A as n}from"../@vue/runtime-core-C6bnekPw.js";import{n as h}from"../@vue/reactivity-DiY1c2vO.js";var i=c({props:{code:{type:String,required:!0},language:{type:String,default:""},autodetect:{type:Boolean,default:!0},ignoreIllegals:{type:Boolean,default:!0}},setup:function(e){var t=h(e.language);s((function(){return e.language}),(function(a){t.value=a}));var r=n((function(){return e.autodetect||!t.value})),o=n((function(){return!r.value&&!u.getLanguage(t.value)}));return{className:n((function(){return o.value?"":"hljs "+t.value})),highlightedCode:n((function(){var a;if(o.value)return console.warn('The language "'+t.value+'" you specified could not be found.'),e.code.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;");if(r.value){var l=u.highlightAuto(e.code);return t.value=(a=l.language)!==null&&a!==void 0?a:"",l.value}return(l=u.highlight(e.code,{language:t.value,ignoreIllegals:e.ignoreIllegals})).value}))}},render:function(){return g("pre",{},[g("code",{class:this.className,innerHTML:this.highlightedCode})])}}),v={install:function(e){e.component("highlightjs",i)},component:i};export{v as o};
@@ -1 +1 @@
import"./uikit-base-component-vue3-CTQKyL8o.js";import{A as r}from"../tuikit-atomicx-vue3-Bf7F3z1j.js";import{f as s,ak as c,I as l,aq as m,G as u,at as i,H as p,A as d}from"../@vue/runtime-core-tF_2qJHK.js";import{y as v}from"../@vue/reactivity-BmZ-20sQ.js";import"./chat-uikit-engine-zx802ozq.js";const _=(t,o)=>{const a=t.__vccOpts||t;for(const[e,n]of o)a[e]=n;return a},f={key:0,class:"chat"},h=s({name:"Chat",__name:"Chat",props:{PlaceholderEmpty:{default:null}},setup(t){const{activeConversation:o}=r(),a=d(()=>{var e;return!((e=o.value)!=null&&e.conversationID)});return(e,n)=>v(o)?(c(),l("div",f,[m(e.$slots,"default",{},void 0,!0)])):a.value&&t.PlaceholderEmpty?(c(),u(i(t.PlaceholderEmpty),{key:1})):p("",!0)}}),A=_(h,[["__scopeId","data-v-1c9c77cd"]]);typeof window<"u"&&(window.__CHAT_ATOMICX_VUE3__={name:"@tencentcloud/chat-uikit-vue3",version:"4.5.4"},console.log("[@tencentcloud/chat-uikit-vue3] v4.5.4"));export{A as E};
import"./uikit-base-component-vue3-YgTqL4da.js";import{A as r}from"../tuikit-atomicx-vue3-Dln8Zi6e.js";import{f as s,ak as c,I as l,aq as m,G as u,at as i,H as p,A as d}from"../@vue/runtime-core-C6bnekPw.js";import{y as v}from"../@vue/reactivity-DiY1c2vO.js";import"./chat-uikit-engine-zx802ozq.js";const _=(t,o)=>{const a=t.__vccOpts||t;for(const[e,n]of o)a[e]=n;return a},f={key:0,class:"chat"},h=s({name:"Chat",__name:"Chat",props:{PlaceholderEmpty:{default:null}},setup(t){const{activeConversation:o}=r(),a=d(()=>{var e;return!((e=o.value)!=null&&e.conversationID)});return(e,n)=>v(o)?(c(),l("div",f,[m(e.$slots,"default",{},void 0,!0)])):a.value&&t.PlaceholderEmpty?(c(),u(i(t.PlaceholderEmpty),{key:1})):p("",!0)}}),A=_(h,[["__scopeId","data-v-1c9c77cd"]]);typeof window<"u"&&(window.__CHAT_ATOMICX_VUE3__={name:"@tencentcloud/chat-uikit-vue3",version:"4.5.4"},console.log("[@tencentcloud/chat-uikit-vue3] v4.5.4"));export{A as E};
@@ -1,4 +1,4 @@
import{g as Ue}from"../lodash-D3kF6u-c.js";import{c as Ut,B as jt,n as rt,a as at,o as Wt,q as zt,b as qt,w as Gt,d as kt,e as Xt,f as Jt,g as B,F as ct,S as Qt,h as Yt,i as Zt,j as te,u as ee,k as se,l as ne,s as Z,r as tt,C as je,D as We,E as ze,m as qe,K as Ge,p as ke,T as Xe,t as Je,v as Qe,x as Ye,y as Ze,z as ts,A as es,G as ss,H as ns,I as os,J as is,L as rs,M as as,N as cs,O as ls,P as fs,Q as us,R as ps,U as ds,V as hs,W as ms,X as gs,Y as _s,Z as Ss,_ as bs,$ as Cs,a0 as ys,a1 as vs,a2 as Ts,a3 as ws,a4 as Es,a5 as As,a6 as Rs,a7 as Ps,a8 as Ms,a9 as Ns,aa as xs,ab as Os,ac as Vs,ad as Ds,ae as Ls,af as Is,ag as Bs,ah as Hs,ai as $s,aj as Fs,ak as Ks,al as Us,am as js,an as Ws,ao as zs,ap as qs,aq as Gs,ar as ks,as as Xs,at as Js,au as Qs,av as Ys,aw as Zs,ax as tn,ay as en,az as sn,aA as nn,aB as on,aC as rn,aD as an,aE as cn,aF as ln,aG as fn,aH as un,aI as pn,aJ as dn,aK as hn,aL as mn,aM as gn,aN as _n,aO as Sn,aP as bn,aQ as Cn,aR as yn}from"./runtime-core-tF_2qJHK.js";import{j as oe,n as L,C as v,e as T,h as vn,z as M,l as ie,s as Tn,D as wn,E as X,N as En,i as g,K as q,k as H,F as I,A as lt,I as et,L as re,f as An,M as Rn,O as Pn,u as Mn,G as ae,a as Nn,o as xn,P as On,p as Vn,Q as Dn,B as Ln}from"./shared-mAAVTE9n.js";import{y as ce,t as le,E as In,R as Bn,T as Hn,z as $n,q as Fn,A as Kn,B as Un,C as jn,D as Wn,i as zn,o as qn,x as Gn,a as kn,v as Xn,m as Jn,F as Qn,G as Yn,p as Zn,r as to,H as eo,n as so,s as no,c as oo,u as io,I as ro,J as ao,K as co,L as lo,M as fo}from"./reactivity-BmZ-20sQ.js";/**
import{g as Ue}from"../lodash-D3kF6u-c.js";import{c as Ut,B as jt,n as rt,a as at,o as Wt,q as zt,b as qt,w as Gt,d as kt,e as Xt,f as Jt,g as B,F as ct,S as Qt,h as Yt,i as Zt,j as te,u as ee,k as se,l as ne,s as Z,r as tt,C as je,D as We,E as ze,m as qe,K as Ge,p as ke,T as Xe,t as Je,v as Qe,x as Ye,y as Ze,z as ts,A as es,G as ss,H as ns,I as os,J as is,L as rs,M as as,N as cs,O as ls,P as fs,Q as us,R as ps,U as ds,V as hs,W as ms,X as gs,Y as _s,Z as Ss,_ as bs,$ as Cs,a0 as ys,a1 as vs,a2 as Ts,a3 as ws,a4 as Es,a5 as As,a6 as Rs,a7 as Ps,a8 as Ms,a9 as Ns,aa as xs,ab as Os,ac as Vs,ad as Ds,ae as Ls,af as Is,ag as Bs,ah as Hs,ai as $s,aj as Fs,ak as Ks,al as Us,am as js,an as Ws,ao as zs,ap as qs,aq as Gs,ar as ks,as as Xs,at as Js,au as Qs,av as Ys,aw as Zs,ax as tn,ay as en,az as sn,aA as nn,aB as on,aC as rn,aD as an,aE as cn,aF as ln,aG as fn,aH as un,aI as pn,aJ as dn,aK as hn,aL as mn,aM as gn,aN as _n,aO as Sn,aP as bn,aQ as Cn,aR as yn}from"./runtime-core-C6bnekPw.js";import{j as oe,n as L,C as v,e as T,h as vn,z as M,l as ie,s as Tn,D as wn,E as X,N as En,i as g,K as q,k as H,F as I,A as lt,I as et,L as re,f as An,M as Rn,O as Pn,u as Mn,G as ae,a as Nn,o as xn,P as On,p as Vn,Q as Dn,B as Ln}from"./shared-mAAVTE9n.js";import{y as ce,t as le,E as In,R as Bn,T as Hn,z as $n,u as Fn,A as Kn,B as Un,C as jn,D as Wn,i as zn,o as qn,x as Gn,a as kn,v as Xn,m as Jn,F as Qn,G as Yn,p as Zn,r as to,H as eo,n as so,s as no,c as oo,q as io,I as ro,J as ao,K as co,L as lo,M as fo}from"./reactivity-DiY1c2vO.js";/**
* @vue/runtime-dom v3.5.29
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{C as A,F as C,a as w,L as h,u as D,J as R,H as g,q as k,n as W}from"../@vue/reactivity-BmZ-20sQ.js";import{w as b,b as x,n as L,g as F,$ as M,a5 as P}from"../@vue/runtime-core-tF_2qJHK.js";function J(e){return A()?(C(e),!0):!1}const d=new WeakMap,z=(...e)=>{var t;const r=e[0],n=(t=F())==null?void 0:t.proxy;if(n==null&&!M())throw new Error("injectLocal must be called in setup");return n&&d.has(n)&&r in d.get(n)?d.get(n)[r]:P(...e)},B=typeof window<"u"&&typeof document<"u";typeof WorkerGlobalScope<"u"&&globalThis instanceof WorkerGlobalScope;const K=e=>typeof e<"u",I=Object.prototype.toString,Q=e=>I.call(e)==="[object Object]",m=()=>{};function j(e,t){function r(...n){return new Promise((a,o)=>{Promise.resolve(e(()=>t.apply(this,n),{fn:t,thisArg:this,args:n})).then(a).catch(o)})}return r}const S=e=>e();function V(...e){let t=0,r,n=!0,a=m,o,s,i,u,c;!w(e[0])&&typeof e[0]=="object"?{delay:s,trailing:i=!0,leading:u=!0,rejectOnCancel:c=!1}=e[0]:[s,i=!0,u=!0,c=!1]=e;const f=()=>{r&&(clearTimeout(r),r=void 0,a(),a=m)};return O=>{const l=h(s),v=Date.now()-t,p=()=>o=O();return f(),l<=0?(t=Date.now(),p()):(v>l&&(u||!n)?(t=Date.now(),p()):i&&(o=new Promise((y,T)=>{a=c?T:y,r=setTimeout(()=>{t=Date.now(),n=!0,y(p()),f()},Math.max(0,l-v))})),!u&&!r&&(r=setTimeout(()=>n=!0,l)),n=!1,o)}}function E(e=S,t={}){const{initialState:r="active"}=t,n=N(r==="active");function a(){n.value=!1}function o(){n.value=!0}const s=(...i)=>{n.value&&e(...i)};return{isActive:g(n),pause:a,resume:o,eventFilter:s}}function U(e){return e.endsWith("rem")?Number.parseFloat(e)*16:Number.parseFloat(e)}function G(e){return F()}function X(e){return Array.isArray(e)?e:[e]}function N(...e){if(e.length!==1)return R(...e);const t=e[0];return typeof t=="function"?g(k(()=>({get:t,set:m}))):W(t)}function Y(e,t=200,r=!1,n=!0,a=!1){return j(V(t,r,n,a),e)}function _(e,t,r={}){const{eventFilter:n=S,...a}=r;return b(e,j(n,t),a)}function Z(e,t,r={}){const{eventFilter:n,initialState:a="active",...o}=r,{eventFilter:s,pause:i,resume:u,isActive:c}=E(n,{initialState:a});return{stop:_(e,t,{...o,eventFilter:s}),pause:i,resume:u,isActive:c}}function ee(e,t=!0,r){G()?x(e,r):t?e():L(e)}function te(e=!1,t={}){const{truthyValue:r=!0,falsyValue:n=!1}=t,a=w(e),o=D(e);function s(i){if(arguments.length)return o.value=i,o.value;{const u=h(r);return o.value=o.value===u?h(n):u,o.value}}return a?s:[o,s]}function ne(e,t,r){return b(e,t,{...r,immediate:!0})}export{N as a,ee as b,Q as c,X as d,Z as e,z as f,K as g,Y as h,B as i,U as p,J as t,te as u,ne as w};
import{C as A,F as C,a as w,L as h,q as D,J as R,H as g,u as k,n as W}from"../@vue/reactivity-DiY1c2vO.js";import{w as b,b as x,n as L,g as F,$ as M,a5 as P}from"../@vue/runtime-core-C6bnekPw.js";function J(e){return A()?(C(e),!0):!1}const d=new WeakMap,z=(...e)=>{var t;const r=e[0],n=(t=F())==null?void 0:t.proxy;if(n==null&&!M())throw new Error("injectLocal must be called in setup");return n&&d.has(n)&&r in d.get(n)?d.get(n)[r]:P(...e)},B=typeof window<"u"&&typeof document<"u";typeof WorkerGlobalScope<"u"&&globalThis instanceof WorkerGlobalScope;const K=e=>typeof e<"u",I=Object.prototype.toString,Q=e=>I.call(e)==="[object Object]",m=()=>{};function j(e,t){function r(...n){return new Promise((a,o)=>{Promise.resolve(e(()=>t.apply(this,n),{fn:t,thisArg:this,args:n})).then(a).catch(o)})}return r}const S=e=>e();function V(...e){let t=0,r,n=!0,a=m,o,s,i,u,c;!w(e[0])&&typeof e[0]=="object"?{delay:s,trailing:i=!0,leading:u=!0,rejectOnCancel:c=!1}=e[0]:[s,i=!0,u=!0,c=!1]=e;const f=()=>{r&&(clearTimeout(r),r=void 0,a(),a=m)};return O=>{const l=h(s),v=Date.now()-t,p=()=>o=O();return f(),l<=0?(t=Date.now(),p()):(v>l&&(u||!n)?(t=Date.now(),p()):i&&(o=new Promise((y,T)=>{a=c?T:y,r=setTimeout(()=>{t=Date.now(),n=!0,y(p()),f()},Math.max(0,l-v))})),!u&&!r&&(r=setTimeout(()=>n=!0,l)),n=!1,o)}}function E(e=S,t={}){const{initialState:r="active"}=t,n=N(r==="active");function a(){n.value=!1}function o(){n.value=!0}const s=(...i)=>{n.value&&e(...i)};return{isActive:g(n),pause:a,resume:o,eventFilter:s}}function U(e){return e.endsWith("rem")?Number.parseFloat(e)*16:Number.parseFloat(e)}function G(e){return F()}function X(e){return Array.isArray(e)?e:[e]}function N(...e){if(e.length!==1)return R(...e);const t=e[0];return typeof t=="function"?g(k(()=>({get:t,set:m}))):W(t)}function Y(e,t=200,r=!1,n=!0,a=!1){return j(V(t,r,n,a),e)}function _(e,t,r={}){const{eventFilter:n=S,...a}=r;return b(e,j(n,t),a)}function Z(e,t,r={}){const{eventFilter:n,initialState:a="active",...o}=r,{eventFilter:s,pause:i,resume:u,isActive:c}=E(n,{initialState:a});return{stop:_(e,t,{...o,eventFilter:s}),pause:i,resume:u,isActive:c}}function ee(e,t=!0,r){G()?x(e,r):t?e():L(e)}function te(e=!1,t={}){const{truthyValue:r=!0,falsyValue:n=!1}=t,a=w(e),o=D(e);function s(i){if(arguments.length)return o.value=i,o.value;{const u=h(r);return o.value=o.value===u?h(n):u,o.value}}return a?s:[o,s]}function ne(e,t,r){return b(e,t,{...r,immediate:!0})}export{N as a,ee as b,Q as c,X as d,Z as e,z as f,K as g,Y as h,B as i,U as p,J as t,te as u,ne as w};
@@ -1,2 +1,2 @@
import{i as C,Q as y,a as E}from"./editor-Cyf37SuL.js";import{ak as g,I as h,f as w,b as P,w as O,aJ as b}from"../@vue/runtime-core-tF_2qJHK.js";import{n as d,t as $,u as F}from"../@vue/reactivity-BmZ-20sQ.js";var B=Object.defineProperty,D=Object.defineProperties,j=Object.getOwnPropertyDescriptors,m=Object.getOwnPropertySymbols,H=Object.prototype.hasOwnProperty,S=Object.prototype.propertyIsEnumerable,_=(e,t,o)=>t in e?B(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o,A=(e,t)=>{for(var o in t||(t={}))H.call(t,o)&&_(e,o,t[o]);if(m)for(var o of m(t))S.call(t,o)&&_(e,o,t[o]);return e},M=(e,t)=>D(e,j(t));function u(e){let t=`请使用 '@${e}' 事件,不要放在 props 中`;return t+=`
Please use '@${e}' event instead of props`,t}var v=(e,t)=>{for(const[o,a]of t)e[o]=a;return e};const V=w({props:{mode:{type:String,default:"default"},defaultContent:{type:Array,default:[]},defaultHtml:{type:String,default:""},defaultConfig:{type:Object,default:{}},modelValue:{type:String,default:""}},setup(e,t){const o=d(null),a=F(null),i=d(""),s=()=>{if(!o.value)return;const f=$(e.defaultContent);C({selector:o.value,mode:e.mode,content:f||[],html:e.defaultHtml||e.modelValue||"",config:M(A({},e.defaultConfig),{onCreated(r){if(a.value=r,t.emit("onCreated",r),e.defaultConfig.onCreated){const n=u("onCreated");throw new Error(n)}},onChange(r){const n=r.getHtml();if(i.value=n,t.emit("update:modelValue",n),t.emit("onChange",r),e.defaultConfig.onChange){const l=u("onChange");throw new Error(l)}},onDestroyed(r){if(t.emit("onDestroyed",r),e.defaultConfig.onDestroyed){const n=u("onDestroyed");throw new Error(n)}},onMaxLength(r){if(t.emit("onMaxLength",r),e.defaultConfig.onMaxLength){const n=u("onMaxLength");throw new Error(n)}},onFocus(r){if(t.emit("onFocus",r),e.defaultConfig.onFocus){const n=u("onFocus");throw new Error(n)}},onBlur(r){if(t.emit("onBlur",r),e.defaultConfig.onBlur){const n=u("onBlur");throw new Error(n)}},customAlert(r,n){if(t.emit("customAlert",r,n),e.defaultConfig.customAlert){const l=u("customAlert");throw new Error(l)}},customPaste:(r,n)=>{if(e.defaultConfig.customPaste){const c=u("customPaste");throw new Error(c)}let l;return t.emit("customPaste",r,n,c=>{l=c}),l}})})};function p(f){const r=a.value;r!=null&&r.setHtml(f)}return P(()=>{s()}),O(()=>e.modelValue,f=>{f!==i.value&&p(f)}),{box:o}}}),I={ref:"box",style:{height:"100%"}};function L(e,t,o,a,i,s){return g(),h("div",I,null,512)}var N=v(V,[["render",L]]);const T=w({props:{editor:{type:Object},mode:{type:String,default:"default"},defaultConfig:{type:Object,default:{}}},setup(e){const t=d(null),o=a=>{if(t.value){if(a==null)throw new Error("Not found instance of Editor when create <Toolbar/> component");y.getToolbar(a)||E({editor:a,selector:t.value||"<div></div>",mode:e.mode,config:e.defaultConfig})}};return b(()=>{const{editor:a}=e;a!=null&&o(a)}),{selector:t}}}),R={ref:"selector"};function k(e,t,o,a,i,s){return g(),h("div",R,null,512)}var q=v(T,[["render",k]]);export{N as E,q as T};
import{i as C,Q as y,a as E}from"./editor-Cyf37SuL.js";import{ak as g,I as h,f as w,b as P,w as O,aJ as b}from"../@vue/runtime-core-C6bnekPw.js";import{n as d,t as $,q as F}from"../@vue/reactivity-DiY1c2vO.js";var B=Object.defineProperty,D=Object.defineProperties,j=Object.getOwnPropertyDescriptors,m=Object.getOwnPropertySymbols,H=Object.prototype.hasOwnProperty,S=Object.prototype.propertyIsEnumerable,_=(e,t,o)=>t in e?B(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o,A=(e,t)=>{for(var o in t||(t={}))H.call(t,o)&&_(e,o,t[o]);if(m)for(var o of m(t))S.call(t,o)&&_(e,o,t[o]);return e},M=(e,t)=>D(e,j(t));function u(e){let t=`请使用 '@${e}' 事件,不要放在 props 中`;return t+=`
Please use '@${e}' event instead of props`,t}var v=(e,t)=>{for(const[o,a]of t)e[o]=a;return e};const V=w({props:{mode:{type:String,default:"default"},defaultContent:{type:Array,default:[]},defaultHtml:{type:String,default:""},defaultConfig:{type:Object,default:{}},modelValue:{type:String,default:""}},setup(e,t){const o=d(null),a=F(null),i=d(""),s=()=>{if(!o.value)return;const f=$(e.defaultContent);C({selector:o.value,mode:e.mode,content:f||[],html:e.defaultHtml||e.modelValue||"",config:M(A({},e.defaultConfig),{onCreated(r){if(a.value=r,t.emit("onCreated",r),e.defaultConfig.onCreated){const n=u("onCreated");throw new Error(n)}},onChange(r){const n=r.getHtml();if(i.value=n,t.emit("update:modelValue",n),t.emit("onChange",r),e.defaultConfig.onChange){const l=u("onChange");throw new Error(l)}},onDestroyed(r){if(t.emit("onDestroyed",r),e.defaultConfig.onDestroyed){const n=u("onDestroyed");throw new Error(n)}},onMaxLength(r){if(t.emit("onMaxLength",r),e.defaultConfig.onMaxLength){const n=u("onMaxLength");throw new Error(n)}},onFocus(r){if(t.emit("onFocus",r),e.defaultConfig.onFocus){const n=u("onFocus");throw new Error(n)}},onBlur(r){if(t.emit("onBlur",r),e.defaultConfig.onBlur){const n=u("onBlur");throw new Error(n)}},customAlert(r,n){if(t.emit("customAlert",r,n),e.defaultConfig.customAlert){const l=u("customAlert");throw new Error(l)}},customPaste:(r,n)=>{if(e.defaultConfig.customPaste){const c=u("customPaste");throw new Error(c)}let l;return t.emit("customPaste",r,n,c=>{l=c}),l}})})};function p(f){const r=a.value;r!=null&&r.setHtml(f)}return P(()=>{s()}),O(()=>e.modelValue,f=>{f!==i.value&&p(f)}),{box:o}}}),I={ref:"box",style:{height:"100%"}};function L(e,t,o,a,i,s){return g(),h("div",I,null,512)}var J=v(V,[["render",L]]);const T=w({props:{editor:{type:Object},mode:{type:String,default:"default"},defaultConfig:{type:Object,default:{}}},setup(e){const t=d(null),o=a=>{if(t.value){if(a==null)throw new Error("Not found instance of Editor when create <Toolbar/> component");y.getToolbar(a)||E({editor:a,selector:t.value||"<div></div>",mode:e.mode,config:e.defaultConfig})}};return b(()=>{const{editor:a}=e;a!=null&&o(a)}),{selector:t}}}),R={ref:"selector"};function k(e,t,o,a,i,s){return g(),h("div",R,null,512)}var N=v(T,[["render",k]]);export{J as E,N as T};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.cell-stack[data-v-05090333]{display:flex;flex-direction:column;gap:2px;line-height:1.35}
@@ -0,0 +1 @@
.cell-stack[data-v-4de87dfa]{display:flex;flex-direction:column;gap:2px;line-height:1.35}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{M as x,N as y,r as I,d as C,L as $}from"./element-plus-DFTWCWyi.js";import{W as D}from"./@element-plus/icons-vue-HOEUG8sr.js";import{a0 as L}from"./tcm-FwHxrTHD.js";import{f as T,w as P,ak as g,I as A,aP as E,G as k,aN as n,a,O as m,J as B}from"./@vue/runtime-core-C6bnekPw.js";import{Q as p}from"./@vue/shared-mAAVTE9n.js";import{y as F,n as b}from"./@vue/reactivity-DiY1c2vO.js";import{_ as M}from"./index-3gMrVgPp.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const V={class:"assign-log-panel"},K=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(h,{expose:w}){const c=h,d=b(!1),_=b([]);function v(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function N(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const i=new Date(t*1e3);if(Number.isNaN(i.getTime()))return"—";const r=s=>String(s).padStart(2,"0");return`${i.getFullYear()}-${r(i.getMonth()+1)}-${r(i.getDate())} ${r(i.getHours())}:${r(i.getMinutes())}:${r(i.getSeconds())}`}function u(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",i=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const s=Number(o[i]);return Number.isFinite(s)&&s>0?`ID:${s}`:"—"}const f=async()=>{if(c.diagnosisId){d.value=!0;try{const o=await L({id:c.diagnosisId}),e=Array.isArray(o)?o:[];_.value=e}catch(o){console.error(o),_.value=[]}finally{d.value=!1}}};return P(()=>c.diagnosisId,()=>{f()},{immediate:!0}),w({refresh:f}),(o,e)=>{const t=y,i=C,r=I,s=x,S=$;return g(),A("div",V,[E((g(),k(s,{data:_.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[a(t,{label:"操作时间",width:"175",prop:"create_time_text"}),a(t,{label:"原医助","min-width":"120"},{default:n(({row:l})=>[m(p(u(l,"from")),1)]),_:1}),a(t,{label:"新医助","min-width":"120"},{default:n(({row:l})=>[m(p(u(l,"to")),1)]),_:1}),a(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:l})=>[m(p(v(l)),1)]),_:1}),a(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[0]||(e[0]=B("span",null,"快照·业务单创建时间",-1)),a(r,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[a(i,{class:"assign-log-col-hint"},{default:n(()=>[a(F(D))]),_:1})]),_:1})]),default:n(({row:l})=>[m(p(N(l)),1)]),_:1}),a(t,{label:"操作人",width:"110",prop:"operator_name"}),a(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),a(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[S,d.value]])])}}}),yt=M(K,[["__scopeId","data-v-5465d5eb"]]);export{yt as default};
@@ -0,0 +1 @@
.assign-log-col-hint[data-v-5465d5eb]{margin-left:4px;vertical-align:middle;color:var(--el-text-color-secondary);cursor:help}
@@ -1 +0,0 @@
import{_ as o}from"./AssignLogPanel.vue_vue_type_script_setup_true_lang-DjSF_Yo0.js";import"./element-plus-kM-bcufB.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/runtime-core-tF_2qJHK.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@vue/shared-mAAVTE9n.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./tcm-BJy_atdx.js";import"./index-BIddbE7W.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-BjeWxBI0.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";export{o as default};
@@ -1 +0,0 @@
import{L as m,N as f,M as u}from"./element-plus-kM-bcufB.js";import{_ as w}from"./tcm-BJy_atdx.js";import{f as h,w as g,ak as n,I as v,aP as b,G as x,aN as y,a as e}from"./@vue/runtime-core-tF_2qJHK.js";import{n as l}from"./@vue/reactivity-BmZ-20sQ.js";const I={class:"assign-log-panel"},E=h({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(c,{expose:p}){const t=c,s=l(!1),i=l([]),r=async()=>{if(t.diagnosisId){s.value=!0;try{const o=await w({id:t.diagnosisId});i.value=Array.isArray(o)?o:[]}catch(o){console.error(o),i.value=[]}finally{s.value=!1}}};return g(()=>t.diagnosisId,()=>{r()},{immediate:!0}),p({refresh:r}),(o,L)=>{const a=f,d=u,_=m;return n(),v("div",I,[b((n(),x(d,{data:i.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:y(()=>[e(a,{label:"操作时间",width:"175",prop:"create_time_text"}),e(a,{label:"原医助","min-width":"120",prop:"from_assistant_name"}),e(a,{label:"新医助","min-width":"120",prop:"to_assistant_name"}),e(a,{label:"操作人",width:"110",prop:"operator_name"}),e(a,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),e(a,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[_,s.value]])])}}});export{E as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.call-record-panel .call-record-tip[data-v-b392e2ba]{margin:0 0 8px;line-height:1.5;font-size:13px}.call-record-panel .call-record-tip.muted[data-v-b392e2ba]{color:var(--el-text-color-secondary)}.call-record-panel .call-record-tip code[data-v-b392e2ba]{font-size:12px;word-break:break-all}.call-record-panel .recording-list[data-v-b392e2ba]{display:flex;flex-direction:column;gap:4px}.call-record-panel .recording-video[data-v-b392e2ba]{max-width:100%;max-height:180px;border-radius:4px;background:#000}
@@ -1 +0,0 @@
import{K as E,L,N,_ as P,M as T}from"./element-plus-kM-bcufB.js";import{a3 as B}from"./tcm-BJy_atdx.js";import{f as R,w as V,ak as e,I as i,a as o,aP as D,G as u,aN as s,O as n,F,ap as A}from"./@vue/runtime-core-tF_2qJHK.js";import{Q as l}from"./@vue/shared-mAAVTE9n.js";import{n as g}from"./@vue/reactivity-BmZ-20sQ.js";import{_ as G}from"./index-BIddbE7W.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-BjeWxBI0.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const K={class:"call-record-panel"},M={key:0,class:"text-primary"},O={key:1,class:"text-gray-400"},Q={key:0,class:"recording-list"},S=["src"],$={key:1,class:"text-gray-400"},j=R({__name:"CallRecordPanel",props:{diagnosisId:{}},setup(h,{expose:y}){const p=h,m=g(!1),c=g([]),_=async()=>{if(p.diagnosisId){m.value=!0;try{c.value=await B({diagnosis_id:p.diagnosisId})||[]}catch(a){console.error(a),c.value=[]}finally{m.value=!1}}};V(()=>p.diagnosisId,()=>{_()},{immediate:!0}),y({refresh:_});function b(a){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[a]??"—"}function v(a){return!a||typeof a!="string"?!1:/\.(mp4|webm|ogg)(\?|$)/i.test(a)}return(a,k)=>{const w=E,r=N,x=P,C=T,I=L;return e(),i("div",K,[o(w,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"视频通话与录制回放"}),D((e(),u(C,{data:c.value,border:"",stripe:"","empty-text":"暂无通话记录"},{default:s(()=>[o(r,{label:"开始时间",width:"170",prop:"start_time_text"}),o(r,{label:"结束时间",width:"170",prop:"end_time_text"}),o(r,{label:"通话类型",width:"100"},{default:s(({row:t})=>[n(l(t.call_type===1?"语音":"视频"),1)]),_:1}),o(r,{label:"房间号",width:"140"},{default:s(({row:t})=>[t.room_id?(e(),i("span",M,l(t.room_id),1)):(e(),i("span",O,"—"))]),_:1}),o(r,{label:"时长",width:"110",prop:"duration_text"}),o(r,{label:"状态",width:"90"},{default:s(({row:t})=>[n(l(b(t.status)),1)]),_:1}),o(r,{label:"录制",width:"100"},{default:s(({row:t})=>[n(l(t.recording_status_text||"—"),1)]),_:1}),o(r,{label:"录制回放","min-width":"280"},{default:s(({row:t})=>[(t.recording_urls_list||[]).length?(e(),i("div",Q,[(e(!0),i(F,null,A(t.recording_urls_list,(d,f)=>(e(),i("div",{key:f,class:"recording-item"},[v(d)?(e(),i("video",{key:0,src:d,controls:"",preload:"metadata",class:"recording-video"},null,8,S)):(e(),u(x,{key:1,href:d,target:"_blank",type:"primary"},{default:s(()=>[n(" 打开链接 "+l(f+1),1)]),_:2},1032,["href"]))]))),128))])):(e(),i("span",$,"暂无"))]),_:1})]),_:1},8,["data"])),[[I,m.value]])])}}}),Pt=G(j,[["__scopeId","data-v-b392e2ba"]]);export{Pt as default};
@@ -0,0 +1 @@
.call-record-panel .call-record-toolbar[data-v-78d5c9e4]{display:flex;align-items:center;gap:12px;margin-bottom:12px}.call-record-panel .call-record-empty[data-v-78d5c9e4]{padding:28px 12px;color:var(--el-text-color-secondary);text-align:center}.call-record-panel .call-record-empty__title[data-v-78d5c9e4]{font-size:14px;color:var(--el-text-color-primary)}.call-record-panel .call-record-empty__desc[data-v-78d5c9e4]{margin-top:8px;font-size:12px;line-height:1.6}
@@ -1 +1 @@
import{i as L,L as T,N as V,T as D,M as z,R as M}from"./element-plus-kM-bcufB.js";import{a4 as O}from"./tcm-BJy_atdx.js";import{f as P,b as F,w as R,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as j,G as g,F as A,J as H}from"./@vue/runtime-core-tF_2qJHK.js";import{y as d,n as k}from"./@vue/reactivity-BmZ-20sQ.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-BIddbE7W.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-BjeWxBI0.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},Y={key:0},q={key:1,class:"text-gray-400"},K={class:"void-detail text-xs text-gray-500 mt-1"},U=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,h=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{h("view",e)},B=()=>{h("openPrescription")};return F(()=>{u()}),R(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const b=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(b,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),j((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",Y,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",q,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(A,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),H("div",K,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(b,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(U,[["__scopeId","data-v-043d2738"]]);export{zt as default};
import{i as L,L as T,N as V,T as D,M as z,R as M}from"./element-plus-DFTWCWyi.js";import{a8 as O}from"./tcm-FwHxrTHD.js";import{f as P,b as F,w as R,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as j,G as g,F as A,J as H}from"./@vue/runtime-core-C6bnekPw.js";import{y as d,n as k}from"./@vue/reactivity-DiY1c2vO.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-3gMrVgPp.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},Y={key:0},q={key:1,class:"text-gray-400"},K={class:"void-detail text-xs text-gray-500 mt-1"},U=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,h=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{h("view",e)},B=()=>{h("openPrescription")};return F(()=>{u()}),R(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const b=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(b,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),j((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",Y,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",q,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(A,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),H("div",K,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(b,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(U,[["__scopeId","data-v-043d2738"]]);export{zt as default};
@@ -0,0 +1 @@
.daily-matrix[data-v-3f89758f]{padding:16px}.daily-matrix__toolbar[data-v-3f89758f]{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.daily-matrix__toolbar-left[data-v-3f89758f],.daily-matrix__toolbar-right[data-v-3f89758f]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.daily-matrix__table[data-v-3f89758f],.daily-matrix__table-wrap[data-v-3f89758f]{width:100%}.daily-matrix__chart[data-v-3f89758f]{margin-top:16px;padding:16px 18px;border:1px solid var(--el-border-color-lighter);border-radius:10px;background:linear-gradient(180deg,#fff,#f8fafc)}.daily-matrix__chart-canvas[data-v-3f89758f]{height:280px;width:100%}.daily-matrix__cell[data-v-3f89758f]{min-height:32px;display:inline-flex;align-items:center;justify-content:center;gap:2px;width:100%;color:var(--el-text-color-regular)}.daily-matrix__cell.is-clickable[data-v-3f89758f]{cursor:pointer}.daily-matrix__cell.is-empty[data-v-3f89758f]{color:var(--el-text-color-placeholder)}.daily-matrix__cell.is-high[data-v-3f89758f]{color:#dc2626;font-weight:700}.daily-matrix__cell-up[data-v-3f89758f]{color:#dc2626;font-size:13px}.daily-matrix__todo[data-v-3f89758f]{margin-top:16px}.daily-matrix__section-title[data-v-3f89758f]{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--el-text-color-primary)}.daily-matrix__tracking-existing[data-v-3f89758f]{width:100%;max-height:180px;overflow:auto;padding:8px 10px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-fill-color-light)}.daily-matrix__tracking-line[data-v-3f89758f]{font-size:12.5px;line-height:1.6;color:var(--el-text-color-regular);word-break:break-word}.daily-matrix__tracking-preview[data-v-3f89758f]{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;width:100%;text-align:left}.daily-matrix__tracking-tooltip[data-v-3f89758f]{max-width:320px}.daily-matrix__tracking-tooltip-line[data-v-3f89758f]{font-size:12.5px;line-height:1.6;word-break:break-word}@media(max-width:768px){.daily-matrix[data-v-3f89758f],.daily-matrix__chart[data-v-3f89758f]{padding:12px}.daily-matrix__chart-canvas[data-v-3f89758f]{height:240px}}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.daily-matrix[data-v-8849b23d]{padding:16px}.daily-matrix__toolbar[data-v-8849b23d]{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.daily-matrix__toolbar-left[data-v-8849b23d],.daily-matrix__toolbar-right[data-v-8849b23d]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.daily-matrix__table[data-v-8849b23d],.daily-matrix__table-wrap[data-v-8849b23d]{width:100%}.daily-matrix__chart[data-v-8849b23d]{margin-top:16px;padding:16px 18px;border:1px solid var(--el-border-color-lighter);border-radius:10px;background:linear-gradient(180deg,#fff,#f8fafc)}.daily-matrix__chart-canvas[data-v-8849b23d]{height:280px;width:100%}.daily-matrix__cell[data-v-8849b23d]{min-height:32px;display:inline-flex;align-items:center;justify-content:center;gap:2px;width:100%;color:var(--el-text-color-regular)}.daily-matrix__cell.is-clickable[data-v-8849b23d]{cursor:pointer}.daily-matrix__cell.is-empty[data-v-8849b23d]{color:var(--el-text-color-placeholder)}.daily-matrix__cell.is-high[data-v-8849b23d]{color:#dc2626;font-weight:700}.daily-matrix__cell-up[data-v-8849b23d]{color:#dc2626;font-size:13px}.daily-matrix__todo[data-v-8849b23d]{margin-top:16px}.daily-matrix__section-title[data-v-8849b23d]{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--el-text-color-primary)}.daily-matrix__tracking-existing[data-v-8849b23d]{width:100%;max-height:180px;overflow:auto;padding:8px 10px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-fill-color-light)}.daily-matrix__tracking-line[data-v-8849b23d]{font-size:12.5px;line-height:1.6;color:var(--el-text-color-regular);word-break:break-word}.daily-matrix__tracking-preview[data-v-8849b23d]{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;width:100%;text-align:left}.daily-matrix__tracking-tooltip[data-v-8849b23d]{max-width:320px}.daily-matrix__tracking-tooltip-line[data-v-8849b23d]{font-size:12.5px;line-height:1.6;word-break:break-word}@media(max-width:768px){.daily-matrix[data-v-8849b23d],.daily-matrix__chart[data-v-8849b23d]{padding:12px}.daily-matrix__chart-canvas[data-v-8849b23d]{height:240px}}

Some files were not shown because too many files have changed in this diff Show More