Compare commits
63
Commits
master-6-9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fefbae8f | ||
|
|
541fe59ad1 | ||
|
|
b6c5b422c2 | ||
|
|
500038601c | ||
|
|
f6688a740d | ||
|
|
a347e0642c | ||
|
|
13adbac3fd | ||
|
|
333a18c37e | ||
|
|
e3a91a91f7 | ||
|
|
5ca5f06255 | ||
|
|
e01f3e7072 | ||
|
|
ed78803015 | ||
|
|
546138d60e | ||
|
|
fdddfdb3dd | ||
|
|
f5a89b4310 | ||
|
|
a650747fd3 | ||
|
|
46b62229a6 | ||
|
|
0f33f2ad28 | ||
|
|
a677b69e97 | ||
|
|
6432e9990c | ||
|
|
4d6125268a | ||
|
|
7f93cf5480 | ||
|
|
f8f1953a2a | ||
|
|
99eca8b2e5 | ||
|
|
bde396189a | ||
|
|
55e3b5d9e0 | ||
|
|
eb0efba739 | ||
|
|
c7e7022181 | ||
|
|
26e6cc433b | ||
|
|
c22b84cff9 | ||
|
|
4f9486fb3d | ||
|
|
d24b04f9e9 | ||
|
|
465cba3788 | ||
|
|
dc590d37e9 | ||
|
|
d1f587fdd7 | ||
|
|
e8f68101c4 | ||
|
|
cea9392239 | ||
|
|
3ea3c97c42 | ||
|
|
efd058bce5 | ||
|
|
e538dd03a1 | ||
|
|
4a17fe8a0c | ||
|
|
e2a1e62867 | ||
|
|
b6cdd20c56 | ||
|
|
c1be0aa3e5 | ||
|
|
6c87b72ce4 | ||
|
|
e28480af32 | ||
|
|
37b2945c44 | ||
|
|
c9c2b4608f | ||
|
|
e61e7f97fa | ||
|
|
14e5bba9a2 | ||
|
|
6b713a2d6c | ||
|
|
8ae5b4eead | ||
|
|
e5959fd89a | ||
|
|
08249451a7 | ||
|
|
79ae055bb6 | ||
|
|
c0cc23393e | ||
|
|
470ba419e3 | ||
|
|
1c0cd84355 | ||
|
|
f2e2f84e18 | ||
|
|
f7e52bcafc | ||
|
|
2e59e883b8 | ||
|
|
9f1801771e | ||
|
|
1adf8ccf4b |
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
||||
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<view
|
||||
class="food-tile-icon"
|
||||
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
|
||||
>
|
||||
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<image
|
||||
v-if="src && !imageFailed"
|
||||
class="food-tile-icon__img"
|
||||
:src="src"
|
||||
mode="aspectFit"
|
||||
:mode="imageMode"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</view>
|
||||
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
|
||||
return '🍽'
|
||||
})
|
||||
|
||||
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
|
||||
|
||||
const src = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
|
||||
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
|
||||
|
||||
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
|
||||
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
|
||||
|
||||
watch(
|
||||
() => src.value,
|
||||
() => {
|
||||
@@ -71,6 +82,11 @@ function onImageError() {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
|
||||
width: 82%;
|
||||
height: 82%;
|
||||
}
|
||||
|
||||
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
|
||||
@@ -109,6 +125,11 @@ function onImageError() {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
|
||||
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon__img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||
<view class="vsm-body">
|
||||
<view class="vsm-leaf" aria-hidden="true" />
|
||||
<view class="vsm-face">
|
||||
<view class="vsm-eye vsm-eye--l" />
|
||||
<view class="vsm-eye vsm-eye--r" />
|
||||
<view class="vsm-blush vsm-blush--l" />
|
||||
<view class="vsm-blush vsm-blush--r" />
|
||||
<view class="vsm-mouth" />
|
||||
</view>
|
||||
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||
<view class="vsm-wave vsm-wave--1" />
|
||||
<view class="vsm-wave vsm-wave--2" />
|
||||
<view class="vsm-wave vsm-wave--3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 正在按住说话 / 录音中 */
|
||||
active: { type: Boolean, default: false },
|
||||
/** 深色条背景上使用浅色卡通 */
|
||||
onDark: { type: Boolean, default: false },
|
||||
size: { type: String, default: 'md' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vsm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||
|
||||
.vsm-body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vsm-leaf {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 50%;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -8rpx;
|
||||
border-radius: 0 100% 0 100%;
|
||||
background: #34d399;
|
||||
transform: rotate(-18deg);
|
||||
z-index: 2;
|
||||
}
|
||||
.vsm--on-dark .vsm-leaf {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.vsm-face {
|
||||
position: relative;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
background: #fef9c3;
|
||||
border: 3rpx solid #047857;
|
||||
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||
z-index: 1;
|
||||
}
|
||||
.vsm--sm .vsm-face {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-face {
|
||||
background: #fffbeb;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vsm-eye {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
width: 6rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #064e3b;
|
||||
}
|
||||
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||
.vsm-eye--l { left: 12rpx; }
|
||||
.vsm-eye--r { right: 12rpx; }
|
||||
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||
|
||||
.vsm-blush {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
width: 10rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(251, 113, 133, 0.45);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||
.vsm-blush--l { left: 6rpx; }
|
||||
.vsm-blush--r { right: 6rpx; }
|
||||
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
.vsm-mouth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10rpx;
|
||||
width: 18rpx;
|
||||
height: 8rpx;
|
||||
margin-left: -9rpx;
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
background: #047857;
|
||||
transform-origin: center top;
|
||||
}
|
||||
.vsm--sm .vsm-mouth {
|
||||
bottom: 9rpx;
|
||||
width: 16rpx;
|
||||
height: 7rpx;
|
||||
margin-left: -8rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||
|
||||
.vsm-waves {
|
||||
position: absolute;
|
||||
right: -2rpx;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
height: 28rpx;
|
||||
margin-top: -14rpx;
|
||||
z-index: 0;
|
||||
}
|
||||
.vsm-wave {
|
||||
width: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #047857;
|
||||
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||
}
|
||||
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||
|
||||
.vsm--active .vsm-body {
|
||||
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||
}
|
||||
.vsm--active .vsm-mouth {
|
||||
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||
}
|
||||
.vsm--active .vsm-eye {
|
||||
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes vsm-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4rpx); }
|
||||
}
|
||||
@keyframes vsm-talk {
|
||||
0% {
|
||||
transform: scaleY(0.45);
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1.15);
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@keyframes vsm-blink {
|
||||
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||
44% { transform: scaleY(0.15); }
|
||||
}
|
||||
@keyframes vsm-wave-jump {
|
||||
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,27 @@ import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const MIN_HOLD_MS = 280
|
||||
|
||||
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||||
function isBenignSttError(msg, retcode) {
|
||||
const m = String(msg || '').toLowerCase()
|
||||
if (/internal voice data failed/i.test(m)) return true
|
||||
if (/voice data failed/i.test(m)) return true
|
||||
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||||
if (/please stop after start/i.test(m)) return true
|
||||
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||||
const code = Number(retcode)
|
||||
if (code === -30002 || code === -30003 || code === -30012) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function friendlySttError(msg) {
|
||||
const m = String(msg || '').trim()
|
||||
if (!m || isBenignSttError(m)) return ''
|
||||
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||||
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||||
return '语音识别失败,请重试'
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||
@@ -112,7 +133,8 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
function stopListening() {
|
||||
// #ifdef MP-WEIXIN
|
||||
const ownsRecording = sharedRecording && activeCtl === controller
|
||||
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
|
||||
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||||
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
@@ -128,6 +150,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
// #endif
|
||||
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
sttListening.value = false
|
||||
}
|
||||
|
||||
@@ -171,7 +194,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
}
|
||||
try {
|
||||
startRequested = true
|
||||
sharedRecording = true
|
||||
wxRecordManager.start({
|
||||
duration: 60000,
|
||||
lang: 'zh_CN'
|
||||
@@ -251,22 +273,28 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 松手结束:手指抬起 */
|
||||
function endHoldSpeech() {
|
||||
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||||
function endHoldSpeech(force = false) {
|
||||
let recording = false
|
||||
// #ifdef MP-WEIXIN
|
||||
recording = sharedRecording && activeCtl === controller
|
||||
// #endif
|
||||
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||
return
|
||||
}
|
||||
sttHolding.value = false
|
||||
// 松手后不再补开排队的录音
|
||||
pendingStartSession = 0
|
||||
if (sttListening.value || startRequested || recording) {
|
||||
if (sttListening.value || recording) {
|
||||
stopListening()
|
||||
return
|
||||
}
|
||||
if (startRequested) {
|
||||
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||
startRequested = false
|
||||
holdSessionId += 1
|
||||
return
|
||||
}
|
||||
holdSessionId += 1
|
||||
}
|
||||
|
||||
@@ -276,9 +304,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
const controller = {
|
||||
handleStart() {
|
||||
if (!sttHolding.value) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||
startRequested = false
|
||||
sttListening.value = false
|
||||
return
|
||||
}
|
||||
startRequested = false
|
||||
@@ -306,6 +334,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
const msg = (res && res.msg) || ''
|
||||
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
|
||||
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
||||
if (/please stop after start/i.test(msg)) {
|
||||
try {
|
||||
@@ -316,12 +345,18 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
}
|
||||
pendingStartSession = 0
|
||||
sttHolding.value = false
|
||||
const heldMs = Date.now() - holdStartTs
|
||||
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
|
||||
if (typeof onError === 'function') onError(msg || '')
|
||||
return
|
||||
}
|
||||
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||
if (typeof onError === 'function') {
|
||||
const handled = onError(msg || '')
|
||||
if (handled === true) return
|
||||
}
|
||||
notify(msg || '语音识别失败')
|
||||
const tip = friendlySttError(msg)
|
||||
if (tip) notify(tip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,3 +446,5 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
stopSpeechToText: endHoldSpeech
|
||||
}
|
||||
}
|
||||
|
||||
export { isBenignSttError, friendlySttError }
|
||||
|
||||
@@ -63,7 +63,15 @@ const FOOD_TIP_OVERRIDE = {
|
||||
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||
friedNoodles: '油多又是精面,升糖快',
|
||||
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝'
|
||||
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
|
||||
sandwich: '夹心面包精制碳多,升糖快',
|
||||
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
|
||||
milletCongee: '小米粥熬软升糖快,糖友控量',
|
||||
shandongPancake: '煎饼加油面,升糖中等偏快',
|
||||
cake: '蛋糕又甜又油,升糖快',
|
||||
biscuit: '饼干又甜又碎,升糖快',
|
||||
wonton: '馄饨面皮精白,升糖中等,别多吃',
|
||||
blackCoffee: '不加糖的黑咖啡,升糖很低'
|
||||
}
|
||||
|
||||
/** 含糖等级通用科普文案 */
|
||||
@@ -109,7 +117,7 @@ const RAW_FOODS = [
|
||||
|
||||
// 中 GI
|
||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
|
||||
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||
@@ -126,16 +134,24 @@ const RAW_FOODS = [
|
||||
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
|
||||
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
|
||||
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
|
||||
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
|
||||
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
|
||||
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
|
||||
|
||||
// 高 GI
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
|
||||
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
|
||||
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
|
||||
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
|
||||
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
|
||||
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
|
||||
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||
|
||||
@@ -4,10 +4,27 @@
|
||||
*/
|
||||
/**
|
||||
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
||||
* 第一关固定为这 5 种带真实图片素材的食物。
|
||||
* 第一关固定为这 16 种带真实图片素材的食物。
|
||||
*/
|
||||
const FIXED_LEVEL_FOODS = {
|
||||
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
|
||||
1: [
|
||||
'sandwich',
|
||||
'eightTreasureCongee',
|
||||
'milkOats',
|
||||
'milletCongee',
|
||||
'shandongPancake',
|
||||
'grainMantou',
|
||||
'riceNoodleSoup',
|
||||
'youtiao',
|
||||
'friedNoodles',
|
||||
'sweetPotato',
|
||||
'centuryEggCongee',
|
||||
'whiteRice',
|
||||
'cake',
|
||||
'biscuit',
|
||||
'wonton',
|
||||
'blackCoffee'
|
||||
]
|
||||
}
|
||||
|
||||
export function getLevelConfig(levelId) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -639,7 +639,8 @@
|
||||
}
|
||||
|
||||
.more-page .mc-tasks-section {
|
||||
margin-top: 40rpx;
|
||||
margin-top: 0;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-tasks-grid {
|
||||
@@ -1167,3 +1168,259 @@
|
||||
font-size: 22rpx;
|
||||
color: rgba(108, 122, 113, 0.6);
|
||||
}
|
||||
|
||||
.more-page .mc-tree-species-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
margin-left: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 108, 73, 0.08);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel,
|
||||
.more-page .mc-species-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
max-width: 680rpx;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 78vh;
|
||||
background: var(--surface);
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-container-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-summary {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: var(--surface-container-low);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-water {
|
||||
border-color: rgba(0, 108, 73, 0.22);
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-claimed {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-name {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-growth {
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
background: var(--on-surface);
|
||||
color: var(--surface);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn.ghost {
|
||||
background: var(--surface-container-low);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-species-panel-tip {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
color: var(--outline);
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-scroll {
|
||||
max-height: 56vh;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card {
|
||||
padding: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(0, 108, 73, 0.1);
|
||||
background: var(--surface-container-low);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-emoji {
|
||||
font-size: 56rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-name {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-tagline {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-species-stages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage {
|
||||
width: calc(20% - 8rpx);
|
||||
min-width: 96rpx;
|
||||
padding: 10rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage.reached {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage.current {
|
||||
box-shadow: 0 0 0 2rpx var(--primary);
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-emoji {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-lv {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-name {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
color: var(--outline);
|
||||
margin-top: 2rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
|
||||
|
||||
/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */
|
||||
export const TREE_SPECIES_LIST = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: '稳糖灵树',
|
||||
tagline: '经典路线,从种子到圆满',
|
||||
stages: TREE_LEVELS.map((l) => l.emoji)
|
||||
},
|
||||
{
|
||||
id: 'bonsai',
|
||||
name: '雅韵盆景',
|
||||
tagline: '小巧精致,书桌旁的温柔陪伴',
|
||||
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
|
||||
},
|
||||
{
|
||||
id: 'pine',
|
||||
name: '苍劲青松',
|
||||
tagline: '挺拔向上,越记越稳',
|
||||
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
|
||||
},
|
||||
{
|
||||
id: 'sakura',
|
||||
name: '樱花小树',
|
||||
tagline: '坚持打卡,枝头渐开',
|
||||
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
|
||||
},
|
||||
{
|
||||
id: 'ginkgo',
|
||||
name: '金秋银杏',
|
||||
tagline: '叶色渐变,见证每一天',
|
||||
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
|
||||
}
|
||||
]
|
||||
|
||||
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
|
||||
|
||||
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
|
||||
export const DAILY_MAX_TASK_POINTS = 40
|
||||
|
||||
export function getTreeSpecies(id) {
|
||||
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
|
||||
}
|
||||
|
||||
export function speciesEmojiAtLevel(speciesId, level) {
|
||||
const sp = getTreeSpecies(speciesId)
|
||||
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||
return sp.stages[lv] || sp.stages[0] || '🌱'
|
||||
}
|
||||
|
||||
/** 当前树种成长路线图(供选择面板展示) */
|
||||
export function speciesGrowthRoadmap(speciesId) {
|
||||
const sp = getTreeSpecies(speciesId)
|
||||
return TREE_LEVELS.map((meta, i) => {
|
||||
const xpTotal = i * TREE_XP_PER_LEVEL
|
||||
const daysHint = i === 0
|
||||
? 0
|
||||
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
|
||||
return {
|
||||
level: i,
|
||||
name: meta.name,
|
||||
emoji: sp.stages[i] || meta.emoji,
|
||||
desc: meta.desc,
|
||||
xpTotal,
|
||||
daysHint
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 全勤打卡大约多少天满级 */
|
||||
export function estimateDaysToMaxLevel() {
|
||||
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
|
||||
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -96,6 +96,21 @@ export function yejiStatsRevisitBreakdown(params: {
|
||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||
}
|
||||
|
||||
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
|
||||
export function yejiStatsAssignLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/assignLines', params })
|
||||
}
|
||||
|
||||
export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
@@ -181,6 +196,42 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:部门下拉(前端组树) */
|
||||
export function revisitRateDeptOptions() {
|
||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
||||
export function revisitRateAssignLines(params: {
|
||||
month?: string
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||
export function revisitRateVisitOrderLines(params: {
|
||||
month?: string
|
||||
slot: number
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||
}
|
||||
|
||||
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
|
||||
export function autoAssignLogLists(params: Record<string, any>) {
|
||||
return request.get({ url: '/stats.autoAssignLog/lists', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
|
||||
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
||||
}
|
||||
|
||||
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
|
||||
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
|
||||
id: number
|
||||
revisit_slot_start_offset: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
|
||||
}
|
||||
|
||||
/** 诊单挂号 / 取消挂号 操作日志 */
|
||||
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||
@@ -425,6 +433,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderPatchPrescriptionUsage(params: {
|
||||
id: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
@@ -446,6 +465,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||
}
|
||||
|
||||
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
|
||||
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
|
||||
}
|
||||
|
||||
/** 撤回支付单审核 */
|
||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||
@@ -468,6 +492,8 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
|
||||
pay_create_type?: 'fubei' | 'express_cod'
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
@@ -516,6 +542,18 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
|
||||
export function prescriptionOrderAddLog(params: {
|
||||
id: number
|
||||
summary: string
|
||||
prescription_audit_status?: number | ''
|
||||
payment_slip_audit_status?: number | ''
|
||||
prescription_audit_remark?: string
|
||||
payment_slip_audit_remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
|
||||
@@ -32,8 +32,12 @@
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
@@ -42,7 +46,8 @@
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
<el-table-column label="预览" width="100">
|
||||
@@ -129,8 +134,9 @@ import MaterialPicker from '@/components/material/picker.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const searchData = reactive<any>({
|
||||
title: '',
|
||||
@@ -198,6 +204,7 @@ const getList = async () => {
|
||||
const res = await apiAssetResourceList(buildQueryParams())
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
selectedIds.value = []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@@ -269,6 +276,22 @@ const handleDelete = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
|
||||
await apiAssetResourceDelete({ id: selectedIds.value })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 处方业务订单——纯展示/格式化工具
|
||||
*
|
||||
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab)
|
||||
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
|
||||
* 否则两个详情页面会再次出现文案/口径漂移。
|
||||
*/
|
||||
|
||||
/** 与诊间医助角色 ID 一致(server 角色表) */
|
||||
export const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
export function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
const d = new Date(v * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export function formatMoney(v: unknown) {
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return '—'
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatOrderTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||
return formatTime(v)
|
||||
}
|
||||
|
||||
export function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '挂号费',
|
||||
2: '问诊费',
|
||||
3: '药品费用',
|
||||
4: '首付费用',
|
||||
5: '尾款费用',
|
||||
6: '其他费用',
|
||||
7: '全部费用',
|
||||
8: '驼奶费用'
|
||||
}
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
export function fulfillmentText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待双审通过',
|
||||
2: '待发货',
|
||||
3: '已完成',
|
||||
4: '已取消',
|
||||
5: '已发货',
|
||||
6: '已签收',
|
||||
7: '进行中',
|
||||
8: '暂不制药',
|
||||
9: '拒收',
|
||||
10: '退款',
|
||||
11: '保留药方',
|
||||
12: '制药缓发'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
const n = Number(s)
|
||||
if (n === 3) return 'success'
|
||||
if (n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4) return 'danger'
|
||||
if (n === 2) return 'warning'
|
||||
if (n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
if (n === 9 || n === 10) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待支付',
|
||||
2: '已支付',
|
||||
3: '已取消',
|
||||
4: '已退款',
|
||||
5: '待审核'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
export function consumerRxAuditText(s: number | undefined) {
|
||||
const n = Number(s)
|
||||
if (n === 1) return '已通过'
|
||||
if (n === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
export function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
export 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: '关联支付单',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
patch_rx_usage: '服用参数',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
refund: '退款',
|
||||
manual_log: '手工备注',
|
||||
assign_assistant: '改派医助',
|
||||
add_pay_order: '补齐支付单',
|
||||
set_ship_mode: '发货类型'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
|
||||
export function auditStatusText(s: number | undefined) {
|
||||
if (s === 1) return '已通过'
|
||||
if (s === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||
const createType = String(row?.create_type || '')
|
||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||
if (createType === 'fubei') return '付呗'
|
||||
if (createType === 'express_cod') return '快递代收'
|
||||
const paymentMethod = String(row?.payment_method || '')
|
||||
const methodMap: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
wechat_work: '企业微信',
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收',
|
||||
manual: '手动确认到账'
|
||||
}
|
||||
if (paymentMethod && methodMap[paymentMethod]) {
|
||||
return methodMap[paymentMethod]
|
||||
}
|
||||
return '普通订单'
|
||||
}
|
||||
|
||||
export function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
}
|
||||
|
||||
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
|
||||
const a = normalizeBizPhone(recipient)
|
||||
const b = normalizeBizPhone(rxPhone)
|
||||
if (!a || !b) return false
|
||||
return a !== b
|
||||
}
|
||||
|
||||
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => ({
|
||||
name: String(x?.name ?? '').trim(),
|
||||
dosage: Number(x?.dosage) || 0
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export type SlipFormulaType = '主方' | '辅方'
|
||||
|
||||
export interface SlipAuxUsageForm {
|
||||
dosage_amount?: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
}
|
||||
|
||||
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
|
||||
if (prescriptionType === '饮片') {
|
||||
return {
|
||||
dosage_amount: 50,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
if (prescriptionType === '浓缩水丸') {
|
||||
return {
|
||||
dosage_amount: 5,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
return {
|
||||
dosage_amount: 1,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
|
||||
const base = defaultSlipAuxUsage(prescriptionType)
|
||||
if (!raw || typeof raw !== 'object') return { ...base }
|
||||
const o = raw as Record<string, unknown>
|
||||
return {
|
||||
dosage_amount:
|
||||
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||
? Number(o.dosage_amount)
|
||||
: base.dosage_amount,
|
||||
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula) */
|
||||
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!rx) return false
|
||||
const herbs = rx.herbs
|
||||
if (Array.isArray(herbs)) {
|
||||
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||
}
|
||||
return Number(rx.has_aux_formula) === 1
|
||||
}
|
||||
|
||||
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
|
||||
export const LOGISTICS_URGENT_KEYWORDS = [
|
||||
'拒收',
|
||||
'拒签',
|
||||
'退件',
|
||||
'客户拒收',
|
||||
'拦截退回',
|
||||
'退回发件',
|
||||
'派件退回',
|
||||
'无人签收',
|
||||
'退回快件'
|
||||
]
|
||||
|
||||
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
|
||||
const t = String(s ?? '')
|
||||
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
|
||||
}
|
||||
|
||||
export function logisticsTraceLineUrgent(context: unknown): boolean {
|
||||
return logisticsStringHasUrgentKeyword(context)
|
||||
}
|
||||
|
||||
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
|
||||
export 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 }
|
||||
}
|
||||
|
||||
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
|
||||
export function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||
if (!row?.id) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
|
||||
export function formatDietaryTaboo(raw: unknown): string {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 服务套餐 dict:server_order */
|
||||
export type ServicePackageOption = { name: string; value: string; status?: number }
|
||||
|
||||
export function normalizeServicePackageValue(v: unknown): string {
|
||||
return String(v ?? '').trim()
|
||||
}
|
||||
|
||||
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
|
||||
export function parseServicePackageValues(raw: unknown): string[] {
|
||||
if (raw == null || raw === '') return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(normalizeServicePackageValue).filter(Boolean)
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw.split(',').map((v) => v.trim()).filter(Boolean)
|
||||
}
|
||||
const one = normalizeServicePackageValue(raw)
|
||||
return one ? [one] : []
|
||||
}
|
||||
|
||||
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
|
||||
const sa = normalizeServicePackageValue(a)
|
||||
const sb = normalizeServicePackageValue(b)
|
||||
if (!sa || !sb) return false
|
||||
if (sa === sb) return true
|
||||
const na = Number(sa)
|
||||
const nb = Number(sb)
|
||||
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
|
||||
}
|
||||
|
||||
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((item: any) => ({
|
||||
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
|
||||
value: normalizeServicePackageValue(item?.value),
|
||||
status: Number(item?.status ?? 1)
|
||||
}))
|
||||
.filter((item) => item.value !== '')
|
||||
}
|
||||
|
||||
export function findServicePackageOption(
|
||||
options: ServicePackageOption[],
|
||||
value: unknown
|
||||
): ServicePackageOption | undefined {
|
||||
const key = normalizeServicePackageValue(value)
|
||||
if (!key) return undefined
|
||||
return options.find((opt) => servicePackageValueEquals(opt.value, key))
|
||||
}
|
||||
|
||||
/** 展示用:value → 字典 name,多选用「、」连接 */
|
||||
export function formatServicePackageLabels(
|
||||
value: unknown,
|
||||
options: ServicePackageOption[],
|
||||
emptyText = '—'
|
||||
): string {
|
||||
const packages = parseServicePackageValues(value)
|
||||
if (packages.length === 0) return emptyText
|
||||
const names = packages.map((val) => {
|
||||
const option = findServicePackageOption(options, val)
|
||||
return option?.name || val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
|
||||
export function mergeServicePackageSelectOptions(
|
||||
options: ServicePackageOption[],
|
||||
selected: unknown[]
|
||||
): ServicePackageOption[] {
|
||||
const known = new Set(options.map((o) => o.value))
|
||||
const extras: ServicePackageOption[] = []
|
||||
for (const raw of selected) {
|
||||
const val = normalizeServicePackageValue(raw)
|
||||
if (!val || known.has(val)) continue
|
||||
const matched = findServicePackageOption(options, val)
|
||||
extras.push(matched ?? { name: val, value: val, status: 0 })
|
||||
known.add(val)
|
||||
}
|
||||
return extras.length ? [...options, ...extras] : options
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
<el-radio-button label="rejected">驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="pl-toolbar__sep" aria-hidden="true" />
|
||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
|
||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
|
||||
<el-radio-group
|
||||
v-model="formData.source_filter"
|
||||
size="small"
|
||||
@@ -48,7 +48,7 @@
|
||||
>
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="manual">手工</el-radio-button>
|
||||
<el-radio-button label="system">系统代开</el-radio-button>
|
||||
<el-radio-button label="system">空白处方</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="pl-toolbar__line pl-toolbar__line--second">
|
||||
@@ -146,7 +146,7 @@
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
系统代开
|
||||
空白处方
|
||||
</el-tag>
|
||||
<span v-else class="text-gray-400 text-sm">手工</span>
|
||||
</template>
|
||||
@@ -290,7 +290,7 @@
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>系统代开</el-tag>
|
||||
>空白处方</el-tag>
|
||||
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
||||
<el-tag
|
||||
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
||||
@@ -437,6 +437,7 @@
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -1856,7 +1857,7 @@ const formData = reactive({
|
||||
creator_ids: [] as number[],
|
||||
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
||||
audit_filter: 'all' as string,
|
||||
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
|
||||
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
|
||||
source_filter: 'all' as string,
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -858,31 +858,94 @@
|
||||
</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>
|
||||
<div class="flex flex-col gap-1 text-sm leading-relaxed">
|
||||
<div>
|
||||
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方:</span>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="detailHasAuxHerbs && detailAuxUsage">
|
||||
<span class="text-gray-500 mr-1">辅方:</span>
|
||||
<template v-if="detailAuxUsage.dosage_amount != null && detailAuxUsage.dosage_amount !== 0">
|
||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<span>服用方式</span>
|
||||
<el-button
|
||||
v-if="
|
||||
detailData.prescription_id &&
|
||||
detailPrescription &&
|
||||
!String(detailData.prescription_detail_error || '').trim()
|
||||
"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div>
|
||||
<span class="text-gray-500 mr-1">主方:</span>
|
||||
每天
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
|
||||
· 处方开立
|
||||
{{
|
||||
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
||||
? detailPrescription.usage_days + ' 天'
|
||||
: '— 天'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="detailAuxUsage">
|
||||
<span class="text-gray-500 mr-1">辅方:</span>
|
||||
每天
|
||||
{{ detailAuxUsage.times_per_day ? detailAuxUsage.times_per_day + ' 次' : '— 次' }}
|
||||
· 处方开立
|
||||
{{
|
||||
detailAuxUsage.usage_days != null && Number(detailAuxUsage.usage_days) > 0
|
||||
? detailAuxUsage.usage_days + ' 天'
|
||||
: '— 天'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
<div>
|
||||
<span class="text-gray-500">订单设置:</span>
|
||||
{{
|
||||
@@ -894,6 +957,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
|
||||
{{ detailDietaryText }}
|
||||
</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 ? '已作废' : '正常' }}
|
||||
@@ -1136,7 +1202,7 @@
|
||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
|
||||
@@ -1295,7 +1361,7 @@
|
||||
<el-form-item label="新金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="updateAmountForm.amount"
|
||||
:min="0.01"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
@@ -1522,10 +1588,11 @@
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in servicePackageOptions"
|
||||
v-for="item in editServicePackageSelectOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
:disabled="item.status === 0"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -1998,13 +2065,14 @@
|
||||
<el-form-item label="添加方式" prop="add_mode">
|
||||
<el-radio-group v-model="addPayOrderForm.add_mode">
|
||||
<el-radio value="create">付呗</el-radio>
|
||||
<el-radio value="create_express">快递代收</el-radio>
|
||||
<el-radio value="link">关联已有</el-radio>
|
||||
<el-radio value="completion_only">直接完单申请</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 付呗(手动创建支付单) -->
|
||||
<template v-if="addPayOrderForm.add_mode === 'create'">
|
||||
<!-- 付呗 / 快递代收(手动创建支付单) -->
|
||||
<template v-if="addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express'">
|
||||
<el-form-item label="费用类别" prop="order_type">
|
||||
<el-select v-model="addPayOrderForm.order_type" class="w-full">
|
||||
<el-option label="挂号费" :value="1" />
|
||||
@@ -2147,6 +2215,93 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改服用参数:主方 / 辅方 / 订单设置 -->
|
||||
<el-dialog
|
||||
v-model="patchUsageVisible"
|
||||
title="修改服用参数"
|
||||
width="92%"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="po-h5-dialog"
|
||||
@closed="resetPatchUsageForm"
|
||||
>
|
||||
<el-form
|
||||
ref="patchUsageFormRef"
|
||||
:model="patchUsageForm"
|
||||
:rules="patchUsageRules"
|
||||
label-width="96px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处方开立" prop="usage_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.usage_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处方开立" prop="aux_usage_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_usage_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
|
||||
<el-form-item label="服用天数" prop="medication_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.medication_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="patchUsageVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 处方详情查看(处方单样式) -->
|
||||
<el-drawer
|
||||
v-model="prescriptionViewVisible"
|
||||
@@ -2310,6 +2465,7 @@
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -2560,6 +2716,7 @@ import {
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderPatchPrescriptionUsage,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
@@ -2568,6 +2725,16 @@ import {
|
||||
getDoctors,
|
||||
getAssistants
|
||||
} from '@/api/tcm'
|
||||
import {
|
||||
formatDietaryTaboo,
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
parseServicePackageValues,
|
||||
mergeServicePackageSelectOptions,
|
||||
formatServicePackageLabels,
|
||||
normalizeSlipAuxUsageForm,
|
||||
prescriptionHasAuxFormula
|
||||
} from './components/prescription-order-utils'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -2663,7 +2830,7 @@ const canViewFinanceFields = () => {
|
||||
const regionOptions = ref([])
|
||||
|
||||
// 服务套餐选项
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||
|
||||
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
||||
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
@@ -2784,8 +2951,7 @@ const loadRegionData = async () => {
|
||||
const loadServicePackageOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'server_order' })
|
||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
||||
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
|
||||
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
|
||||
} catch (error) {
|
||||
console.error('加载服务套餐选项失败:', error)
|
||||
servicePackageOptions.value = []
|
||||
@@ -3193,28 +3359,6 @@ function feeTypeText(t: number | undefined) {
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
// 格式化服务套餐显示
|
||||
function formatServicePackage(value: any): string {
|
||||
if (!value) return '—'
|
||||
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').filter(v => v.trim() !== '')
|
||||
}
|
||||
|
||||
if (packages.length === 0) return '—'
|
||||
|
||||
// 将值转换为名称
|
||||
const names = packages.map(val => {
|
||||
const option = servicePackageOptions.value.find(opt => opt.value === val)
|
||||
return option ? option.name : val
|
||||
})
|
||||
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
function auditStatusText(s: number | undefined) {
|
||||
if (s === 1) return '已通过'
|
||||
if (s === 2) return '已驳回'
|
||||
@@ -3467,6 +3611,10 @@ const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
|
||||
const detailServicePackageText = computed(() =>
|
||||
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||
)
|
||||
|
||||
// --- 订单可视化审批履约流程逻辑 ---
|
||||
const workflowActiveStep = computed(() => {
|
||||
if (!detailData.value) return 0
|
||||
@@ -3578,6 +3726,8 @@ const detailPrescription = computed(() => {
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
|
||||
|
||||
function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
@@ -3621,6 +3771,16 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
|
||||
|
||||
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
||||
|
||||
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
|
||||
|
||||
const detailAuxUsage = computed(() => {
|
||||
const rx = detailPrescription.value as any
|
||||
if (!rx || !prescriptionHasAuxFormula(rx)) return null
|
||||
const raw = rx.aux_usage
|
||||
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
|
||||
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
|
||||
})
|
||||
|
||||
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
||||
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
|
||||
|
||||
@@ -3725,8 +3885,8 @@ const updateAmountRules: FormRules = {
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
callback(new Error('订单金额必须大于0'))
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
callback(new Error('订单金额不能为负数'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
@@ -3847,6 +4007,101 @@ const patchRxPatientRules: FormRules = {
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
}
|
||||
return rules
|
||||
})
|
||||
|
||||
function openPatchUsageDialog() {
|
||||
const rx = detailPrescription.value
|
||||
const ord = detailData.value
|
||||
if (!rx || !ord?.id || !ord.prescription_id) {
|
||||
feedback.msgWarning('无处方数据')
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageForm.aux_times_per_day =
|
||||
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
async function submitPatchUsage() {
|
||||
const form = patchUsageFormRef.value
|
||||
if (!form) return
|
||||
try {
|
||||
await form.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const ordId = detailData.value?.id
|
||||
if (!ordId) return
|
||||
patchUsageSaving.value = true
|
||||
try {
|
||||
const payload: {
|
||||
id: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
patchUsageVisible.value = false
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
await fetchLogs(ordId)
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
patchUsageSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPatchRxPatientDialog() {
|
||||
const rx = detailPrescription.value
|
||||
const ord = detailData.value
|
||||
@@ -3878,7 +4133,8 @@ function openUpdateAmount() {
|
||||
return
|
||||
}
|
||||
updateAmountForm.id = Number(row?.id) || 0
|
||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
||||
const amt = Number(row?.amount)
|
||||
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
|
||||
updateAmountVisible.value = true
|
||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -4051,6 +4307,10 @@ const editForm = reactive({
|
||||
diagnosis_creator_dept_path: ''
|
||||
})
|
||||
|
||||
const editServicePackageSelectOptions = computed(() =>
|
||||
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
|
||||
)
|
||||
|
||||
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
||||
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
||||
@@ -4096,12 +4356,14 @@ function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unk
|
||||
const createType = String(row?.create_type || '')
|
||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||
if (createType === 'fubei') return '付呗'
|
||||
if (createType === 'express_cod') return '快递代收'
|
||||
const paymentMethod = String(row?.payment_method || '')
|
||||
const methodMap: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
wechat_work: '企业微信',
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收',
|
||||
manual: '手动确认到账'
|
||||
}
|
||||
if (paymentMethod && methodMap[paymentMethod]) {
|
||||
@@ -4299,18 +4561,7 @@ async function openEdit(row: {
|
||||
editForm.dose_unit = d.dose_unit || '剂'
|
||||
editForm.prev_staff = d.prev_staff || ''
|
||||
editForm.service_channel = d.service_channel || ''
|
||||
// 处理服务套餐:如果是字符串,转换为数组
|
||||
if (d.service_package) {
|
||||
if (Array.isArray(d.service_package)) {
|
||||
editForm.service_package = d.service_package
|
||||
} else if (typeof d.service_package === 'string') {
|
||||
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
editForm.service_package = parseServicePackageValues(d.service_package)
|
||||
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = d.tracking_number || ''
|
||||
editForm.fee_type = Number(d.fee_type) || 3
|
||||
@@ -4955,10 +5206,13 @@ const addPayOrderAlertTitle = computed(() => {
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
return '不创建或关联支付单,仅提交完单申请,由审核人员在支付审核时处理。'
|
||||
}
|
||||
if (addPayOrderForm.add_mode === 'create_express') {
|
||||
return '通过快递代收创建新支付单(流转同付呗),或关联已存在但未绑定的支付单。'
|
||||
}
|
||||
return '可以通过付呗创建新支付单,或关联已存在但未绑定的支付单。'
|
||||
})
|
||||
const addPayOrderForm = reactive({
|
||||
add_mode: 'create' as 'create' | 'link' | 'completion_only',
|
||||
add_mode: 'create' as 'create' | 'create_express' | 'link' | 'completion_only',
|
||||
order_type: 3,
|
||||
pay_amount: undefined as number | undefined,
|
||||
pay_remark: '',
|
||||
@@ -4970,7 +5224,7 @@ const addPayOrderRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }]
|
||||
}
|
||||
if (addPayOrderForm.add_mode === 'create') {
|
||||
if (addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express') {
|
||||
rules.order_type = [{ required: true, message: '请选择费用类别', trigger: 'change' }]
|
||||
rules.pay_amount = [
|
||||
{ required: true, message: '请输入支付金额', trigger: 'blur' },
|
||||
@@ -5054,14 +5308,19 @@ async function submitAddPayOrder() {
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
|
||||
feedback.msgSuccess('完单申请已提交,请等待支付审核')
|
||||
} else if (addPayOrderForm.add_mode === 'create') {
|
||||
// 手动创建新支付单
|
||||
} else if (
|
||||
addPayOrderForm.add_mode === 'create' ||
|
||||
addPayOrderForm.add_mode === 'create_express'
|
||||
) {
|
||||
// 手动创建新支付单(付呗 / 快递代收)
|
||||
await prescriptionOrderAddPayOrder({
|
||||
id: addPayOrderRowId.value,
|
||||
order_type: addPayOrderForm.order_type,
|
||||
pay_amount: addPayOrderForm.pay_amount!,
|
||||
pay_remark: addPayOrderForm.pay_remark || '',
|
||||
completion_request: addPayOrderForm.completion_request
|
||||
completion_request: addPayOrderForm.completion_request,
|
||||
pay_create_type:
|
||||
addPayOrderForm.add_mode === 'create_express' ? 'express_cod' : 'fubei'
|
||||
})
|
||||
feedback.msgSuccess('支付单已新增,请等待审核')
|
||||
} else {
|
||||
@@ -5176,12 +5435,7 @@ const slipHerbsList = computed(() => {
|
||||
return Array.isArray(h) ? h : []
|
||||
})
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = prescriptionViewData.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
if (typeof d === 'string' && d.trim()) return d.trim()
|
||||
return ''
|
||||
})
|
||||
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
|
||||
|
||||
const slipChuwanVisible = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
|
||||
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { adminLists } from '@/api/perms/admin'
|
||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await adminLists({
|
||||
const res = await doctorLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1
|
||||
page_size: 1000
|
||||
})
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
|
||||
@@ -283,7 +283,15 @@
|
||||
<td class="col-name">{{ r.name }}</td>
|
||||
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
|
||||
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
|
||||
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
|
||||
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
|
||||
<span
|
||||
v-if="Number(r.assign_count ?? 0) > 0"
|
||||
class="yeji-lead-cell--link"
|
||||
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
|
||||
<template v-else>{{
|
||||
formatLeaderboardInt(r.assign_count ?? 0)
|
||||
}}</template>
|
||||
</td>
|
||||
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
|
||||
<span
|
||||
v-if="Number(r.appointment_count ?? 0) > 0"
|
||||
@@ -390,7 +398,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>tcm_diagnosis_assign_log</b> 行数(成功指派:<b>to_assistant_id > 0</b>),**仅按 <b>related_po_create_time</b>** 落在所选区间内(与日志表快照一致;诊单手动指派已写入该字段)。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||
<b>tcm_diagnosis_assign_log</b> 中成功指派(<b>to_assistant_id > 0</b>,<b>剔除勾选「继承」的指派</b>)按<b>指派操作时间</b>落在所选区间内,「医助 × 诊单」去重、剔除已删诊单,与「复诊接诊率」页面同口径。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
||||
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
||||
</template>
|
||||
@@ -561,7 +569,13 @@
|
||||
>{{ formatInt(r.lead_count) }}</span>
|
||||
<template v-else>{{ formatInt(r.lead_count) }}</template>
|
||||
</td>
|
||||
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
|
||||
<td @click.stop="onAssignCountCellClick($event, tb, r)">
|
||||
<span
|
||||
v-if="yejiAssignCountCellClickable(r)"
|
||||
class="yeji-lead-cell--link"
|
||||
>{{ formatInt(r.assign_count ?? 0) }}</span>
|
||||
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
|
||||
</td>
|
||||
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
|
||||
<span
|
||||
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
|
||||
@@ -1356,6 +1370,59 @@
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="assignLinesDialogVisible"
|
||||
width="min(960px, 96vw)"
|
||||
destroy-on-close
|
||||
class="yeji-leadlines-dialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="yeji-unassigned-dialog__head">
|
||||
<span class="yeji-unassigned-dialog__title">被指派明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
|
||||
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
|
||||
<el-table
|
||||
v-if="assignLinesRows.length > 0 || assignLinesLoading"
|
||||
:data="assignLinesRows"
|
||||
size="small"
|
||||
stripe
|
||||
border
|
||||
max-height="440"
|
||||
class="yeji-unassigned-dialog__table"
|
||||
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
|
||||
>
|
||||
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
|
||||
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-empty
|
||||
v-if="!assignLinesLoading && assignLinesRows.length === 0"
|
||||
description="暂无被指派明细"
|
||||
/>
|
||||
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
|
||||
<el-pagination
|
||||
:current-page="assignLinesPage"
|
||||
:page-size="assignLinesPageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="assignLinesCount"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:disabled="assignLinesLoading"
|
||||
:hide-on-single-page="false"
|
||||
@current-change="onAssignLinesPageChange"
|
||||
@size-change="onAssignLinesPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1376,6 +1443,7 @@ import {
|
||||
yejiStatsLeadLines,
|
||||
yejiStatsAppointmentLines,
|
||||
yejiStatsRevisitBreakdown,
|
||||
yejiStatsAssignLines,
|
||||
} from '@/api/stats'
|
||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
@@ -1491,6 +1559,17 @@ interface YejiLeadLineRow {
|
||||
external_contact_name: string
|
||||
}
|
||||
|
||||
interface YejiAssignLineRow {
|
||||
diagnosis_id: number
|
||||
patient_name: string
|
||||
patient_phone: string
|
||||
assistant_id: number
|
||||
assistant_name: string
|
||||
assign_count: number
|
||||
last_assign_time: number
|
||||
last_assign_time_text: string
|
||||
}
|
||||
|
||||
interface YejiRow {
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
@@ -1709,6 +1788,31 @@ type AppointmentLinesCtx =
|
||||
}
|
||||
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
|
||||
|
||||
const assignLinesDialogVisible = ref(false)
|
||||
const assignLinesLoading = ref(false)
|
||||
const assignLinesSubtitle = ref('')
|
||||
const assignLinesApiNote = ref('')
|
||||
const assignLinesRows = ref<YejiAssignLineRow[]>([])
|
||||
const assignLinesCount = ref(0)
|
||||
const assignLinesPage = ref(1)
|
||||
const assignLinesPageSize = ref(20)
|
||||
type AssignLinesCtx =
|
||||
| {
|
||||
mode: 'assistant'
|
||||
assistant_id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
| {
|
||||
mode: 'dept'
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
const assignLinesContext = ref<AssignLinesCtx | null>(null)
|
||||
|
||||
const appointmentLinesShowChannelColumn = computed(() =>
|
||||
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
|
||||
)
|
||||
@@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
void openLeadLinesDialog(tb, row)
|
||||
}
|
||||
|
||||
function yejiAssignCountCellClickable(r: YejiRow): boolean {
|
||||
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
|
||||
}
|
||||
|
||||
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
if (!yejiAssignCountCellClickable(row)) {
|
||||
return
|
||||
}
|
||||
ev.stopPropagation()
|
||||
void openAssignLinesDialogForDept(tb, row)
|
||||
}
|
||||
|
||||
function assignLinesIndexMethod(index: number) {
|
||||
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
|
||||
}
|
||||
|
||||
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
|
||||
const ctx = assignLinesContext.value
|
||||
if (!ctx) {
|
||||
return null
|
||||
}
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: ctx.start_date,
|
||||
end_date: ctx.end_date,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (ctx.mode === 'dept') {
|
||||
p.dept_id = ctx.dept_id
|
||||
} else {
|
||||
p.assistant_id = ctx.assistant_id
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
async function fetchAssignLinesPage() {
|
||||
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
|
||||
if (!params) {
|
||||
return
|
||||
}
|
||||
assignLinesLoading.value = true
|
||||
try {
|
||||
const res: any = await yejiStatsAssignLines(params as any)
|
||||
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||
assignLinesCount.value = Number(res?.count ?? 0)
|
||||
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||
} catch (e: unknown) {
|
||||
if (axios.isCancel(e)) return
|
||||
const any = e as any
|
||||
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesApiNote.value = ''
|
||||
} finally {
|
||||
assignLinesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
|
||||
assignLinesContext.value = {
|
||||
mode: 'dept',
|
||||
dept_id: row.dept_id,
|
||||
dept_name: row.dept_name,
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
}
|
||||
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ~ ${tb.end_date}`
|
||||
assignLinesApiNote.value = ''
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesPage.value = 1
|
||||
assignLinesPageSize.value = 20
|
||||
assignLinesDialogVisible.value = true
|
||||
await fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
|
||||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
|
||||
return
|
||||
}
|
||||
if (Number(r.assign_count ?? 0) <= 0) {
|
||||
return
|
||||
}
|
||||
const lb = leaderboardBlock.value
|
||||
assignLinesContext.value = {
|
||||
mode: 'assistant',
|
||||
assistant_id: r.admin_id,
|
||||
name: r.name,
|
||||
start_date: lb.start_date,
|
||||
end_date: lb.end_date,
|
||||
}
|
||||
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ~ ${lb.end_date}`
|
||||
assignLinesApiNote.value = ''
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesPage.value = 1
|
||||
assignLinesPageSize.value = 20
|
||||
assignLinesDialogVisible.value = true
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onAssignLinesPageChange(p: number) {
|
||||
assignLinesPage.value = p
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onAssignLinesPageSizeChange(size: number) {
|
||||
assignLinesPageSize.value = size
|
||||
assignLinesPage.value = 1
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||
{ key: 'event_time_text', label: '进线时间' },
|
||||
{ key: 'reception_admin_name', label: '接待' },
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/split']"
|
||||
type="primary"
|
||||
link
|
||||
@@ -237,7 +237,7 @@
|
||||
小程序码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/pay']"
|
||||
type="success"
|
||||
link
|
||||
@@ -255,7 +255,7 @@
|
||||
退款
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/cancel']"
|
||||
type="danger"
|
||||
link
|
||||
@@ -434,7 +434,7 @@
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-3"
|
||||
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
|
||||
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
|
||||
/>
|
||||
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
|
||||
<el-radio-group v-model="payForm.payType">
|
||||
@@ -502,6 +502,7 @@
|
||||
<el-radio label="normal">普通订单</el-radio>
|
||||
<el-radio label="wechat_work">企业微信对外收款</el-radio>
|
||||
<el-radio label="fubei">付呗</el-radio>
|
||||
<el-radio label="express_cod">快递代收</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
@@ -524,7 +525,20 @@
|
||||
<template #title>付呗</template>
|
||||
通过付呗收款的支付单,创建后请按实际对账/审核流程在列表中处理。
|
||||
</el-alert>
|
||||
<el-form-item v-if="createForm.createType === 'fubei'" label="支付单审核">
|
||||
<el-alert
|
||||
v-if="createForm.createType === 'express_cod'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>快递代收</template>
|
||||
通过快递代收的支付单,流转与付呗一致;审核通过/确认到账后将更新为已支付。
|
||||
</el-alert>
|
||||
<el-form-item
|
||||
v-if="createForm.createType === 'fubei' || createForm.createType === 'express_cod'"
|
||||
label="支付单审核"
|
||||
>
|
||||
<el-switch
|
||||
v-model="createForm.requirePaymentSlipAudit"
|
||||
active-text="申请审核"
|
||||
@@ -980,8 +994,8 @@ const patientLoading = ref(false)
|
||||
const patientList = ref<any[]>([])
|
||||
|
||||
const createForm = reactive({
|
||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei',
|
||||
/** 仅「付呗」:开启后订单为待审核(5) */
|
||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei' | 'express_cod',
|
||||
/** 「付呗」「快递代收」:开启后订单为待审核(5) */
|
||||
requirePaymentSlipAudit: false,
|
||||
patient_id: '',
|
||||
order_type: '',
|
||||
@@ -1167,13 +1181,17 @@ const submitCreateOrder = async () => {
|
||||
if (createForm.createType === 'fubei') {
|
||||
params.payment_channel = 'fubei'
|
||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
||||
} else if (createForm.createType === 'express_cod') {
|
||||
params.payment_channel = 'express_cod'
|
||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
||||
} else {
|
||||
params.payment_channel = 'normal'
|
||||
params.require_payment_slip_audit = 0
|
||||
}
|
||||
await orderCreate(params)
|
||||
const tip =
|
||||
createForm.createType === 'fubei' && createForm.requirePaymentSlipAudit
|
||||
(createForm.createType === 'fubei' || createForm.createType === 'express_cod') &&
|
||||
createForm.requirePaymentSlipAudit
|
||||
? '订单已创建,支付状态为「待审核」'
|
||||
: '订单创建成功'
|
||||
feedback.msgSuccess(tip)
|
||||
@@ -1254,10 +1272,11 @@ const getCreateTypeText = (row: any) => {
|
||||
const createTypeMap: Record<string, string> = {
|
||||
normal: '普通订单',
|
||||
wechat_work: '企业微信对外收款',
|
||||
fubei: '付呗'
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收'
|
||||
}
|
||||
const ct = row?.create_type
|
||||
if (ct === 'wechat_work' || ct === 'fubei') {
|
||||
if (ct === 'wechat_work' || ct === 'fubei' || ct === 'express_cod') {
|
||||
return createTypeMap[ct]
|
||||
}
|
||||
if (ct === 'normal' && row?.payment_method) {
|
||||
@@ -1395,7 +1414,8 @@ const handleDetail = async (row: any) => {
|
||||
|
||||
// 支付订单
|
||||
const handlePay = (row: any) => {
|
||||
const fubeiPending = row.status === 5 && row.payment_method === 'fubei'
|
||||
const fubeiPending =
|
||||
row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod')
|
||||
payForm.value = {
|
||||
order_id: row.id,
|
||||
order_no: row.order_no,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="auto-assign-log-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="log-filter-form">
|
||||
<el-form-item label="执行日期">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
@change="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-select v-model="queryParams.action" clearable placeholder="全部" class="w-[120px]" @change="doSearch">
|
||||
<el-option label="已分配" value="1" />
|
||||
<el-option label="未分配" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="患者/手机号/医助/诊单ID"
|
||||
clearable
|
||||
class="w-[220px]"
|
||||
@keyup.enter="doSearch"
|
||||
@clear="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="log-hint">
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70% 每人2条/日,50%~60% 每人1条/日,<50% 不分)。每条待指派诊单一行,含分配 / 不分配原因。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||
<el-table-column label="记录时间" width="160">
|
||||
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="诊单" width="90" prop="diagnosis_id" />
|
||||
<el-table-column label="患者" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.patient_name || '—' }}</div>
|
||||
<div class="cell-sub">{{ row.patient_phone || '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="Number(row.action) === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.action_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医助" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.assistant_id) > 0">
|
||||
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="档位" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="轮次" width="70" align="center">
|
||||
<template #default="{ row }">{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" min-width="360" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.reason }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="统计月" width="90" prop="stat_month" align="center" />
|
||||
<el-table-column label="批次" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-sub">{{ row.batch_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="statsAutoAssignLog">
|
||||
import { autoAssignLogLists } from '@/api/stats'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
action: '' as '' | '0' | '1',
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
watch(dateRange, (val) => {
|
||||
queryParams.start_date = val?.[0] ?? ''
|
||||
queryParams.end_date = val?.[1] ?? ''
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: autoAssignLogLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const doSearch = () => resetPage()
|
||||
|
||||
const handleReset = () => {
|
||||
dateRange.value = null
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
queryParams.action = ''
|
||||
queryParams.keyword = ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
onMounted(() => getLists())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auto-assign-log-page {
|
||||
.log-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.log-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -129,7 +129,7 @@
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>诊单金额占比</span>
|
||||
<span>{{ amountPieTitle }}</span>
|
||||
<span class="card-hint">TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -146,17 +146,17 @@
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>加粉占比</span>
|
||||
<span>{{ secondaryPieTitle }}</span>
|
||||
<span class="card-hint">TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="fanPieHasData"
|
||||
v-if="secondaryPieHasData"
|
||||
class="stats-chart"
|
||||
:option="fanPieOption"
|
||||
:option="secondaryPieOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无加粉数据" />
|
||||
<el-empty v-else :description="secondaryPieEmptyText" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
|
||||
amounts: [],
|
||||
order_counts: [],
|
||||
fan_counts: [],
|
||||
rois: []
|
||||
rois: [],
|
||||
appointment_counts: [],
|
||||
interview_counts: []
|
||||
},
|
||||
amount_share: [],
|
||||
fan_share: []
|
||||
fan_share: [],
|
||||
order_share: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -392,7 +395,7 @@ const currentEntityId = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const summaryCards: MetricCard[] = [
|
||||
const defaultSummaryCards: MetricCard[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
||||
]
|
||||
|
||||
const tableColumns: MetricColumn[] = [
|
||||
const doctorSummaryCards: MetricCard[] = [
|
||||
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
|
||||
]
|
||||
|
||||
const defaultTableColumns: MetricColumn[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
||||
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
||||
]
|
||||
|
||||
const doctorTableColumns: MetricColumn[] = [
|
||||
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
|
||||
]
|
||||
|
||||
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
|
||||
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
|
||||
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
|
||||
|
||||
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
||||
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
||||
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
|
||||
const secondaryPieHasData = computed(() =>
|
||||
isDoctorDimension.value
|
||||
? (overview.charts.order_share?.length || 0) > 0
|
||||
: (overview.charts.fan_share?.length || 0) > 0
|
||||
)
|
||||
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
|
||||
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
|
||||
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
|
||||
|
||||
const rankingChartData = computed(() => {
|
||||
const ranking = overview.charts?.ranking || {}
|
||||
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
|
||||
return {
|
||||
names,
|
||||
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
||||
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
|
||||
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
|
||||
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
|
||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
|
||||
}
|
||||
})
|
||||
|
||||
const rankingChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rankingChartData.value.names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额 / 数量' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
const rankingChartOption = computed(() => {
|
||||
const common = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rankingChartData.value.names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '接诊诊单',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.fanCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额 / 数量' }
|
||||
]
|
||||
}
|
||||
|
||||
if (!isDoctorDimension.value) {
|
||||
return {
|
||||
...common,
|
||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
},
|
||||
{
|
||||
name: '接诊诊单',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.fanCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
...common,
|
||||
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
|
||||
series: [
|
||||
{
|
||||
name: '开药金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
},
|
||||
{
|
||||
name: '挂号数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.appointmentCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '面诊数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.interviewCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
},
|
||||
{
|
||||
name: '接诊单数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#7c3aed' }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const amountPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
name: amountPieTitle.value,
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
const fanPieOption = computed(() => ({
|
||||
const secondaryPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '加粉数',
|
||||
name: secondaryPieTitle.value,
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
data: overview.charts.fan_share
|
||||
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
|
||||
}
|
||||
]
|
||||
}))
|
||||
@@ -658,9 +742,18 @@ const handleReset = () => {
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const calcDoctorReceiveRate = (source: Record<string, any>) => {
|
||||
const completedOrderCount = Number(source?.completed_order_count || 0)
|
||||
const interviewCount = Number(source?.interview_count || 0)
|
||||
if (interviewCount <= 0) return 0
|
||||
return (completedOrderCount / interviewCount) * 100
|
||||
}
|
||||
|
||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
||||
if (placeholder) return '—'
|
||||
const value = source?.[key] ?? 0
|
||||
const value = isDoctorDimension.value && key === 'receive_rate'
|
||||
? calcDoctorReceiveRate(source)
|
||||
: source?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<div class="revisit-rate-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="rate-filter-form">
|
||||
<el-form-item label="统计月份">
|
||||
<el-date-picker
|
||||
v-model="month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择月份"
|
||||
:clearable="false"
|
||||
:disabled-date="disableFutureMonth"
|
||||
style="width: 160px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="deptId"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="二中心(全部)"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:props="deptTreeProps"
|
||||
style="width: 220px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
统计口径
|
||||
</span>
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||
</p>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="rate-kpi-grid">
|
||||
<div
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="rate-kpi-card"
|
||||
:class="{ 'is-clickable': card.clickable }"
|
||||
@click="card.clickable && card.onClick && card.onClick()"
|
||||
>
|
||||
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="rate-card-header">
|
||||
<span>部门 · 医助明细({{ monthText }})</span>
|
||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
size="small"
|
||||
border
|
||||
row-key="row_key"
|
||||
:tree-props="{ children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
show-summary
|
||||
:summary-method="getSummaries"
|
||||
>
|
||||
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_dept" class="rate-dept-cell">
|
||||
{{ row.dept_name }}
|
||||
<span class="rate-dept-count">{{ row.assistant_count }} 人</span>
|
||||
</span>
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row)"
|
||||
>
|
||||
{{ row.assigned_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.assigned_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-for="slot in visitSlots" :key="slot">
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_count`"
|
||||
:label="`${slotLabel(slot)}单数`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}单数</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
|
||||
>
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(slot, row)"
|
||||
>
|
||||
{{ row[`visit${slot}_count`] }}
|
||||
</el-link>
|
||||
<span v-else>{{ row[`visit${slot}_count`] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 下钻明细 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="980px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-table
|
||||
v-loading="detailLoading"
|
||||
:data="detailRows"
|
||||
size="small"
|
||||
border
|
||||
stripe
|
||||
max-height="60vh"
|
||||
>
|
||||
<template v-if="detailType === 'assign'">
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>
|
||||
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
|
||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderDetail && row.order_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderInfo(row.order_id)"
|
||||
>
|
||||
{{ row.order_no || `#${row.order_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>
|
||||
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="rate-detail-footer">共 {{ detailRows.length }} 条</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 订单详情抽屉(共享组件,与处方订单列表详情一致;只读) -->
|
||||
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||
|
||||
<!-- 诊单(患者)详情:编辑页只读模式 -->
|
||||
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitRateStatsPage">
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
import {
|
||||
revisitRateAssignLines,
|
||||
revisitRateDeptOptions,
|
||||
revisitRateOverview,
|
||||
revisitRateVisitOrderLines
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
|
||||
const OrderDetailDrawer = defineAsyncComponent(
|
||||
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||
)
|
||||
/** 诊单编辑弹窗(只读打开) */
|
||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
interface MetricPack extends Record<string, any> {
|
||||
assigned_count: number
|
||||
}
|
||||
|
||||
interface StatsRow extends MetricPack {
|
||||
row_key: string
|
||||
is_dept: 0 | 1
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
assistant_id?: number
|
||||
assistant_name?: string
|
||||
assistant_count?: number
|
||||
children?: StatsRow[]
|
||||
}
|
||||
|
||||
interface DeptTreeNode {
|
||||
id: number
|
||||
name: string
|
||||
pid: number
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
|
||||
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
|
||||
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||
|
||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
||||
const visitSlots = ref<number[]>([2, 3, 4])
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot >= 2 && slot <= 10) {
|
||||
return `${cnDigits[slot]}诊`
|
||||
}
|
||||
if (slot > 10 && slot < 20) {
|
||||
return `十${cnDigits[slot - 10]}诊`
|
||||
}
|
||||
return `${slot}诊`
|
||||
}
|
||||
|
||||
const initNow = new Date()
|
||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||
const month = ref<string>(currentMonth)
|
||||
const deptId = ref<number | undefined>(undefined)
|
||||
const loading = ref(false)
|
||||
const rows = ref<StatsRow[]>([])
|
||||
const total = ref<MetricPack | null>(null)
|
||||
|
||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
|
||||
const monthText = computed(() => {
|
||||
const [y, m] = month.value.split('-')
|
||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||
})
|
||||
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
const baseQuery = () => ({
|
||||
month: month.value,
|
||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||
})
|
||||
|
||||
interface SummaryCard {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
clickable: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const t = total.value
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'assigned',
|
||||
label: '当月被指派总数',
|
||||
value: t ? String(t.assigned_count) : '—',
|
||||
sub: '',
|
||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||
onClick: () => openAssignDetail(null)
|
||||
}
|
||||
]
|
||||
for (const slot of visitSlots.value) {
|
||||
const cnt = t ? t[`visit${slot}_count`] : null
|
||||
const rate = t ? t[`visit${slot}_rate`] : null
|
||||
cards.push({
|
||||
key: `visit${slot}`,
|
||||
label: `当月${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(rate) : '—',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||
clickable: !!(canViewOrderLines && cnt > 0),
|
||||
onClick: () => openOrderDetail(slot, null)
|
||||
})
|
||||
}
|
||||
return cards
|
||||
})
|
||||
|
||||
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
|
||||
const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||
const t = total.value
|
||||
return columns.map((col, index) => {
|
||||
if (index === 0) {
|
||||
return '合计'
|
||||
}
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
const prop = String(col.property || '')
|
||||
if (prop === 'assigned_count') {
|
||||
return String(t.assigned_count)
|
||||
}
|
||||
if (/^visit\d+_count$/.test(prop)) {
|
||||
return String(t[prop] ?? '')
|
||||
}
|
||||
if (/^visit\d+_rate$/.test(prop)) {
|
||||
return formatRate(t[prop])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
const disableFutureMonth = (date: Date) => {
|
||||
const now = new Date()
|
||||
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await revisitRateOverview(baseQuery())
|
||||
rows.value = res?.rows ?? []
|
||||
total.value = res?.total ?? null
|
||||
if (Array.isArray(res?.slots) && res.slots.length) {
|
||||
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
|
||||
}
|
||||
if (res?.month) {
|
||||
month.value = res.month
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||
const map = new Map<number, DeptTreeNode>()
|
||||
for (const d of flat) {
|
||||
map.set(d.id, { ...d, children: [] })
|
||||
}
|
||||
const roots: DeptTreeNode[] = []
|
||||
for (const d of flat) {
|
||||
const node = map.get(d.id)!
|
||||
if (d.pid > 0 && map.has(d.pid)) {
|
||||
map.get(d.pid)!.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const strip = (n: DeptTreeNode) => {
|
||||
if (n.children?.length) {
|
||||
n.children.forEach(strip)
|
||||
} else {
|
||||
delete n.children
|
||||
}
|
||||
}
|
||||
roots.forEach(strip)
|
||||
return roots
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await revisitRateDeptOptions()
|
||||
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
|
||||
} catch {
|
||||
deptTreeOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── 下钻明细 ──────────────────────────────────────────
|
||||
const detailVisible = ref(false)
|
||||
const detailType = ref<'assign' | 'orders'>('assign')
|
||||
const detailTitle = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailRows = ref<any[]>([])
|
||||
|
||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||
if (!row) {
|
||||
return { label: '合计' }
|
||||
}
|
||||
if (row.is_dept) {
|
||||
return { dept_id: row.dept_id, label: row.dept_name }
|
||||
}
|
||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
|
||||
const openAssignDetail = async (row: StatsRow | null) => {
|
||||
if (!canViewAssignLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'assign'
|
||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
try {
|
||||
const res = await revisitRateAssignLines({
|
||||
...baseQuery(),
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||
if (!canViewOrderLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'orders'
|
||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
try {
|
||||
const res = await revisitRateVisitOrderLines({
|
||||
...baseQuery(),
|
||||
slot,
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 订单详情(复用处方订单列表的详情抽屉) ──────────────
|
||||
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||
|
||||
const openOrderInfo = (orderId: number) => {
|
||||
if (!canViewOrderDetail || !orderId) {
|
||||
return
|
||||
}
|
||||
orderDetailRef.value?.open(orderId)
|
||||
}
|
||||
|
||||
// ── 患者(诊单)详情:诊单编辑页只读打开 ────────────────
|
||||
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||
|
||||
const openPatientDetail = (diagnosisId: number) => {
|
||||
if (!canViewPatientDetail || !diagnosisId) {
|
||||
return
|
||||
}
|
||||
diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
month.value = currentMonth
|
||||
deptId.value = undefined
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revisit-rate-page {
|
||||
.rate-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-caliber-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.rate-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.rate-kpi-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rate-kpi-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rate-kpi-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
|
||||
.rate-card-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-dept-cell {
|
||||
font-weight: 600;
|
||||
|
||||
.rate-dept-count {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-th-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-detail-footer {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rate-caliber {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -83,6 +83,23 @@
|
||||
<el-radio-button value="0">未确认</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">部门</span>
|
||||
<el-tree-select
|
||||
v-model="formData.assistant_dept_id"
|
||||
:data="departmentTreeRaw"
|
||||
class="filter-dept-select"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
node-key="id"
|
||||
size="small"
|
||||
:props="assistantDeptTreeProps"
|
||||
placeholder="选父级含子级"
|
||||
@change="handleAssistantDeptChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
@@ -542,6 +559,7 @@
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
import { deptAll } from '@/api/org/department'
|
||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { addDoctorNote } from '@/api/patient'
|
||||
@@ -603,10 +621,19 @@ const formData = reactive({
|
||||
end_date: '',
|
||||
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
||||
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
|
||||
assistant_dept_id: '' as number | '',
|
||||
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
||||
include_status_counts: 0 as 0 | 1
|
||||
})
|
||||
|
||||
const departmentTreeRaw = ref<unknown[]>([])
|
||||
const assistantDeptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
}
|
||||
|
||||
const activeTab = ref('1')
|
||||
const dateCustomVisible = ref(false)
|
||||
const statusCount = ref<Record<number, number>>({
|
||||
@@ -721,6 +748,12 @@ const handleDiagnosisConfirmedChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 部门筛选变更
|
||||
const handleAssistantDeptChange = () => {
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 快捷日期变更
|
||||
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
|
||||
const v = String(val || '')
|
||||
@@ -770,6 +803,7 @@ const handleReset = () => {
|
||||
formData.doctor_name = ''
|
||||
formData.date_preset = 'today'
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.assistant_dept_id = ''
|
||||
const t = new Date()
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
|
||||
@@ -1099,7 +1133,13 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
|
||||
formData.end_date = formData.start_date
|
||||
formData.status = 1
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const deptTree = await deptAll()
|
||||
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
|
||||
} catch {
|
||||
departmentTreeRaw.value = []
|
||||
}
|
||||
loadData()
|
||||
listPollTimer = setInterval(() => {
|
||||
loadData({ silent: true })
|
||||
@@ -1228,6 +1268,10 @@ onUnmounted(() => {
|
||||
padding: 6px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dept-select {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
}
|
||||
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -572,6 +576,7 @@ const formRules = {
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,43 @@
|
||||
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="diagnosisId > 0" class="po-revisit-offset-bar mb-3">
|
||||
<div class="po-revisit-offset-bar__main">
|
||||
<span class="text-sm text-gray-600">复诊统计起始偏移</span>
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div class="max-w-xs leading-relaxed">
|
||||
在实单诊次序号上叠加偏移量。设为 0(默认):第 1 笔实单计为一诊;设为 1:第 1 笔实单计为二诊;设为 2:第 1 笔实单计为三诊——若有 5 笔实单且偏移 2,则统计上相当于计至七诊(5+2)。
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="text-gray-400 align-middle ml-1"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-input-number
|
||||
v-model="revisitSlotStartOffset"
|
||||
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-[120px] ml-3"
|
||||
:disabled="offsetSaving"
|
||||
/>
|
||||
<span class="text-xs text-gray-500 ml-2">
|
||||
第 1 笔实单计为{{ visitSlotStartLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="offsetSaving"
|
||||
:disabled="!offsetDirty"
|
||||
@click="saveRevisitSlotStartOffset"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
@@ -12,6 +49,23 @@
|
||||
empty-text="暂无业务订单"
|
||||
>
|
||||
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="全局诊次" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.global_visit_seq">{{ row.global_visit_seq }}诊</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计入统计" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.counts_for_revisit_rate"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>是</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="plain">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
||||
@@ -56,298 +110,23 @@
|
||||
</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" v-if="showPrescriptionAuditFilter">
|
||||
{{ 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="服务套餐">{{ formatServicePackage(detailData.service_package) }}</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>
|
||||
<!-- 业务订单详情抽屉:共享组件的 readonly 受限版(展示范围由组件内 v-if="!readonly" 门控) -->
|
||||
<prescription-order-detail-drawer ref="detailDrawerRef" readonly append-to-body />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref, onMounted } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import {
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderPaidPayOrders
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
import {
|
||||
formatTime,
|
||||
fulfillmentText,
|
||||
fulfillmentTagType
|
||||
} from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
@@ -368,6 +147,26 @@ const { pager, getLists, resetPage } = usePaging({
|
||||
size: 10
|
||||
})
|
||||
|
||||
const revisitSlotStartOffset = ref(0)
|
||||
const savedRevisitSlotStartOffset = ref(0)
|
||||
const offsetSaving = ref(false)
|
||||
const offsetLoading = ref(false)
|
||||
|
||||
const offsetDirty = computed(
|
||||
() => Number(revisitSlotStartOffset.value) !== Number(savedRevisitSlotStartOffset.value)
|
||||
)
|
||||
|
||||
const visitSlotStartLabel = computed(() => {
|
||||
const raw = Number(revisitSlotStartOffset.value)
|
||||
const offset = Number.isFinite(raw) ? raw : 0
|
||||
const slot = offset + 1
|
||||
const cn = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
if (slot >= 1 && slot <= 10) {
|
||||
return cn[slot] + '诊'
|
||||
}
|
||||
return `第${slot}诊`
|
||||
})
|
||||
|
||||
const buildParams = () => {
|
||||
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
||||
if (props.diagnosisId > 0) {
|
||||
@@ -379,179 +178,52 @@ const buildParams = () => {
|
||||
queryParams.scene = 'diagnosis_edit'
|
||||
}
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」相关字段
|
||||
*/
|
||||
const showPrescriptionAuditFilter = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
// ─── 详情抽屉 ───
|
||||
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 snap = d.agency_collect_amount
|
||||
if (snap !== null && snap !== undefined && snap !== '') {
|
||||
return Number(snap) || 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
|
||||
async function loadRevisitSlotStartOffset() {
|
||||
if (props.diagnosisId <= 0) return
|
||||
offsetLoading.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>
|
||||
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
|
||||
const d = res?.data ?? res ?? {}
|
||||
const offset = Number(d.revisit_slot_start_offset)
|
||||
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
|
||||
revisitSlotStartOffset.value = val
|
||||
savedRevisitSlotStartOffset.value = val
|
||||
} catch {
|
||||
logisticsTracePayload.value = null
|
||||
revisitSlotStartOffset.value = 0
|
||||
savedRevisitSlotStartOffset.value = 0
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
offsetLoading.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
|
||||
async function saveRevisitSlotStartOffset() {
|
||||
if (props.diagnosisId <= 0) return
|
||||
const offset = Number(revisitSlotStartOffset.value)
|
||||
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
|
||||
feedback.msgWarning('起始偏移须在 0~20 之间')
|
||||
return
|
||||
}
|
||||
offsetSaving.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
|
||||
await tcmDiagnosisSetRevisitSlotStartOffset({
|
||||
id: props.diagnosisId,
|
||||
revisit_slot_start_offset: offset
|
||||
})
|
||||
savedRevisitSlotStartOffset.value = offset
|
||||
feedback.msgSuccess('保存成功')
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
offsetSaving.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 = [] }
|
||||
}
|
||||
// ─── 详情抽屉(共享组件,数据拉取/展示全部在组件内) ───
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
|
||||
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()
|
||||
function openDetail(id: number) {
|
||||
detailDrawerRef.value?.open(id)
|
||||
}
|
||||
|
||||
const formatAmount = (value: unknown) => {
|
||||
@@ -559,131 +231,16 @@ const formatAmount = (value: unknown) => {
|
||||
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) {
|
||||
const d = new Date(value * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
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)] ?? '—'
|
||||
}
|
||||
|
||||
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order)
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
async function loadServicePackageOptions() {
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'server_order' })
|
||||
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
|
||||
servicePackageOptions.value = list.filter((item) => item.status !== 0)
|
||||
} catch {
|
||||
servicePackageOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatServicePackage(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value.map((v) => String(v)).filter((v) => v !== '')
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
|
||||
}
|
||||
if (packages.length === 0) return '—'
|
||||
const names = packages.map((val) => {
|
||||
const opt = servicePackageOptions.value.find((o) => o.value === val)
|
||||
return opt ? opt.name : val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServicePackageOptions()
|
||||
})
|
||||
|
||||
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: '制药缓发' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
|
||||
const n = Number(s)
|
||||
if (n === 3 || n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4 || n === 9 || n === 10) return 'danger'
|
||||
if (n === 2 || n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
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()
|
||||
void loadRevisitSlotStartOffset()
|
||||
resetPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -699,22 +256,21 @@ defineExpose({ refresh: () => getLists() })
|
||||
.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;
|
||||
.po-revisit-offset-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
.po-revisit-offset-bar__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.stamp-pass { color: #22c55e; }
|
||||
.stamp-reject { color: #ef4444; }
|
||||
</style>
|
||||
|
||||
@@ -67,26 +67,66 @@
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
|
||||
<el-alert
|
||||
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
>
|
||||
<template v-if="latestPrescriptionOrder">
|
||||
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为「{{ latestPrescriptionOrder.fulfillment_status_text }}」,患者基本信息不可修改
|
||||
</template>
|
||||
<template v-else>
|
||||
存在未完成的业务订单,患者基本信息不可修改
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form-item label="诊单ID">
|
||||
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
||||
</el-form-item>
|
||||
|
||||
<fieldset
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="patient_name">
|
||||
<el-input
|
||||
v-model="formData.patient_name"
|
||||
placeholder="请输入姓名"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份证号" prop="id_card">
|
||||
<template v-if="showIdCardMaskedEdit">
|
||||
<el-input
|
||||
:model-value="
|
||||
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
|
||||
"
|
||||
readonly
|
||||
placeholder="点击可查看完整身份证号"
|
||||
maxlength="18"
|
||||
class="cursor-pointer phone-mask-toggle"
|
||||
@click="toggleIdCardReveal"
|
||||
/>
|
||||
<p
|
||||
v-if="originalIdCard && !idCardRevealUnlocked"
|
||||
class="text-xs text-gray-400 mt-1"
|
||||
>
|
||||
当前为脱敏显示,点击输入框可查看完整号码
|
||||
</p>
|
||||
</template>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formData.id_card"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
:disabled="!canEditIdCard"
|
||||
@focus="handleIdCardFocus"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
@@ -120,6 +160,7 @@
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
@focus="handlePhoneFocus"
|
||||
@blur="handlePhoneBlur"
|
||||
/>
|
||||
@@ -127,7 +168,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="formData.gender">
|
||||
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
|
||||
<el-radio :label="1">男</el-radio>
|
||||
<el-radio :label="0">女</el-radio>
|
||||
</el-radio-group>
|
||||
@@ -144,11 +185,14 @@
|
||||
:max="150"
|
||||
placeholder="请输入年龄"
|
||||
class="w-full"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
:controls="canEditPatientBasicFields"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<!-- 生命体征 -->
|
||||
@@ -778,9 +822,40 @@ const submitting = ref(false)
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
||||
const patientBasicLocked = ref(false)
|
||||
const canEditPatientBasicFromApi = ref(true)
|
||||
const latestPrescriptionOrder = ref<{
|
||||
id: number
|
||||
fulfillment_status: number
|
||||
fulfillment_status_text: string
|
||||
} | null>(null)
|
||||
const canEditPatientBasicFields = computed(() => {
|
||||
if (viewOnly.value) return false
|
||||
if (mode.value === 'add') return true
|
||||
return canEditPatientBasicFromApi.value
|
||||
})
|
||||
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => {
|
||||
if (mode.value !== 'edit') return false
|
||||
if (!canEditPatientBasicFields.value) return true
|
||||
return !hasPhonePlainPermission.value
|
||||
})
|
||||
const phoneRevealUnlocked = ref(false)
|
||||
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
|
||||
const showIdCardMaskedEdit = computed(() => {
|
||||
if (mode.value !== 'edit') return false
|
||||
if (!originalIdCard.value) return false
|
||||
if (!canEditPatientBasicFields.value) return true
|
||||
return !hasPhonePlainPermission.value
|
||||
})
|
||||
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
|
||||
const canEditIdCard = computed(() => {
|
||||
if (!canEditPatientBasicFields.value) return false
|
||||
if (mode.value === 'add') return true
|
||||
if (!originalIdCard.value) return true
|
||||
return hasPhonePlainPermission.value
|
||||
})
|
||||
const idCardRevealUnlocked = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const drawerTitle = computed(() => {
|
||||
if (viewOnly.value) return '诊单详情'
|
||||
@@ -920,6 +995,11 @@ const togglePhoneReveal = () => {
|
||||
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
||||
}
|
||||
|
||||
const toggleIdCardReveal = () => {
|
||||
if (!originalIdCard.value) return
|
||||
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
|
||||
}
|
||||
|
||||
const maskIdCard = (idCard: string) => {
|
||||
if (!idCard) return idCard
|
||||
if (idCard.length === 15) {
|
||||
@@ -990,43 +1070,40 @@ const handlePhoneBlur = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证聚焦 - 显示完整数据
|
||||
// 身份证聚焦 - 显示完整数据(无「明文」权限时仅新增模式在失焦后可再次展开编辑)
|
||||
const handleIdCardFocus = () => {
|
||||
isIdCardFocused.value = true
|
||||
if (originalIdCard.value) {
|
||||
if (!originalIdCard.value) return
|
||||
if (hasPhonePlainPermission.value) {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
return
|
||||
}
|
||||
if (mode.value === 'add') {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证失焦 - 恢复脱敏并验证
|
||||
// 身份证失焦:有明文权限则保持明文并校验;新增且无明文权限时仍脱敏展示
|
||||
const handleIdCardBlur = async () => {
|
||||
isIdCardFocused.value = false
|
||||
|
||||
// 保存原始数据并脱敏显示
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
|
||||
// 验证身份证格式并计算年龄
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
// 从身份证提取出生日期计算年龄
|
||||
const id = formData.value.id_card
|
||||
const birthYear = parseInt(id.substring(6, 10))
|
||||
const birthMonth = parseInt(id.substring(10, 12))
|
||||
const birthDay = parseInt(id.substring(12, 14))
|
||||
|
||||
const processIdCard = async (idCard: string) => {
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
|
||||
const birthYear = parseInt(idCard.substring(6, 10))
|
||||
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||
const birthDay = parseInt(idCard.substring(12, 14))
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
formData.value.age = age
|
||||
|
||||
// 验证身份证唯一性
|
||||
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id_card: idCard,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
@@ -1034,8 +1111,24 @@ const handleIdCardBlur = async () => {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏显示
|
||||
}
|
||||
|
||||
if (hasPhonePlainPermission.value) {
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
await processIdCard(formData.value.id_card)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑模式下已有身份证号且无明文权限时不处理
|
||||
if (mode.value === 'edit' && originalIdCard.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
await processIdCard(formData.value.id_card)
|
||||
formData.value.id_card = maskIdCard(formData.value.id_card)
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
// 使用原始数据进行验证
|
||||
const idCardToValidate = originalIdCard.value || value
|
||||
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
if (!idCardToValidate) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -1073,6 +1170,7 @@ const formRules = {
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
@@ -1167,9 +1265,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
|
||||
originalPhone.value = data.phone || ''
|
||||
originalIdCard.value = data.id_card || ''
|
||||
patientBasicLocked.value = !!data.patient_basic_locked
|
||||
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
|
||||
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
|
||||
|
||||
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
|
||||
data.id_card = maskIdCard(data.id_card || '')
|
||||
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
|
||||
|
||||
formData.value = data
|
||||
formData.value.create_source = data.create_source ?? ''
|
||||
@@ -1178,12 +1279,20 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||
}
|
||||
|
||||
const resetPatientBasicLockState = () => {
|
||||
patientBasicLocked.value = false
|
||||
canEditPatientBasicFromApi.value = true
|
||||
latestPrescriptionOrder.value = null
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
viewOnly.value = false
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
@@ -1206,6 +1315,8 @@ const openViewOnly = async (id: number) => {
|
||||
visible.value = true
|
||||
activeTab.value = 'basic'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
await getDictOptions()
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
}
|
||||
@@ -1230,6 +1341,7 @@ const handleSubmit = async () => {
|
||||
if (result && result.id) {
|
||||
mode.value = 'edit'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
formData.value.id = result.id
|
||||
|
||||
// 重新获取详情,确保patient_id等字段正确
|
||||
@@ -1244,7 +1356,9 @@ const handleSubmit = async () => {
|
||||
formData.value.phone = hasPhonePlainPermission.value
|
||||
? detail.phone || ''
|
||||
: maskPhone(detail.phone || '')
|
||||
formData.value.id_card = maskIdCard(detail.id_card || '')
|
||||
formData.value.id_card = hasPhonePlainPermission.value
|
||||
? detail.id_card || ''
|
||||
: maskIdCard(detail.id_card || '')
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
@@ -1346,6 +1460,7 @@ const handleClose = () => {
|
||||
isPhoneFocused.value = false
|
||||
isIdCardFocused.value = false
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
viewOnly.value = false
|
||||
|
||||
visible.value = false
|
||||
@@ -1370,6 +1485,19 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.diagnosis-patient-basic-fieldset:disabled {
|
||||
opacity: 1;
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-input-number),
|
||||
:deep(.el-input-number__decrease),
|
||||
:deep(.el-input-number__increase),
|
||||
:deep(.el-radio),
|
||||
:deep(.el-radio__input) {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form) {
|
||||
padding-bottom: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -121,6 +121,16 @@
|
||||
end-placeholder="最近挂号结束"
|
||||
@change="handleLatestAppointmentFilterChange"
|
||||
/>
|
||||
<daterange-picker
|
||||
class="latest-assign-range"
|
||||
v-model:startTime="formData.latest_assign_start_date"
|
||||
v-model:endTime="formData.latest_assign_end_date"
|
||||
picker-type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="最近指派开始"
|
||||
end-placeholder="最近指派结束"
|
||||
@change="handleLatestAssignFilterChange"
|
||||
/>
|
||||
<el-select
|
||||
v-model="formData.latest_appointment_channel_source"
|
||||
placeholder="最近挂号渠道"
|
||||
@@ -173,6 +183,7 @@
|
||||
v-loading="pager.loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="goReadonly"
|
||||
@sort-change="handleTableSortChange"
|
||||
:row-class-name="getRowClassName"
|
||||
class="diagnosis-table"
|
||||
stripe
|
||||
@@ -283,7 +294,14 @@
|
||||
<span v-else class="status-unprescribed">未开方</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="未服务天数" width="110" align="center">
|
||||
<el-table-column
|
||||
label="未服务天数"
|
||||
prop="unserved_days"
|
||||
width="110"
|
||||
align="center"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.last_blood_record_at"
|
||||
@@ -762,6 +780,10 @@ const formData = reactive({
|
||||
latest_appointment_start_date: '' as string,
|
||||
latest_appointment_end_date: '' as string,
|
||||
latest_appointment_channel_source: '' as string,
|
||||
latest_assign_start_date: '' as string,
|
||||
latest_assign_end_date: '' as string,
|
||||
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||
appointment_date: '' as string,
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
@@ -849,22 +871,50 @@ function resolvePendingAssignOrderMonthForRequest(): string {
|
||||
return dayjs().format('YYYY-MM')
|
||||
}
|
||||
|
||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||
return buildTcmDiagnosisListRequestPayload({
|
||||
...formData,
|
||||
/** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步) */
|
||||
function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
||||
return {
|
||||
keyword: formData.keyword,
|
||||
diagnosis_type: formData.diagnosis_type,
|
||||
syndrome_type: formData.syndrome_type,
|
||||
assistant_id: formData.assistant_id,
|
||||
diagnosis_confirmed: formData.diagnosis_confirmed,
|
||||
has_appointment: formData.has_appointment,
|
||||
latest_appointment_start_date: formData.latest_appointment_start_date,
|
||||
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||
latest_assign_start_date: formData.latest_assign_start_date,
|
||||
latest_assign_end_date: formData.latest_assign_end_date
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
page_no: 1,
|
||||
page_size: 1,
|
||||
pending_assign: 1,
|
||||
...buildSharedDiagnosisFilterPayload(),
|
||||
appointment_date: '',
|
||||
has_appointment: '',
|
||||
pending_booking: '',
|
||||
completed_appointment: '',
|
||||
latest_appointment_start_date: '',
|
||||
latest_appointment_end_date: '',
|
||||
latest_appointment_channel_source: '',
|
||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
|
||||
} as Record<string, unknown>) as Record<string, unknown>
|
||||
pending_assign: '',
|
||||
pending_assign_order_month: '',
|
||||
pending_assign_keyword: '',
|
||||
sort_unserved_days: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||
return buildTcmDiagnosisListRequestPayload(
|
||||
buildDateCountRequestPayload({
|
||||
pending_assign: 1,
|
||||
has_appointment: '',
|
||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest(),
|
||||
pending_assign_keyword: formData.pending_assign_keyword
|
||||
}) as Record<string, unknown>
|
||||
) as Record<string, unknown>
|
||||
}
|
||||
|
||||
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
||||
@@ -893,6 +943,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
||||
formData.latest_appointment_start_date = ''
|
||||
formData.latest_appointment_end_date = ''
|
||||
formData.latest_appointment_channel_source = ''
|
||||
formData.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
formData.sort_unserved_days = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
activeTab.value = 'all'
|
||||
if (kw1 !== '') {
|
||||
@@ -994,14 +1047,26 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
||||
const fetchDateCounts = async () => {
|
||||
try {
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
|
||||
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(
|
||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: todayStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: tomorrowStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: dayAfterStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(buildDateCountRequestPayload() as any),
|
||||
tcmDiagnosisLists(buildDateCountRequestPayload({ has_appointment: 0 }) as any),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||
])
|
||||
dateCounts.value = {
|
||||
@@ -1135,6 +1200,11 @@ const clearLatestAppointmentFilters = () => {
|
||||
formData.latest_appointment_channel_source = ''
|
||||
}
|
||||
|
||||
const clearLatestAssignFilters = () => {
|
||||
formData.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
}
|
||||
|
||||
const hasLatestAppointmentFilter = () =>
|
||||
!!(
|
||||
formData.latest_appointment_start_date ||
|
||||
@@ -1142,6 +1212,9 @@ const hasLatestAppointmentFilter = () =>
|
||||
formData.latest_appointment_channel_source
|
||||
)
|
||||
|
||||
const hasLatestAssignFilter = () =>
|
||||
!!(formData.latest_assign_start_date || formData.latest_assign_end_date)
|
||||
|
||||
const handleLatestAppointmentFilterChange = () => {
|
||||
if (hasLatestAppointmentFilter()) {
|
||||
formData.appointment_date = ''
|
||||
@@ -1154,6 +1227,27 @@ const handleLatestAppointmentFilterChange = () => {
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const handleLatestAssignFilterChange = () => {
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const handleTableSortChange = ({
|
||||
prop,
|
||||
order
|
||||
}: {
|
||||
prop: string
|
||||
order: 'ascending' | 'descending' | null
|
||||
}) => {
|
||||
if (prop === 'unserved_days') {
|
||||
formData.sort_unserved_days =
|
||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||
} else {
|
||||
formData.sort_unserved_days = ''
|
||||
}
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
|
||||
const latestAppointmentChannelText = (row: any) => {
|
||||
const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
|
||||
const raw = String(row?.latest_appointment_channel_source || '').trim()
|
||||
@@ -1200,6 +1294,9 @@ const handleReset = () => {
|
||||
formData.latest_appointment_start_date = ''
|
||||
formData.latest_appointment_end_date = ''
|
||||
formData.latest_appointment_channel_source = ''
|
||||
formData.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
formData.sort_unserved_days = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
@@ -2298,6 +2395,11 @@ onUnmounted(() => {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.latest-assign-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.latest-appointment-channel {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -169,14 +169,19 @@ class AssetResourceController extends BaseAdminController
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->post('id');
|
||||
if (empty($id)) {
|
||||
$ids = is_array($id) ? $id : [$id];
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
|
||||
return $item > 0;
|
||||
})));
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
AssetResource::destroy($id);
|
||||
AssetUserResource::where('resource_id', $id)->delete();
|
||||
AssetResource::destroy($ids);
|
||||
AssetUserResource::whereIn('resource_id', $ids)->delete();
|
||||
Db::commit();
|
||||
return $this->success('删除成功');
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -207,11 +207,17 @@ class OrderController extends BaseAdminController
|
||||
$params['creator_id'] = $this->adminId;
|
||||
$params['payment_channel'] = (string)$this->request->post('payment_channel', 'normal');
|
||||
$params['require_payment_slip_audit'] = (int)$this->request->post('require_payment_slip_audit', 0);
|
||||
// 创建方式:优先取前端透传的 create_type,否则按 payment_channel 派生(fubei→fubei,其余 normal)
|
||||
// 创建方式:优先取前端透传的 create_type,否则按 payment_channel 派生(fubei→fubei,express_cod→express_cod,其余 normal)
|
||||
$createTypeReq = (string)$this->request->post('create_type', '');
|
||||
$params['create_type'] = in_array($createTypeReq, ['normal', 'wechat_work', 'fubei'], true)
|
||||
? $createTypeReq
|
||||
: ($params['payment_channel'] === 'fubei' ? 'fubei' : 'normal');
|
||||
if (in_array($createTypeReq, ['normal', 'wechat_work', 'fubei', 'express_cod'], true)) {
|
||||
$params['create_type'] = $createTypeReq;
|
||||
} elseif ($params['payment_channel'] === 'fubei') {
|
||||
$params['create_type'] = 'fubei';
|
||||
} elseif ($params['payment_channel'] === 'express_cod') {
|
||||
$params['create_type'] = 'express_cod';
|
||||
} else {
|
||||
$params['create_type'] = 'normal';
|
||||
}
|
||||
|
||||
$result = OrderLogic::create($params);
|
||||
if (!$result) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\stats\AutoAssignLogLists;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派日志
|
||||
*
|
||||
* - GET stats.autoAssignLog/lists 日志列表(每条待指派诊单一行:分配结果 + 原因)
|
||||
*/
|
||||
class AutoAssignLogController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new AutoAssignLogLists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||
|
||||
/**
|
||||
* 复诊接诊率统计(按月)
|
||||
*
|
||||
* - GET stats.revisitRate/overview 当月 N 诊接诊率(部门 → 医助分组 + 合计)
|
||||
* - GET stats.revisitRate/deptOptions 部门下拉(前端组树)
|
||||
* - GET stats.revisitRate/assignLines 「被指派数」明细(按诊单聚合)
|
||||
* - GET stats.revisitRate/visitOrderLines 「N 诊单数」订单明细
|
||||
*/
|
||||
class RevisitRateController extends BaseAdminController
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
// 涉及指派日志 + 全量订单序列扫描,放宽执行时间兜底
|
||||
@set_time_limit(120);
|
||||
$params = $this->request->get();
|
||||
|
||||
return $this->data(RevisitRateLogic::overview($params));
|
||||
}
|
||||
|
||||
public function deptOptions()
|
||||
{
|
||||
return $this->data(RevisitRateLogic::deptOptions());
|
||||
}
|
||||
|
||||
/** 「被指派数」点击下钻:诊单维度明细 */
|
||||
public function assignLines()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
$params = $this->request->get();
|
||||
|
||||
return $this->data(RevisitRateLogic::assignLines($params));
|
||||
}
|
||||
|
||||
/** 「N 诊单数」点击下钻:具体订单明细 */
|
||||
public function visitOrderLines()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
$params = $this->request->get();
|
||||
|
||||
return $this->data(RevisitRateLogic::visitOrderLines($params));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
|
||||
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
|
||||
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
|
||||
* - GET stats.yejiStats/assignLines 被指派数明细(医助×诊单去重,与看板同口径)
|
||||
*/
|
||||
class YejiStatsController extends BaseAdminController
|
||||
{
|
||||
@@ -141,4 +142,13 @@ class YejiStatsController extends BaseAdminController
|
||||
|
||||
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
/** 被指派数明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重) */
|
||||
public function assignLines()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
$params = $this->request->get();
|
||||
|
||||
return $this->data(YejiStatsLogic::assignLines($params, $this->adminId, $this->adminInfo));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +87,31 @@ class DiagnosisController extends BaseAdminController
|
||||
public function edit()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('edit');
|
||||
$result = DiagnosisLogic::edit($params);
|
||||
$result = DiagnosisLogic::edit($params, $this->adminInfo);
|
||||
if ($result) {
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 设置复诊接诊率统计起始偏移(业务订单 tab)
|
||||
*/
|
||||
public function setRevisitSlotStartOffset()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('setRevisitSlotStartOffset');
|
||||
$ok = DiagnosisLogic::setRevisitSlotStartOffset(
|
||||
(int) $params['id'],
|
||||
(int) $params['revisit_slot_start_offset'],
|
||||
$this->adminInfo
|
||||
);
|
||||
if (!$ok) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除诊单
|
||||
* @return \think\response\Json
|
||||
@@ -116,7 +134,7 @@ class DiagnosisController extends BaseAdminController
|
||||
{
|
||||
|
||||
$params = (new DiagnosisValidate())->goCheck('id');
|
||||
$result = DiagnosisLogic::detail($params);
|
||||
$result = DiagnosisLogic::detail($params, $this->adminInfo);
|
||||
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,20 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
|
||||
*/
|
||||
public function patchPrescriptionUsage()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionUsage');
|
||||
$ok = PrescriptionOrderLogic::patchPrescriptionUsage($params, $this->adminId, $this->adminInfo);
|
||||
if (!$ok) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
public function auditPrescription()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
||||
@@ -286,6 +300,32 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('已撤回', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 批量将处方业务订单改派给其他医助(写入 creator_id 并逐单记操作日志)
|
||||
*/
|
||||
public function batchAssignAssistant()
|
||||
{
|
||||
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||
return $this->fail('无权限批量改派订单');
|
||||
}
|
||||
$params = $this->request->post();
|
||||
$rawIds = $params['order_ids'] ?? [];
|
||||
if (!\is_array($rawIds) || $rawIds === []) {
|
||||
return $this->fail('请选择订单');
|
||||
}
|
||||
$assistantId = (int) ($params['assistant_id'] ?? 0);
|
||||
$result = PrescriptionOrderLogic::batchAssignAssistant($rawIds, $assistantId, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
$msg = '已改派 ' . (int) $result['success'] . ' 单';
|
||||
if (!empty($result['errors'])) {
|
||||
$msg .= ';' . implode(';', $result['errors']);
|
||||
}
|
||||
|
||||
return $this->success($msg, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务订单操作日志
|
||||
*/
|
||||
@@ -302,6 +342,20 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工新增操作日志(可选同步调整处方/支付单审核状态)
|
||||
*/
|
||||
public function addLog()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('addLog');
|
||||
$result = PrescriptionOrderLogic::addLog($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('日志已添加', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace app\adminapi\lists\doctor;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Prescription;
|
||||
@@ -75,6 +77,35 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按部门筛选:接诊医生、诊单医助或挂号医助所属部门命中子树即可(选父级含子级)
|
||||
*
|
||||
* @param mixed $query
|
||||
*/
|
||||
private function applyAssistantDeptIdFilter($query): void
|
||||
{
|
||||
if (!isset($this->params['assistant_dept_id']) || $this->params['assistant_dept_id'] === '' || (int) $this->params['assistant_dept_id'] <= 0) {
|
||||
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 {
|
||||
return $id > 0;
|
||||
}));
|
||||
if ($deptIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$inList = implode(',', $deptIds);
|
||||
$adTbl = (new AdminDept())->getTable();
|
||||
$query->whereRaw(
|
||||
"(EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`doctor_id` AND ad.`dept_id` IN ({$inList}))"
|
||||
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = u.`assistant_id` AND ad.`dept_id` IN ({$inList}))"
|
||||
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`assistant_id` AND ad.`dept_id` IN ({$inList})))"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
|
||||
*
|
||||
@@ -191,6 +222,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
|
||||
$this->applyAssistantIdFilter($query);
|
||||
|
||||
$this->applyAssistantDeptIdFilter($query);
|
||||
|
||||
$this->applyChannelSourceFilter($query, $chFilter);
|
||||
|
||||
// 是否确认诊单:1=已确认 0=未确认
|
||||
@@ -373,6 +406,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
|
||||
$this->applyAssistantIdFilter($query);
|
||||
|
||||
$this->applyAssistantDeptIdFilter($query);
|
||||
|
||||
$this->applyChannelSourceFilter($query, $chFilter);
|
||||
|
||||
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\stats;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派日志列表
|
||||
*
|
||||
* 数据来源:tcm_diagnosis_auto_assign_log(定时命令 tcm:auto-assign-pending 写入)
|
||||
* 筛选:run_date(执行日期)/ action(1=已分配 0=未分配)/ assistant_id / keyword(患者姓名、手机号、医助姓名,纯数字兼容诊单ID)
|
||||
*/
|
||||
class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
private const TIER_LABELS = [
|
||||
'gt70' => '>70%',
|
||||
'60_70' => '60%~70%',
|
||||
'50_60' => '50%~60%',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 设置搜索条件
|
||||
*/
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['run_date', 'action', 'assistant_id', 'batch_no', 'stat_month'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取列表
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->buildQuery()
|
||||
->order(['id' => 'desc'])
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$ct = (int) ($row['create_time'] ?? 0);
|
||||
$row['create_time_text'] = $ct > 0 ? date('Y-m-d H:i:s', $ct) : '';
|
||||
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
|
||||
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
|
||||
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取数量
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return $this->buildQuery()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \think\db\Query
|
||||
*/
|
||||
private function buildQuery()
|
||||
{
|
||||
$query = Db::name('tcm_diagnosis_auto_assign_log')->where($this->searchWhere);
|
||||
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->whereLike('patient_name', '%' . $keyword . '%')
|
||||
->whereOr('patient_phone', 'like', '%' . $keyword . '%')
|
||||
->whereOr('assistant_name', 'like', '%' . $keyword . '%');
|
||||
if (preg_match('/^\d+$/', $keyword) === 1 && (int) $keyword > 0) {
|
||||
$q->whereOr('diagnosis_id', '=', (int) $keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$startDate = trim((string) ($this->params['start_date'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) === 1) {
|
||||
$query->where('run_date', '>=', $startDate);
|
||||
}
|
||||
$endDate = trim((string) ($this->params['end_date'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) === 1) {
|
||||
$query->where('run_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
|
||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||
|
||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||
|
||||
@@ -167,30 +168,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
}
|
||||
|
||||
// 按挂号状态优先级排序:已过号(4) > 已预约(1) > 已完成(3),然后按挂号日期+时间升序
|
||||
// 若传了 appointment_date(当天/明天等筛选),只按「该日」的挂号排序与展示
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$aptTbl = (new Appointment())->getTable();
|
||||
$minAptDateCond = '';
|
||||
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
||||
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
||||
}
|
||||
|
||||
// 获取最早的挂号状态(用于排序优先级)
|
||||
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
|
||||
|
||||
// 获取最早的挂号时间(用于同状态内排序)
|
||||
$minAptExpr = '(SELECT MIN(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 IN (1,3,4)' . $minAptDateCond . ')';
|
||||
|
||||
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序…
|
||||
$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 = $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
||||
} else {
|
||||
$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";
|
||||
}
|
||||
$orderRaw = $this->resolveListOrderRaw($pendingWideSearch);
|
||||
|
||||
$lists = $query
|
||||
->with(['DiagnosisViewRecord'])
|
||||
@@ -571,6 +549,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
|
||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||
|
||||
// 仅已开方(待分配+关键词检索时不限制)
|
||||
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||
@@ -644,6 +623,99 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$query->whereExists("SELECT 1 FROM {$aptTbl} latest_apt WHERE " . implode(' AND ', $conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近一次成功指派过滤:按 create_time DESC, id DESC 取 to_assistant_id>0 的一条。
|
||||
*
|
||||
* @param mixed $query
|
||||
*/
|
||||
private function applyLatestAssignFilters($query, bool $pendingWideSearch): void
|
||||
{
|
||||
if ($pendingWideSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
$startDate = $this->normalizeYmd($this->params['latest_assign_start_date'] ?? '');
|
||||
$endDate = $this->normalizeYmd($this->params['latest_assign_end_date'] ?? '');
|
||||
if ($startDate === '' && $endDate === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$logTbl = Db::name('tcm_diagnosis_assign_log')->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$latestIdSql = $this->latestAssignLogIdSubSql($logTbl, $diagTbl);
|
||||
$conditions = ["latest_lg.id = ({$latestIdSql})"];
|
||||
|
||||
if ($startDate !== '') {
|
||||
$startTs = (int) strtotime($startDate . ' 00:00:00');
|
||||
$conditions[] = "latest_lg.create_time >= {$startTs}";
|
||||
}
|
||||
if ($endDate !== '') {
|
||||
$endTs = (int) strtotime($endDate . ' 23:59:59');
|
||||
$conditions[] = "latest_lg.create_time <= {$endTs}";
|
||||
}
|
||||
|
||||
$query->whereExists("SELECT 1 FROM {$logTbl} latest_lg WHERE " . implode(' AND ', $conditions));
|
||||
}
|
||||
|
||||
private function latestAssignLogIdSubSql(string $logTbl, string $diagTbl): string
|
||||
{
|
||||
return "SELECT lg_latest.id FROM {$logTbl} lg_latest "
|
||||
. "WHERE lg_latest.diagnosis_id = {$diagTbl}.id "
|
||||
. 'AND lg_latest.to_assistant_id > 0 '
|
||||
. 'ORDER BY lg_latest.create_time DESC, lg_latest.id DESC LIMIT 1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
|
||||
*/
|
||||
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
||||
{
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
||||
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
||||
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
||||
$nullLast = "CASE WHEN IFNULL({$anchorExpr}, 0) = 0 THEN 1 ELSE 0 END ASC";
|
||||
if ($sortUnserved === 'desc') {
|
||||
return "{$nullLast}, IFNULL({$anchorExpr}, 0) ASC, {$diagTbl}.id DESC";
|
||||
}
|
||||
|
||||
return "{$nullLast}, IFNULL({$anchorExpr}, 0) DESC, {$diagTbl}.id DESC";
|
||||
}
|
||||
|
||||
$aptTbl = (new Appointment())->getTable();
|
||||
$minAptDateCond = '';
|
||||
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
||||
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
||||
}
|
||||
|
||||
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
|
||||
$minAptExpr = '(SELECT MIN(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 IN (1,3,4)' . $minAptDateCond . ')';
|
||||
|
||||
$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 . ')';
|
||||
|
||||
return $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
||||
}
|
||||
|
||||
return $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";
|
||||
}
|
||||
|
||||
/** 未服务天数锚点:血糖/饮食/运动记录最近 record_date 的最大值 */
|
||||
private function unservedAnchorExpr(string $diagTbl): string
|
||||
{
|
||||
$bloodTbl = (new BloodRecord())->getTable();
|
||||
$dietTbl = (new DietRecord())->getTable();
|
||||
$exerciseTbl = (new ExerciseRecord())->getTable();
|
||||
|
||||
return 'GREATEST('
|
||||
. "COALESCE((SELECT MAX(br.record_date) FROM {$bloodTbl} br WHERE br.diagnosis_id = {$diagTbl}.id AND br.delete_time IS NULL), 0), "
|
||||
. "COALESCE((SELECT MAX(dr.record_date) FROM {$dietTbl} dr WHERE dr.diagnosis_id = {$diagTbl}.id AND dr.delete_time IS NULL), 0), "
|
||||
. "COALESCE((SELECT MAX(er.record_date) FROM {$exerciseTbl} er WHERE er.diagnosis_id = {$diagTbl}.id AND er.delete_time IS NULL), 0)"
|
||||
. ')';
|
||||
}
|
||||
|
||||
private function latestAppointmentIdSubSql(string $aptTbl, string $diagTbl): string
|
||||
{
|
||||
$statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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\cache\ExportCache;
|
||||
use app\common\lists\ListsExcelInterface;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
@@ -26,6 +27,12 @@ use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\db\Query;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
|
||||
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
|
||||
{
|
||||
@@ -108,6 +115,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$this->applyExpressKeywordFilter($query);
|
||||
$this->applyServiceChannelFilter($query);
|
||||
$this->applySupplyModeFilter($query);
|
||||
$this->applyHasAuxFormulaFilter($query);
|
||||
$this->applyAuditAdminFilter($query);
|
||||
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
|
||||
@@ -347,6 +355,39 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否含辅方:关联处方 herbs 中是否存在 formula_type=辅方(与 PrescriptionOrderLogic::detail 口径一致)
|
||||
*
|
||||
* 入参 has_aux_formula:1 含辅方 | 0 不含辅方,空表示不限
|
||||
*/
|
||||
private function applyHasAuxFormulaFilter($query): void
|
||||
{
|
||||
$raw = $this->params['has_aux_formula'] ?? '';
|
||||
if ($raw === '' || $raw === null) {
|
||||
return;
|
||||
}
|
||||
$wantAux = (int) $raw === 1;
|
||||
$poTbl = (new PrescriptionOrder())->getTable();
|
||||
$rxTbl = (new Prescription())->getTable();
|
||||
// herbs 为 JSON 字段,ThinkPHP 写入时中文按 Unicode 转义存储(辅方 => \u8f85\u65b9)。
|
||||
// 用 ESCAPE '~' 让反斜杠按字面匹配;同时兼容极少数原文「辅方」写法。
|
||||
$auxEsc = '%"formula_type":"\\\\u8f85\\\\u65b9"%';
|
||||
$auxRaw = '%"formula_type":"辅方"%';
|
||||
$herbsHasAux = "(IFNULL(rx.`herbs`,'') LIKE '{$auxEsc}' ESCAPE '~'"
|
||||
. " OR IFNULL(rx.`herbs`,'') LIKE '{$auxRaw}')";
|
||||
$existsSql = "SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||
. " AND rx.`delete_time` IS NULL AND {$herbsHasAux}";
|
||||
if ($wantAux) {
|
||||
$query->whereExists($existsSql);
|
||||
|
||||
return;
|
||||
}
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||
. " AND rx.`delete_time` IS NULL AND NOT ({$herbsHasAux})"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
|
||||
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
||||
@@ -700,6 +741,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
unset($item);
|
||||
|
||||
if ($this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||
$this->appendDiagnosisEditVisitSeqFields($lists);
|
||||
}
|
||||
|
||||
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
|
||||
|
||||
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
||||
@@ -779,10 +824,16 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
||||
'export_medication_form' => '药品形态',
|
||||
'export_prescription_name' => '药方名称',
|
||||
'export_prescription_herbs' => '处方',
|
||||
'export_main_usage' => '主方服用方式',
|
||||
'export_main_usage_days' => '主方天数',
|
||||
'export_aux_usage' => '辅方服用方式',
|
||||
'export_aux_usage_days' => '辅方天数',
|
||||
'export_service_package' => '服务套餐',
|
||||
'export_medication_days' => '天数',
|
||||
'export_amount' => '总金额',
|
||||
'export_paid_amount' => '已付金额',
|
||||
'export_linked_pay_records' => '关联收款记录',
|
||||
'export_refund_amount' => '退款金额',
|
||||
'export_agency_collect' => '代收金额',
|
||||
'export_tracking_number' => '快递单号',
|
||||
@@ -795,6 +846,141 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方业务订单导出:深色表头、斑马纹、长文本自动换行(覆盖默认亮蓝表头)
|
||||
*
|
||||
* @param array<string, string> $excelFields
|
||||
* @param array<int, array<string, mixed>> $lists
|
||||
*/
|
||||
public function createExcel($excelFields, $lists): string
|
||||
{
|
||||
$title = array_values($excelFields);
|
||||
$fieldKeys = array_keys($excelFields);
|
||||
|
||||
$data = [];
|
||||
foreach ($lists as $row) {
|
||||
$temp = [];
|
||||
foreach ($excelFields as $key => $excelField) {
|
||||
$fieldData = $row[$key] ?? '';
|
||||
if (is_numeric($fieldData) && strlen((string) $fieldData) >= 12) {
|
||||
$fieldData .= "\t";
|
||||
}
|
||||
$temp[$key] = $fieldData;
|
||||
}
|
||||
$data[] = $temp;
|
||||
}
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('处方业务订单');
|
||||
|
||||
foreach ($title as $key => $value) {
|
||||
$sheet->setCellValueByColumnAndRow($key + 1, 1, $value);
|
||||
}
|
||||
|
||||
$rowNum = 2;
|
||||
foreach ($data as $item) {
|
||||
$column = 1;
|
||||
foreach ($item as $value) {
|
||||
$sheet->setCellValueByColumnAndRow($column, $rowNum, $value);
|
||||
++$column;
|
||||
}
|
||||
++$rowNum;
|
||||
}
|
||||
|
||||
$highest = $sheet->getHighestRowAndColumn();
|
||||
$highestRow = (int) $highest['row'];
|
||||
$lastColumn = (string) $highest['column'];
|
||||
$titleScope = 'A1:' . $lastColumn . '1';
|
||||
$allScope = 'A1:' . $lastColumn . $highestRow;
|
||||
$bodyScope = 'A2:' . $lastColumn . $highestRow;
|
||||
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->getRowDimension(1)->setRowHeight(26);
|
||||
|
||||
$sheet->getStyle($titleScope)->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FF334155');
|
||||
$sheet->getStyle($titleScope)->getFont()->getColor()->setARGB('FFFFFFFF');
|
||||
$sheet->getStyle($titleScope)->getFont()->setBold(true);
|
||||
$sheet->getStyle($titleScope)->getAlignment()
|
||||
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||
->setVertical(Alignment::VERTICAL_CENTER);
|
||||
|
||||
$sheet->getStyle($allScope)->getBorders()->getAllBorders()
|
||||
->setBorderStyle(Border::BORDER_THIN)
|
||||
->getColor()->setARGB('FFE2E8F0');
|
||||
|
||||
$sheet->getStyle($bodyScope)->getAlignment()
|
||||
->setVertical(Alignment::VERTICAL_TOP)
|
||||
->setWrapText(true);
|
||||
|
||||
$wrapWideKeys = [
|
||||
'export_linked_pay_records',
|
||||
'export_prescription_name',
|
||||
'export_prescription_herbs',
|
||||
'export_main_usage',
|
||||
'export_aux_usage',
|
||||
'export_guahao_channel_source',
|
||||
'export_assistant_dept',
|
||||
'export_service_package',
|
||||
];
|
||||
$fixedWidths = [
|
||||
'export_fulfillment_status_text' => 12,
|
||||
'export_order_time' => 18,
|
||||
'export_patient_gender' => 6,
|
||||
'export_patient_age' => 6,
|
||||
'export_medication_days' => 6,
|
||||
'export_main_usage_days' => 8,
|
||||
'export_aux_usage_days' => 8,
|
||||
'export_amount' => 10,
|
||||
'export_paid_amount' => 10,
|
||||
'export_refund_amount' => 10,
|
||||
'export_agency_collect' => 10,
|
||||
'export_sign_time' => 12,
|
||||
'export_supply_mode' => 10,
|
||||
'export_linked_pay_records' => 52,
|
||||
'export_prescription_name' => 34,
|
||||
'export_prescription_herbs' => 36,
|
||||
'export_main_usage' => 28,
|
||||
'export_aux_usage' => 28,
|
||||
'export_guahao_channel_source' => 22,
|
||||
'export_assistant_dept' => 24,
|
||||
'export_service_package' => 18,
|
||||
'export_tracking_number' => 18,
|
||||
];
|
||||
|
||||
foreach ($fieldKeys as $idx => $fieldKey) {
|
||||
$colLetter = Coordinate::stringFromColumnIndex($idx + 1);
|
||||
if (isset($fixedWidths[$fieldKey])) {
|
||||
$sheet->getColumnDimension($colLetter)->setWidth($fixedWidths[$fieldKey]);
|
||||
} elseif (!in_array($fieldKey, $wrapWideKeys, true)) {
|
||||
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
|
||||
} else {
|
||||
$sheet->getColumnDimension($colLetter)->setWidth(28);
|
||||
}
|
||||
}
|
||||
|
||||
for ($r = 2; $r <= $highestRow; ++$r) {
|
||||
if ($r % 2 === 0) {
|
||||
$sheet->getStyle('A' . $r . ':' . $lastColumn . $r)->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFF8FAFC');
|
||||
}
|
||||
}
|
||||
|
||||
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
|
||||
$exportCache = new ExportCache();
|
||||
$src = $exportCache->getSrc();
|
||||
if (!file_exists($src)) {
|
||||
mkdir($src, 0775, true);
|
||||
}
|
||||
$writer->save($src . $this->fileName);
|
||||
$vars = ['file' => $exportCache->setFile($this->fileName)];
|
||||
|
||||
return (string) url('adminapi/download/export', $vars, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
|
||||
* (doctor_appointment.status=3,appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
|
||||
@@ -1235,6 +1421,69 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $lists
|
||||
*/
|
||||
private function appendDiagnosisEditVisitSeqFields(array &$lists): void
|
||||
{
|
||||
if ($lists === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diagIds = [];
|
||||
foreach ($lists as $row) {
|
||||
$d = (int) ($row['diagnosis_id'] ?? 0);
|
||||
if ($d > 0) {
|
||||
$diagIds[$d] = true;
|
||||
}
|
||||
}
|
||||
$contextDid = (int) ($this->params['context_diagnosis_id'] ?? 0);
|
||||
if ($contextDid > 0) {
|
||||
$diagIds[$contextDid] = true;
|
||||
}
|
||||
$diagIdList = array_keys($diagIds);
|
||||
if ($diagIdList === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
|
||||
->whereNull('delete_time')
|
||||
->column('revisit_slot_start_offset', 'id');
|
||||
$offsetMap = [];
|
||||
foreach ($offsetRows as $id => $offset) {
|
||||
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
|
||||
}
|
||||
|
||||
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
|
||||
$seqByOrderId = [];
|
||||
foreach ($diagIdList as $did) {
|
||||
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
|
||||
$orderIds = $q
|
||||
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||
->column('id');
|
||||
$seq = 0;
|
||||
foreach ($orderIds as $oid) {
|
||||
$seq++;
|
||||
$seqByOrderId[(int) $oid] = $seq;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($lists as &$row) {
|
||||
$oid = (int) ($row['id'] ?? 0);
|
||||
$did = (int) ($row['diagnosis_id'] ?? 0);
|
||||
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
||||
$offset = (int) ($offsetMap[$did] ?? 0);
|
||||
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
||||
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
||||
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
|
||||
$row['counts_for_revisit_rate'] = $effectiveSlot >= 2 ? 1 : 0;
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
|
||||
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
|
||||
|
||||
@@ -151,6 +151,24 @@ class DeptLogic extends BaseLogic
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function findErCenterRootDeptIds(): array
|
||||
{
|
||||
return self::findCenterRootDeptIdsByNameKeyword('二中心');
|
||||
}
|
||||
|
||||
/**
|
||||
* 名称含「一中心」的部门 id。
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function findYiCenterRootDeptIds(): array
|
||||
{
|
||||
return self::findCenterRootDeptIdsByNameKeyword('一中心');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function findCenterRootDeptIdsByNameKeyword(string $keyword): array
|
||||
{
|
||||
$rows = Dept::whereNull('delete_time')
|
||||
->field(['id', 'name'])
|
||||
@@ -159,7 +177,7 @@ class DeptLogic extends BaseLogic
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$name = (string) ($r['name'] ?? '');
|
||||
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
|
||||
if ($name !== '' && mb_strpos($name, $keyword) !== false) {
|
||||
$out[] = (int) $r['id'];
|
||||
}
|
||||
}
|
||||
@@ -201,6 +219,19 @@ class DeptLogic extends BaseLogic
|
||||
return array_fill_keys($subtreeIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 名称含「一中心」的部门及其全部下级 id(map)。
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
public static function getYiCenterSubtreeDeptIdSet(): array
|
||||
{
|
||||
$yiRoots = self::findYiCenterRootDeptIds();
|
||||
$subtreeIds = self::unionErCenterSubtreeDeptIds($yiRoots);
|
||||
|
||||
return array_fill_keys($subtreeIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
|
||||
*
|
||||
|
||||
@@ -23,9 +23,9 @@ use think\facade\Log;
|
||||
class OrderLogic
|
||||
{
|
||||
/**
|
||||
* 创建方式统一三值:normal 普通订单 / wechat_work 企业微信对外收款 / fubei 付呗
|
||||
* 创建方式:normal 普通订单 / wechat_work 企业微信对外收款 / fubei 付呗 / express_cod 快递代收
|
||||
*/
|
||||
private const CREATE_TYPES = ['normal', 'wechat_work', 'fubei'];
|
||||
private const CREATE_TYPES = ['normal', 'wechat_work', 'fubei', 'express_cod'];
|
||||
|
||||
/**
|
||||
* @notes 生成订单号
|
||||
@@ -64,6 +64,15 @@ class OrderLogic
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为「待审核(5)且需人工确认到账」的单:付呗(payment_method=fubei) 或 快递代收(create_type=express_cod)
|
||||
*/
|
||||
private static function isPendingManualAudit(Order $order): bool
|
||||
{
|
||||
return (string)($order->payment_method ?? '') === 'fubei'
|
||||
|| (string)($order->create_type ?? '') === 'express_cod';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 创建订单
|
||||
* @param array $params
|
||||
@@ -73,11 +82,16 @@ class OrderLogic
|
||||
{
|
||||
try {
|
||||
$channel = (string)($params['payment_channel'] ?? 'normal');
|
||||
if (!in_array($channel, ['normal', 'fubei'], true)) {
|
||||
if (!in_array($channel, ['normal', 'fubei', 'express_cod'], true)) {
|
||||
$channel = 'normal';
|
||||
}
|
||||
$paymentMethod = $channel === 'fubei' ? 'fubei' : null;
|
||||
$createType = self::normalizeCreateType((string)($params['create_type'] ?? ''), $paymentMethod);
|
||||
// 快递代收:以 express_cod 标记创建方式(与 fubei 流转一致,但 payment_method 待到账时再写)
|
||||
$createTypeHint = $channel === 'express_cod' ? 'express_cod' : '';
|
||||
$createType = self::normalizeCreateType(
|
||||
(string)($params['create_type'] ?? $createTypeHint),
|
||||
$paymentMethod
|
||||
);
|
||||
$requirePaymentSlipAudit = (int)($params['require_payment_slip_audit'] ?? 0) === 1;
|
||||
|
||||
$order = new Order();
|
||||
@@ -87,10 +101,13 @@ class OrderLogic
|
||||
$order->order_type = $params['order_type'];
|
||||
$order->amount = $params['amount'];
|
||||
$order->create_type = $createType;
|
||||
// 付呗且申请审核支付单:待审核(5);否则待支付(1)
|
||||
// 付呗/快递代收且申请审核支付单:待审核(5);否则待支付(1)
|
||||
if ($channel === 'fubei') {
|
||||
$order->payment_method = 'fubei';
|
||||
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
||||
} elseif ($channel === 'express_cod') {
|
||||
// payment_method 留空,待人工确认到账时再写真实支付方式
|
||||
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
||||
} else {
|
||||
$order->status = 1; // 待支付
|
||||
}
|
||||
@@ -387,11 +404,11 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$st = (int)$order->status;
|
||||
// 1=待支付;5=待审核(付呗+申请支付单审核) 时允许录入手动到账
|
||||
// 1=待支付;5=待审核(付呗/快递代收+申请支付单审核) 时允许录入手动到账
|
||||
if ($st === 1) {
|
||||
// 正常待支付
|
||||
} elseif ($st === 5 && (string)($order->payment_method ?? '') === 'fubei') {
|
||||
// 待审核的付呗单,通过人工确认后标记已支付
|
||||
} elseif ($st === 5 && self::isPendingManualAudit($order)) {
|
||||
// 待审核的付呗/快递代收单,通过人工确认后标记已支付
|
||||
} else {
|
||||
self::setError('订单状态不允许支付');
|
||||
return false;
|
||||
@@ -424,8 +441,8 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$st = (int)$order->status;
|
||||
if ($st !== 1 && !($st === 5 && (string)($order->payment_method ?? '') === 'fubei')) {
|
||||
self::setError('只有待支付或待审核(付呗)的订单才能取消');
|
||||
if ($st !== 1 && !($st === 5 && self::isPendingManualAudit($order))) {
|
||||
self::setError('只有待支付或待审核(付呗/快递代收)的订单才能取消');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -509,13 +526,13 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$st = (int) $order->status;
|
||||
if ($st === 5 && (string) ($order->payment_method ?? '') !== 'fubei') {
|
||||
self::setError('待审核订单仅支持付呗渠道拆分');
|
||||
if ($st === 5 && !self::isPendingManualAudit($order)) {
|
||||
self::setError('待审核订单仅支持付呗/快递代收渠道拆分');
|
||||
|
||||
return false;
|
||||
}
|
||||
if (! in_array($st, [1, 5, 2], true)) {
|
||||
self::setError('仅「待支付」「待审核(付呗)」或「已支付」的订单可拆分');
|
||||
self::setError('仅「待支付」「待审核(付呗/快递代收)」或「已支付」的订单可拆分');
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -586,7 +603,10 @@ class OrderLogic
|
||||
(string) ($order->remark ?? '')
|
||||
);
|
||||
if ($st === 5) {
|
||||
$n->payment_method = 'fubei';
|
||||
// 付呗子单沿用 payment_method=fubei;快递代收子单 payment_method 留空,靠 create_type 标记
|
||||
if ((string) ($order->payment_method ?? '') === 'fubei') {
|
||||
$n->payment_method = 'fubei';
|
||||
}
|
||||
$n->status = 5;
|
||||
} elseif ($st === 2) {
|
||||
$n->status = 2;
|
||||
|
||||
@@ -48,8 +48,8 @@ class ConversionLogic
|
||||
$emptyResult = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
@@ -57,8 +57,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -75,8 +75,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
@@ -84,8 +84,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -124,8 +124,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
@@ -133,8 +133,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -191,8 +191,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($allRows),
|
||||
'charts' => self::buildCharts($chartRows),
|
||||
'summary' => self::buildSummary($allRows, $dimension),
|
||||
'charts' => self::buildCharts($chartRows, $dimension),
|
||||
'lists' => $pagedRows,
|
||||
'count' => count($allRows),
|
||||
'page_no' => $pageNo,
|
||||
@@ -200,8 +200,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($allRows),
|
||||
'charts' => self::buildCharts($chartRows),
|
||||
'summary' => self::buildSummary($allRows, $dimension),
|
||||
'charts' => self::buildCharts($chartRows, $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -226,8 +226,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($rows),
|
||||
'charts' => self::buildCharts($rows),
|
||||
'summary' => self::buildSummary($rows, $dimension),
|
||||
'charts' => self::buildCharts($rows, $dimension),
|
||||
'lists' => array_slice($rows, $offset, $pageSize),
|
||||
'count' => $count,
|
||||
'page_no' => $pageNo,
|
||||
@@ -235,8 +235,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($rows),
|
||||
'charts' => self::buildCharts($rows),
|
||||
'summary' => self::buildSummary($rows, $dimension),
|
||||
'charts' => self::buildCharts($rows, $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -1226,7 +1226,7 @@ class ConversionLogic
|
||||
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
||||
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
||||
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||
@@ -1459,7 +1459,7 @@ class ConversionLogic
|
||||
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
||||
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
||||
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||
@@ -2010,7 +2010,12 @@ class ConversionLogic
|
||||
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
||||
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
||||
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
|
||||
'receive_rate' => self::percent($completedOrderCount, $addFansCount),
|
||||
'receive_rate' => self::receiveRate(
|
||||
$completedOrderCount,
|
||||
$addFansCount,
|
||||
$interviewCount,
|
||||
'member'
|
||||
),
|
||||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||
@@ -2149,7 +2154,7 @@ class ConversionLogic
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildSummary(array $rows): array
|
||||
private static function buildSummary(array $rows, string $dimension = 'dept'): array
|
||||
{
|
||||
$summary = [
|
||||
'add_fans_count' => 0,
|
||||
@@ -2181,7 +2186,12 @@ class ConversionLogic
|
||||
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
|
||||
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
|
||||
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
|
||||
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']);
|
||||
$summary['receive_rate'] = self::receiveRate(
|
||||
$summary['completed_order_count'],
|
||||
$summary['add_fans_count'],
|
||||
$summary['interview_count'],
|
||||
$dimension
|
||||
);
|
||||
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
||||
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
||||
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
||||
@@ -2310,7 +2320,7 @@ class ConversionLogic
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildCharts(array $rows): array
|
||||
private static function buildCharts(array $rows, string $dimension = 'dept'): array
|
||||
{
|
||||
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
|
||||
$topRows = array_slice($chartableRows, 0, 10);
|
||||
@@ -2319,29 +2329,56 @@ class ConversionLogic
|
||||
'names' => [],
|
||||
'amounts' => [],
|
||||
'order_counts' => [],
|
||||
'fan_counts' => [],
|
||||
'rois' => [],
|
||||
];
|
||||
if ($dimension === 'doctor') {
|
||||
$ranking['appointment_counts'] = [];
|
||||
$ranking['interview_counts'] = [];
|
||||
} else {
|
||||
$ranking['fan_counts'] = [];
|
||||
$ranking['rois'] = [];
|
||||
}
|
||||
|
||||
foreach ($topRows as $row) {
|
||||
$ranking['names'][] = $row['name'];
|
||||
$ranking['amounts'][] = $row['completed_order_amount'];
|
||||
$ranking['order_counts'][] = $row['completed_order_count'];
|
||||
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||||
$ranking['rois'][] = $row['roi'];
|
||||
if ($dimension === 'doctor') {
|
||||
$ranking['appointment_counts'][] = $row['appointment_total_count'];
|
||||
$ranking['interview_counts'][] = $row['interview_count'];
|
||||
} else {
|
||||
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||||
$ranking['rois'][] = $row['roi'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
$charts = [
|
||||
'ranking' => $ranking,
|
||||
'amount_share' => array_map(
|
||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
|
||||
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
|
||||
),
|
||||
'fan_share' => array_map(
|
||||
];
|
||||
|
||||
if ($dimension === 'doctor') {
|
||||
$charts['order_share'] = array_map(
|
||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']],
|
||||
array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0)
|
||||
);
|
||||
} else {
|
||||
$charts['fan_share'] = array_map(
|
||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
|
||||
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
return $charts;
|
||||
}
|
||||
|
||||
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
|
||||
{
|
||||
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
|
||||
|
||||
return self::percent($completedOrderCount, $denominator);
|
||||
}
|
||||
|
||||
private static function percent(int $numerator, int $denominator): float
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 复诊接诊率统计(按月)
|
||||
*
|
||||
* 口径说明:
|
||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
||||
* 同一诊单当月被多次指派给同一医助只计 1 次;
|
||||
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
|
||||
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
||||
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
|
||||
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
||||
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
||||
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
||||
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
|
||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||
*/
|
||||
class RevisitRateLogic
|
||||
{
|
||||
/** 至少展示到的复诊序号(二诊/三诊/四诊) */
|
||||
private const MIN_VISIT_SLOT_CEILING = 4;
|
||||
|
||||
/** 复诊序号统计上限(防御脏数据导致列爆炸;诊次跨月累计,上限放宽) */
|
||||
private const MAX_VISIT_SLOT = 50;
|
||||
|
||||
/** 未分配部门的占位分组 */
|
||||
private const UNASSIGNED_DEPT_NAME = '未分配部门';
|
||||
|
||||
/**
|
||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||
*
|
||||
* @return array{
|
||||
* month:string,start_date:string,end_date:string,
|
||||
* slots:list<int>,
|
||||
* total:array<string,int|float|null>,
|
||||
* rows:list<array<string,mixed>>
|
||||
* }
|
||||
*/
|
||||
public static function overview(array $params): array
|
||||
{
|
||||
$ctx = self::buildStatsCore($params);
|
||||
$month = $ctx['month'];
|
||||
|
||||
$minSlots = range(2, self::MIN_VISIT_SLOT_CEILING);
|
||||
$universe = self::assistantUniverse($ctx);
|
||||
if ($universe === []) {
|
||||
return [
|
||||
'month' => $month,
|
||||
'start_date' => $ctx['startDate'],
|
||||
'end_date' => $ctx['endDate'],
|
||||
'slots' => $minSlots,
|
||||
'total' => self::buildMetricPack(0, [], $minSlots),
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// 分档:2 ~ max(4, 当月命中的最大诊次)
|
||||
$maxHitSlot = 0;
|
||||
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
|
||||
foreach ($slotMap as $slot => $_) {
|
||||
if ((int) $slot > $maxHitSlot) {
|
||||
$maxHitSlot = (int) $slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
|
||||
|
||||
$nameMap = Db::name('admin')
|
||||
->whereIn('id', array_keys($universe))
|
||||
->column('name', 'id');
|
||||
|
||||
// 医助行(按部门分组收集)
|
||||
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
||||
$assistantRowsByDept = [];
|
||||
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
|
||||
$deptDiagSet = [];
|
||||
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
||||
$deptSlotCounts = [];
|
||||
/** @var array<int, true> $totalDiagSet */
|
||||
$totalDiagSet = [];
|
||||
/** @var array<int, int> $totalSlotCounts */
|
||||
$totalSlotCounts = [];
|
||||
|
||||
foreach ($universe as $aid => $_) {
|
||||
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
||||
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
||||
foreach ($diagSet as $did => $_d) {
|
||||
$deptDiagSet[$deptId][$did] = true;
|
||||
$totalDiagSet[$did] = true;
|
||||
}
|
||||
$slotCounts = [];
|
||||
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
||||
$cnt = \count($orders);
|
||||
$slotCounts[$slot] = $cnt;
|
||||
$deptSlotCounts[$deptId][$slot] = ($deptSlotCounts[$deptId][$slot] ?? 0) + $cnt;
|
||||
$totalSlotCounts[$slot] = ($totalSlotCounts[$slot] ?? 0) + $cnt;
|
||||
}
|
||||
$deptName = $deptId > 0
|
||||
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||
: self::UNASSIGNED_DEPT_NAME;
|
||||
$assistantRowsByDept[$deptId][] = [
|
||||
'row_key' => 'a' . $aid,
|
||||
'is_dept' => 0,
|
||||
'assistant_id' => (int) $aid,
|
||||
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
||||
'dept_id' => $deptId,
|
||||
'dept_name' => $deptName,
|
||||
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
|
||||
}
|
||||
|
||||
// 部门行 + 子行
|
||||
$rows = [];
|
||||
foreach ($assistantRowsByDept as $deptId => $children) {
|
||||
usort($children, static function (array $a, array $b): int {
|
||||
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||
return $b['assigned_count'] <=> $a['assigned_count'];
|
||||
}
|
||||
|
||||
return strcmp((string) $a['assistant_name'], (string) $b['assistant_name']);
|
||||
});
|
||||
$deptName = $deptId > 0
|
||||
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||
: self::UNASSIGNED_DEPT_NAME;
|
||||
$rows[] = [
|
||||
'row_key' => 'd' . $deptId,
|
||||
'is_dept' => 1,
|
||||
'dept_id' => (int) $deptId,
|
||||
'dept_name' => $deptName,
|
||||
'assistant_count' => \count($children),
|
||||
'children' => $children,
|
||||
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
|
||||
}
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||
return $b['assigned_count'] <=> $a['assigned_count'];
|
||||
}
|
||||
|
||||
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
|
||||
});
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'start_date' => $ctx['startDate'],
|
||||
'end_date' => $ctx['endDate'],
|
||||
'slots' => $slots,
|
||||
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
|
||||
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
|
||||
*
|
||||
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
||||
*
|
||||
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
|
||||
*/
|
||||
public static function assignLines(array $params): array
|
||||
{
|
||||
$ctx = self::buildStatsCore($params);
|
||||
$assistantSet = self::applyRowScope($ctx, $params);
|
||||
|
||||
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
|
||||
$byDiag = [];
|
||||
foreach ($ctx['pairsRaw'] as $p) {
|
||||
$aid = (int) $p['to_assistant_id'];
|
||||
$did = (int) $p['diagnosis_id'];
|
||||
if (!isset($assistantSet[$aid])) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($byDiag[$did])) {
|
||||
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
||||
}
|
||||
$byDiag[$did]['assistants'][$aid] = true;
|
||||
$byDiag[$did]['assign_count']++;
|
||||
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
|
||||
}
|
||||
|
||||
if ($byDiag === []) {
|
||||
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
|
||||
}
|
||||
|
||||
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
|
||||
$assistantIds = [];
|
||||
foreach ($byDiag as $d) {
|
||||
foreach ($d['assistants'] as $aid => $_) {
|
||||
$assistantIds[$aid] = true;
|
||||
}
|
||||
}
|
||||
$nameMap = Db::name('admin')
|
||||
->whereIn('id', array_keys($assistantIds))
|
||||
->column('name', 'id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($byDiag as $did => $d) {
|
||||
$names = [];
|
||||
foreach ($d['assistants'] as $aid => $_) {
|
||||
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||
}
|
||||
$rows[] = [
|
||||
'diagnosis_id' => (int) $did,
|
||||
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||
'assistant_names' => implode('、', $names),
|
||||
'assign_count' => (int) $d['assign_count'],
|
||||
'last_assign_time' => (int) $d['last_time'],
|
||||
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
|
||||
|
||||
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
|
||||
*
|
||||
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
||||
*
|
||||
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
|
||||
*/
|
||||
public static function visitOrderLines(array $params): array
|
||||
{
|
||||
$slot = (int) ($params['slot'] ?? 0);
|
||||
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
|
||||
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||
}
|
||||
|
||||
$ctx = self::buildStatsCore($params);
|
||||
$assistantSet = self::applyRowScope($ctx, $params);
|
||||
|
||||
$orderRows = [];
|
||||
foreach ($assistantSet as $aid => $_) {
|
||||
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
|
||||
$r['holder_assistant_id'] = (int) $aid;
|
||||
$orderRows[] = $r;
|
||||
}
|
||||
}
|
||||
if ($orderRows === []) {
|
||||
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||
}
|
||||
|
||||
$diagIds = array_values(array_unique(array_map(
|
||||
static fn (array $r): int => (int) $r['diagnosis_id'],
|
||||
$orderRows
|
||||
)));
|
||||
$diagInfo = self::fetchDiagnosisInfo($diagIds);
|
||||
$adminIds = [];
|
||||
foreach ($orderRows as $r) {
|
||||
if ((int) $r['creator_id'] > 0) {
|
||||
$adminIds[(int) $r['creator_id']] = true;
|
||||
}
|
||||
$adminIds[(int) $r['holder_assistant_id']] = true;
|
||||
}
|
||||
$adminNames = $adminIds !== []
|
||||
? Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id')
|
||||
: [];
|
||||
|
||||
$rows = [];
|
||||
foreach ($orderRows as $r) {
|
||||
$did = (int) $r['diagnosis_id'];
|
||||
$cid = (int) $r['creator_id'];
|
||||
$hid = (int) $r['holder_assistant_id'];
|
||||
$ct = (int) $r['create_time'];
|
||||
$rows[] = [
|
||||
'order_id' => (int) $r['id'],
|
||||
'order_no' => (string) ($r['order_no'] ?? ''),
|
||||
'diagnosis_id' => $did,
|
||||
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||
'amount' => round((float) ($r['amount'] ?? 0), 2),
|
||||
'create_time' => $ct,
|
||||
'create_time_text' => $ct > 0 ? date('Y-m-d H:i:s', $ct) : '',
|
||||
'creator_id' => $cid,
|
||||
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
||||
'assistant_id' => $hid,
|
||||
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
|
||||
|
||||
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => \count($rows), 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
|
||||
*
|
||||
* @return array{rows: list<array{id:int,pid:int,name:string}>}
|
||||
*/
|
||||
public static function deptOptions(): array
|
||||
{
|
||||
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
if ($erSet === []) {
|
||||
return ['rows' => []];
|
||||
}
|
||||
$rows = Db::name('dept')
|
||||
->whereNull('delete_time')
|
||||
->whereIn('id', array_keys($erSet))
|
||||
->field(['id', 'pid', 'name'])
|
||||
->order('sort', 'desc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'rows' => array_map(static fn (array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'pid' => (int) $r['pid'],
|
||||
'name' => (string) $r['name'],
|
||||
], $rows),
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────── 内部实现 ───────────────────────────
|
||||
|
||||
/**
|
||||
* 核心统计上下文:
|
||||
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
||||
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
|
||||
*
|
||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||
*
|
||||
* @return array{
|
||||
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
|
||||
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
|
||||
* diagsByAssistant:array<int,array<int,true>>,
|
||||
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||
* assistantDept:array<int,int>,
|
||||
* deptNames:array<int,string>
|
||||
* }
|
||||
*/
|
||||
private static function buildStatsCore(array $params): array
|
||||
{
|
||||
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
||||
$startTs = (int) strtotime($month . '-01 00:00:00');
|
||||
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
||||
|
||||
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
||||
$logRows = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||
->where('lg.create_time', '<=', $endTs)
|
||||
->where('lg.diagnosis_id', '>', 0)
|
||||
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
|
||||
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
|
||||
$timeline = [];
|
||||
$pairsRaw = [];
|
||||
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
|
||||
$diagsByAssistant = [];
|
||||
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
|
||||
$candidateDiagSet = [];
|
||||
foreach ($logRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||
$t = (int) ($r['create_time'] ?? 0);
|
||||
$timeline[$did][] = ['t' => $t, 'to' => $aid];
|
||||
if ($aid > 0) {
|
||||
$candidateDiagSet[$did] = true;
|
||||
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
|
||||
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
|
||||
$diagsByAssistant[$aid][$did] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
|
||||
$assignedDiagIds = [];
|
||||
foreach ($diagsByAssistant as $diagSet) {
|
||||
foreach ($diagSet as $did => $_) {
|
||||
$assignedDiagIds[(int) $did] = true;
|
||||
}
|
||||
}
|
||||
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
|
||||
if ($refundRejectDiagSet !== []) {
|
||||
foreach ($diagsByAssistant as $aid => $diagSet) {
|
||||
foreach ($diagSet as $did => $_) {
|
||||
if (isset($refundRejectDiagSet[$did])) {
|
||||
unset($diagsByAssistant[$aid][$did]);
|
||||
}
|
||||
}
|
||||
if ($diagsByAssistant[$aid] === []) {
|
||||
unset($diagsByAssistant[$aid]);
|
||||
}
|
||||
}
|
||||
$pairsRaw = array_values(array_filter(
|
||||
$pairsRaw,
|
||||
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
|
||||
));
|
||||
}
|
||||
|
||||
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
|
||||
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||
$slotOrdersByAssistant = [];
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
|
||||
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
|
||||
$orderRows = self::fetchOrderSeqRows(
|
||||
$chunk,
|
||||
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
||||
);
|
||||
$curDid = 0;
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = 0;
|
||||
foreach ($orderRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($did !== $curDid) {
|
||||
$curDid = $did;
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
}
|
||||
$seq++;
|
||||
$effectiveSlot = $seq + $offset;
|
||||
$ct = (int) ($r['create_time'] ?? 0);
|
||||
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||
$tl = $timeline[$did] ?? [];
|
||||
$tlCount = \count($tl);
|
||||
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
|
||||
$holder = (int) $tl[$ptr]['to'];
|
||||
$ptr++;
|
||||
}
|
||||
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||
continue;
|
||||
}
|
||||
if ($ct < $startTs || $ct > $endTs) {
|
||||
continue;
|
||||
}
|
||||
if ($holder > 0) {
|
||||
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
||||
if ($subtreeSet === []) {
|
||||
// 无二中心部门时整表为空,避免误展示其它中心数据
|
||||
$diagsByAssistant = [];
|
||||
$slotOrdersByAssistant = [];
|
||||
} else {
|
||||
foreach ($universeIds as $aid) {
|
||||
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
||||
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
||||
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'startDate' => date('Y-m-d', $startTs),
|
||||
'endDate' => date('Y-m-d', $endTs),
|
||||
'startTs' => $startTs,
|
||||
'endTs' => $endTs,
|
||||
'pairsRaw' => $pairsRaw,
|
||||
'diagsByAssistant' => $diagsByAssistant,
|
||||
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||
'assistantDept' => $assistantDept,
|
||||
'deptNames' => $deptNames,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
|
||||
*
|
||||
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private static function assistantUniverse(array $ctx): array
|
||||
{
|
||||
$set = [];
|
||||
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
|
||||
$set[(int) $aid] = true;
|
||||
}
|
||||
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
|
||||
$set[(int) $aid] = true;
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行级 scope:assistant_id(医助行)优先;其次 dept_id(部门归组行,0=未分配部门);都不传 = 全部(已含部门筛选)。
|
||||
*
|
||||
* @return array<int, true> scope 内医助集合
|
||||
*/
|
||||
private static function applyRowScope(array $ctx, array $params): array
|
||||
{
|
||||
$assistantId = (int) ($params['assistant_id'] ?? 0);
|
||||
$hasDeptScope = isset($params['dept_id']) && $params['dept_id'] !== '' && $params['dept_id'] !== null;
|
||||
$deptScopeId = $hasDeptScope ? (int) $params['dept_id'] : -1;
|
||||
|
||||
$assistantSet = [];
|
||||
foreach (self::assistantUniverse($ctx) as $aid => $_) {
|
||||
if ($assistantId > 0) {
|
||||
if ((int) $aid === $assistantId) {
|
||||
$assistantSet[$aid] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($hasDeptScope) {
|
||||
if ((int) ($ctx['assistantDept'][$aid] ?? 0) === $deptScopeId) {
|
||||
$assistantSet[$aid] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$assistantSet[$aid] = true;
|
||||
}
|
||||
|
||||
return $assistantSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助 → 归属部门 + 部门名称表。
|
||||
*
|
||||
* @param list<int> $adminIds
|
||||
*
|
||||
* @return array{0: array<int,int>, 1: array<int,string>}
|
||||
*/
|
||||
private static function buildAssistantDeptIndex(array $adminIds): array
|
||||
{
|
||||
if ($adminIds === []) {
|
||||
return [[], []];
|
||||
}
|
||||
// admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性
|
||||
$relRows = Db::name('admin_dept')
|
||||
->whereIn('admin_id', $adminIds)
|
||||
->order(['admin_id' => 'asc', 'dept_id' => 'asc'])
|
||||
->field(['admin_id', 'dept_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$deptNames = Db::name('dept')
|
||||
->whereNull('delete_time')
|
||||
->column('name', 'id');
|
||||
|
||||
$canonical = [];
|
||||
foreach ($relRows as $r) {
|
||||
$aid = (int) ($r['admin_id'] ?? 0);
|
||||
$deptId = (int) ($r['dept_id'] ?? 0);
|
||||
if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($deptNames[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
$canonical[$aid] = $deptId;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
foreach ($deptNames as $id => $name) {
|
||||
$names[(int) $id] = (string) $name;
|
||||
}
|
||||
|
||||
return [$canonical, $names];
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门筛选集合:始终落在「二中心」子树内。
|
||||
* - 未传 dept_ids:整棵二中心子树
|
||||
* - 已传:所选部门及其下级 ∩ 二中心子树(非法/非二中心 id 被忽略)
|
||||
*
|
||||
* @param mixed $raw
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private static function resolveDeptFilterSet(mixed $raw): array
|
||||
{
|
||||
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
if ($erSet === []) {
|
||||
return [];
|
||||
}
|
||||
$deptFilterIds = self::parseDeptIds($raw);
|
||||
if ($deptFilterIds === []) {
|
||||
return $erSet;
|
||||
}
|
||||
$allowedRoots = [];
|
||||
foreach ($deptFilterIds as $id) {
|
||||
if (isset($erSet[$id])) {
|
||||
$allowedRoots[] = $id;
|
||||
}
|
||||
}
|
||||
if ($allowedRoots === []) {
|
||||
return [];
|
||||
}
|
||||
$expanded = self::expandDeptSubtreeSet($allowedRoots);
|
||||
$out = [];
|
||||
foreach ($expanded as $id => $_) {
|
||||
if (isset($erSet[$id])) {
|
||||
$out[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw int[] | 逗号分隔字符串
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function parseDeptIds(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '' || $raw === []) {
|
||||
return [];
|
||||
}
|
||||
$list = \is_array($raw) ? $raw : explode(',', (string) $raw);
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', $list),
|
||||
static fn (int $v): bool => $v > 0
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中部门 + 全部组织下级的 id 集合。
|
||||
*
|
||||
* @param list<int> $deptIds
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private static function expandDeptSubtreeSet(array $deptIds): array
|
||||
{
|
||||
$rows = Db::name('dept')
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'pid'])
|
||||
->select()
|
||||
->toArray();
|
||||
$childrenByPid = [];
|
||||
foreach ($rows as $r) {
|
||||
$childrenByPid[(int) $r['pid']][] = (int) $r['id'];
|
||||
}
|
||||
|
||||
$set = [];
|
||||
$queue = $deptIds;
|
||||
while ($queue !== []) {
|
||||
$id = (int) array_shift($queue);
|
||||
if ($id <= 0 || isset($set[$id])) {
|
||||
continue;
|
||||
}
|
||||
$set[$id] = true;
|
||||
foreach ($childrenByPid[$id] ?? [] as $childId) {
|
||||
$queue[] = $childId;
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。
|
||||
* 用于「当月被指派总数」分母过滤;不限订单创建月份。
|
||||
*
|
||||
* @param list<int> $diagIds
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
|
||||
{
|
||||
if ($diagIds === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||
$ids = Db::name('tcm_prescription_order')
|
||||
->whereIn('diagnosis_id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->whereIn('fulfillment_status', [9, 10])
|
||||
->group('diagnosis_id')
|
||||
->column('diagnosis_id');
|
||||
foreach ($ids as $id) {
|
||||
$out[(int) $id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
|
||||
*
|
||||
* @param list<int> $diagIds
|
||||
* @param list<string> $fields
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
||||
{
|
||||
$q = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->whereIn('o.diagnosis_id', $diagIds)
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
|
||||
return $q
|
||||
->field($fields)
|
||||
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $diagIds
|
||||
*
|
||||
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
|
||||
*/
|
||||
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
||||
{
|
||||
if ($diagIds === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereIn('id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->column('revisit_slot_start_offset', 'id');
|
||||
foreach ($rows as $id => $offset) {
|
||||
$out[(int) $id] = (int) $offset;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
|
||||
*
|
||||
* @param array<int, int> $offsetMap
|
||||
*/
|
||||
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
||||
{
|
||||
$offset = (int) ($offsetMap[$diagId] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > 20) {
|
||||
$offset = 20;
|
||||
}
|
||||
|
||||
return $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $diagIds
|
||||
*
|
||||
* @return array<int, array{patient_name:string,phone:string}>
|
||||
*/
|
||||
private static function fetchDiagnosisInfo(array $diagIds): array
|
||||
{
|
||||
if ($diagIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereIn('id', $diagIds)
|
||||
->field(['id', 'patient_name', 'phone'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[(int) $r['id']] = [
|
||||
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
|
||||
'phone' => trim((string) ($r['phone'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $slotCounts [slot => n]
|
||||
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||
*
|
||||
* @return array<string, int|float|null>
|
||||
*/
|
||||
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
|
||||
{
|
||||
$pack = ['assigned_count' => $assigned];
|
||||
foreach ($slots as $slot) {
|
||||
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
||||
$pack['visit' . $slot . '_count'] = $cnt;
|
||||
$pack['visit' . $slot . '_rate'] = $assigned > 0
|
||||
? round($cnt / $assigned * 100, 2)
|
||||
: null;
|
||||
}
|
||||
|
||||
return $pack;
|
||||
}
|
||||
|
||||
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
||||
private static function normalizeMonth(string $month): string
|
||||
{
|
||||
$month = trim($month);
|
||||
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
|
||||
return $month;
|
||||
}
|
||||
|
||||
return date('Y-m');
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ use think\facade\Db;
|
||||
*
|
||||
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
||||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
||||
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign)
|
||||
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
||||
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic)
|
||||
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
||||
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
||||
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
||||
@@ -857,7 +858,7 @@ class YejiStatsLogic
|
||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
||||
|
||||
$leaderboards = [];
|
||||
@@ -1777,8 +1778,9 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0)。
|
||||
* 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。
|
||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
||||
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
||||
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||
*
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[]|null $tagDiagIds
|
||||
@@ -1801,11 +1803,13 @@ class YejiStatsLogic
|
||||
return [];
|
||||
}
|
||||
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$query = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->where('lg.related_po_create_time', '>', 0)
|
||||
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0);
|
||||
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||
->where('lg.create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0)
|
||||
->where('lg.is_inherit', 0);
|
||||
if ($channelFilterActive) {
|
||||
if ($appointmentChannelValues !== []) {
|
||||
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
|
||||
@@ -1816,8 +1820,6 @@ class YejiStatsLogic
|
||||
if ($pack === null) {
|
||||
return [];
|
||||
}
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
|
||||
$query->whereRaw($pack[0], $pack[1]);
|
||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||
@@ -1837,7 +1839,7 @@ class YejiStatsLogic
|
||||
}
|
||||
}
|
||||
|
||||
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
|
||||
$query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) AS cnt')])
|
||||
->group('lg.to_assistant_id');
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
@@ -1869,7 +1871,7 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。
|
||||
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1),按 to_assistant_id 汇总(供排行榜)。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -1883,11 +1885,13 @@ class YejiStatsLogic
|
||||
?array $dataScopeAdminIds = null,
|
||||
?array $channelInfo = null
|
||||
): array {
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$query = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->where('lg.related_po_create_time', '>', 0)
|
||||
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0);
|
||||
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||
->where('lg.create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0)
|
||||
->where('lg.is_inherit', 0);
|
||||
|
||||
if ($channelFilterActive) {
|
||||
if ($appointmentChannelValues !== []) {
|
||||
@@ -1899,8 +1903,6 @@ class YejiStatsLogic
|
||||
if ($pack === null) {
|
||||
return [];
|
||||
}
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
|
||||
$query->whereRaw($pack[0], $pack[1]);
|
||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||
@@ -1920,7 +1922,7 @@ class YejiStatsLogic
|
||||
}
|
||||
}
|
||||
|
||||
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
|
||||
$query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) AS cnt')])
|
||||
->group('lg.to_assistant_id');
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
@@ -2614,6 +2616,420 @@ class YejiStatsLogic
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
|
||||
*
|
||||
* @param array{
|
||||
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
|
||||
* assistant_id?:int|string,admin_id?:int|string,dept_id?:int|string,
|
||||
* page?:int|string,page_size?:int|string
|
||||
* } $params
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function assignLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
|
||||
{
|
||||
$c = self::resolveYejiContext($params);
|
||||
if ($viewerAdminId > 0) {
|
||||
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
|
||||
}
|
||||
|
||||
$assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0);
|
||||
|
||||
if ($assistantId <= 0 && array_key_exists('dept_id', $params)) {
|
||||
return self::yejiDeptAssignLinesInner($params, $c);
|
||||
}
|
||||
|
||||
return self::yejiAssistantAssignLinesInner($params, $c, $assistantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $c
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function yejiDeptAssignLinesInner(array $params, array $c): array
|
||||
{
|
||||
$deptIdParam = (int) ($params['dept_id'] ?? 0);
|
||||
$channelCode = (string) $c['channelCode'];
|
||||
$deptName = $deptIdParam === 0
|
||||
? '未归属中心'
|
||||
: self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']);
|
||||
|
||||
$empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array {
|
||||
return self::yejiAssignLinesEmptyPayload($c, $note, 0, '', $deptIdParam, $deptName, $channelCode);
|
||||
};
|
||||
|
||||
if ($deptIdParam <= 0) {
|
||||
return $empty('未归属中心无被指派明细(看板该行为 0)。');
|
||||
}
|
||||
if (!in_array($deptIdParam, $c['tableRowDeptIds'], true)) {
|
||||
return $empty('该部门不在当前展示部门筛选或数据权限范围内。');
|
||||
}
|
||||
|
||||
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
if ($channelCode !== '' && isset($erCenterDeptSet[$deptIdParam])) {
|
||||
return $empty('与看板一致:二中心及其组织下级在选定渠道下被指派数计 0,无明细。');
|
||||
}
|
||||
|
||||
$targetAssistantIds = [];
|
||||
foreach ($c['adminToPrimary'] as $aid => $primary) {
|
||||
if ((int) $primary === $deptIdParam) {
|
||||
$targetAssistantIds[] = (int) $aid;
|
||||
}
|
||||
}
|
||||
if ($targetAssistantIds === []) {
|
||||
return $empty('当前部门下没有可映射的被指派医助,无明细。');
|
||||
}
|
||||
|
||||
return self::yejiAssignLinesFetch($c, $params, $targetAssistantIds, 0, '', $deptIdParam, $deptName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $c
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function yejiAssistantAssignLinesInner(array $params, array $c, int $assistantId): array
|
||||
{
|
||||
$channelCode = (string) $c['channelCode'];
|
||||
|
||||
$assistantName = '';
|
||||
if ($assistantId > 0) {
|
||||
$assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: '');
|
||||
}
|
||||
|
||||
$empty = static function (string $note) use ($c, $assistantId, $assistantName, $channelCode): array {
|
||||
return self::yejiAssignLinesEmptyPayload($c, $note, $assistantId, $assistantName, 0, '', $channelCode);
|
||||
};
|
||||
|
||||
if ($assistantId <= 0) {
|
||||
return $empty('请指定有效医助,或传入 dept_id 查看部门汇总明细。');
|
||||
}
|
||||
|
||||
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
|
||||
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
|
||||
? $c['dataScopeVisibleAdminIds']
|
||||
: null;
|
||||
if ($dataScopeRestricted && $dataScopeAdminIds !== null) {
|
||||
$flip = array_flip($dataScopeAdminIds);
|
||||
if (!isset($flip[$assistantId])) {
|
||||
return $empty('当前账号数据权限下不可查看该医助被指派明细。');
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($c['adminToPrimary'][$assistantId])) {
|
||||
return $empty('该医助不在当前业绩看板展示范围内。');
|
||||
}
|
||||
|
||||
$tagDiagIds = $c['tagDiagIds'];
|
||||
$tagAssistantIds = $c['tagAssistantIds'];
|
||||
$tagFallback = $c['tagFallback'];
|
||||
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
|
||||
|
||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||
return $empty('当前渠道标签下无关联诊单,无被指派明细。');
|
||||
}
|
||||
if ($scopedAssistants !== null && $scopedAssistants === []) {
|
||||
return $empty('当前渠道标签下无关联医助,无被指派明细。');
|
||||
}
|
||||
if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) {
|
||||
return $empty('当前渠道标签口径下该医助无被指派记录(与排行榜「被指派数」一致)。');
|
||||
}
|
||||
|
||||
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
$primaryDept = (int) ($c['adminToPrimary'][$assistantId] ?? 0);
|
||||
if ($channelCode !== '' && $primaryDept > 0 && isset($erCenterDeptSet[$primaryDept])) {
|
||||
return $empty('与看板一致:二中心及其组织下级医助在选定渠道下被指派数计 0,无明细。');
|
||||
}
|
||||
|
||||
return self::yejiAssignLinesFetch(
|
||||
$c,
|
||||
$params,
|
||||
[$assistantId],
|
||||
$assistantId,
|
||||
$assistantName,
|
||||
0,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $c
|
||||
* @param array<string, mixed> $params
|
||||
* @param list<int> $targetAssistantIds
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function yejiAssignLinesFetch(
|
||||
array $c,
|
||||
array $params,
|
||||
array $targetAssistantIds,
|
||||
int $assistantIdOut,
|
||||
string $assistantNameOut,
|
||||
int $deptIdOut,
|
||||
string $deptNameOut
|
||||
): array {
|
||||
$channelCode = (string) $c['channelCode'];
|
||||
$startTs = (int) $c['startTs'];
|
||||
$endTs = (int) $c['endTs'];
|
||||
$page = max(1, (int) ($params['page'] ?? 1));
|
||||
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
|
||||
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
|
||||
? $c['dataScopeVisibleAdminIds']
|
||||
: null;
|
||||
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
|
||||
$visFlip = array_flip($dataScopeAdminIds);
|
||||
$targetAssistantIds = array_values(array_filter(
|
||||
$targetAssistantIds,
|
||||
static fn (int $aid): bool => isset($visFlip[$aid])
|
||||
));
|
||||
}
|
||||
|
||||
if ($targetAssistantIds === []) {
|
||||
return self::yejiAssignLinesEmptyPayload(
|
||||
$c,
|
||||
'当前数据权限下无可查看的被指派医助。',
|
||||
$assistantIdOut,
|
||||
$assistantNameOut,
|
||||
$deptIdOut,
|
||||
$deptNameOut,
|
||||
$channelCode
|
||||
);
|
||||
}
|
||||
|
||||
$tagDiagIds = $c['tagDiagIds'];
|
||||
$tagAssistantIds = $c['tagAssistantIds'];
|
||||
$tagFallback = $c['tagFallback'];
|
||||
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
|
||||
|
||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||
return self::yejiAssignLinesEmptyPayload(
|
||||
$c,
|
||||
'当前渠道标签下无关联诊单,无被指派明细。',
|
||||
$assistantIdOut,
|
||||
$assistantNameOut,
|
||||
$deptIdOut,
|
||||
$deptNameOut,
|
||||
$channelCode
|
||||
);
|
||||
}
|
||||
if ($scopedAssistants !== null && $scopedAssistants === []) {
|
||||
return self::yejiAssignLinesEmptyPayload(
|
||||
$c,
|
||||
'当前渠道标签下无关联医助,无被指派明细。',
|
||||
$assistantIdOut,
|
||||
$assistantNameOut,
|
||||
$deptIdOut,
|
||||
$deptNameOut,
|
||||
$channelCode
|
||||
);
|
||||
}
|
||||
|
||||
$buildBaseQuery = static function () use (
|
||||
$c,
|
||||
$targetAssistantIds,
|
||||
$startTs,
|
||||
$endTs,
|
||||
$tagDiagIds,
|
||||
$scopedAssistants
|
||||
) {
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$q = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||
->where('lg.create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0)
|
||||
->where('lg.is_inherit', 0)
|
||||
->whereIn('lg.to_assistant_id', $targetAssistantIds);
|
||||
|
||||
self::applyYejiAssignLogChannelFilters(
|
||||
$q,
|
||||
$tagDiagIds,
|
||||
$scopedAssistants,
|
||||
$c['appointmentChannelValues'],
|
||||
$c['channelFilterActive'],
|
||||
$c['channelInfo']
|
||||
);
|
||||
|
||||
return $q;
|
||||
};
|
||||
|
||||
$countRows = $buildBaseQuery()
|
||||
->field(['lg.to_assistant_id', 'lg.diagnosis_id'])
|
||||
->group('lg.to_assistant_id,lg.diagnosis_id')
|
||||
->select()
|
||||
->toArray();
|
||||
$count = \count($countRows);
|
||||
|
||||
$rawList = $buildBaseQuery()
|
||||
->field([
|
||||
'lg.to_assistant_id',
|
||||
'lg.diagnosis_id',
|
||||
Db::raw('COUNT(*) AS assign_count'),
|
||||
Db::raw('MAX(lg.create_time) AS last_assign_time'),
|
||||
])
|
||||
->group('lg.to_assistant_id,lg.diagnosis_id')
|
||||
->order('last_assign_time', 'desc')
|
||||
->order('lg.diagnosis_id', 'desc')
|
||||
->limit($offset, $limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$diagIds = [];
|
||||
$assistantIds = [];
|
||||
foreach ($rawList as $rw) {
|
||||
$diagIds[(int) ($rw['diagnosis_id'] ?? 0)] = true;
|
||||
$assistantIds[(int) ($rw['to_assistant_id'] ?? 0)] = true;
|
||||
}
|
||||
$diagInfo = self::fetchYejiAssignDiagnosisInfo(array_keys($diagIds));
|
||||
$nameMap = $assistantIds !== []
|
||||
? Db::name('admin')->whereIn('id', array_keys($assistantIds))->column('name', 'id')
|
||||
: [];
|
||||
|
||||
$lists = [];
|
||||
foreach ($rawList as $rw) {
|
||||
$did = (int) ($rw['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($rw['to_assistant_id'] ?? 0);
|
||||
$lastTs = (int) ($rw['last_assign_time'] ?? 0);
|
||||
$lists[] = [
|
||||
'diagnosis_id' => $did,
|
||||
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||
'assistant_id' => $aid,
|
||||
'assistant_name' => $aid > 0 ? (string) ($nameMap[$aid] ?? ('#' . $aid)) : '—',
|
||||
'assign_count' => (int) ($rw['assign_count'] ?? 0),
|
||||
'last_assign_time' => $lastTs,
|
||||
'last_assign_time_text' => $lastTs > 0 ? date('Y-m-d H:i:s', $lastTs) : '',
|
||||
];
|
||||
}
|
||||
|
||||
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
|
||||
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
|
||||
|
||||
return [
|
||||
'start_date' => (string) $c['startDate'],
|
||||
'end_date' => (string) $c['endDate'],
|
||||
'assistant_id' => $assistantIdOut,
|
||||
'assistant_name' => $assistantNameOut,
|
||||
'dept_id' => $deptIdOut,
|
||||
'dept_name' => $deptNameOut,
|
||||
'channel_code' => $channelCode,
|
||||
'count' => $count,
|
||||
'lists' => $lists,
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $c
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function yejiAssignLinesEmptyPayload(
|
||||
array $c,
|
||||
string $note,
|
||||
int $assistantId,
|
||||
string $assistantName,
|
||||
int $deptId,
|
||||
string $deptName,
|
||||
string $channelCode
|
||||
): array {
|
||||
return [
|
||||
'start_date' => (string) $c['startDate'],
|
||||
'end_date' => (string) $c['endDate'],
|
||||
'assistant_id' => $assistantId,
|
||||
'assistant_name' => $assistantName,
|
||||
'dept_id' => $deptId,
|
||||
'dept_name' => $deptName,
|
||||
'channel_code' => $channelCode,
|
||||
'count' => 0,
|
||||
'lists' => [],
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \think\db\Query $query
|
||||
*/
|
||||
private static function applyYejiAssignLogChannelFilters(
|
||||
$query,
|
||||
?array $tagDiagIds,
|
||||
?array $tagAssistantIds,
|
||||
array $appointmentChannelValues,
|
||||
bool $channelFilterActive,
|
||||
?array $channelInfo
|
||||
): void {
|
||||
if (!$channelFilterActive) {
|
||||
return;
|
||||
}
|
||||
if ($appointmentChannelValues !== []) {
|
||||
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
|
||||
if ($norm === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
|
||||
if ($pack === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$query->whereRaw($pack[0], $pack[1]);
|
||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($tagDiagIds !== null) {
|
||||
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
|
||||
}
|
||||
if ($tagAssistantIds !== null) {
|
||||
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
|
||||
}
|
||||
} else {
|
||||
$query->whereRaw('0 = 1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $diagIds
|
||||
*
|
||||
* @return array<int, array{patient_name:string,phone:string}>
|
||||
*/
|
||||
private static function fetchYejiAssignDiagnosisInfo(array $diagIds): array
|
||||
{
|
||||
if ($diagIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereIn('id', $diagIds)
|
||||
->field(['id', 'patient_name', 'phone'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[(int) $r['id']] = [
|
||||
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
|
||||
'phone' => trim((string) ($r['phone'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,7 @@ use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
@@ -73,8 +74,8 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
// 生成患者ID
|
||||
$params['patient_id'] = self::generatePatientId();
|
||||
// 新患者先占位 0,写入后 patient_id 与自增 id 对齐
|
||||
$params['patient_id'] = 0;
|
||||
|
||||
// 处理既往史数组
|
||||
if (isset($params['past_history']) && is_array($params['past_history'])) {
|
||||
@@ -119,6 +120,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
$model = Diagnosis::create($params);
|
||||
$model = self::syncPatientIdWithDiagnosisId($model);
|
||||
|
||||
// 图片写入 doctor_note 表
|
||||
if (!empty($newTongueImages) || !empty($newReportFiles)) {
|
||||
@@ -130,7 +132,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
// 自动为患者创建 TRTC 账号
|
||||
self::createPatientTrtcAccount($model->id);
|
||||
self::createPatientTrtcAccount($model->patient_id);
|
||||
|
||||
return $model->id;
|
||||
} catch (\Exception $e) {
|
||||
@@ -140,16 +142,20 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 生成患者ID
|
||||
* @return int
|
||||
* @notes 新患者首张诊单:patient_id 与自增 id 对齐
|
||||
* @param Diagnosis $model
|
||||
* @return Diagnosis
|
||||
*/
|
||||
private static function generatePatientId(): int
|
||||
private static function syncPatientIdWithDiagnosisId(Diagnosis $model): Diagnosis
|
||||
{
|
||||
// 获取当前最大的患者ID
|
||||
$maxPatientId = Diagnosis::max('id') ?? 10000000;
|
||||
|
||||
// 返回下一个患者ID
|
||||
return $maxPatientId + 1;
|
||||
if ((int) $model->patient_id === (int) $model->id) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
Diagnosis::where('id', $model->id)->update(['patient_id' => $model->id]);
|
||||
$model->patient_id = (int) $model->id;
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,9 +163,23 @@ class DiagnosisLogic extends BaseLogic
|
||||
* @param array $params
|
||||
* @return bool
|
||||
*/
|
||||
public static function edit(array $params): bool
|
||||
/** 诊单编辑:患者基本信息字段(姓名/身份证/手机/性别/年龄) */
|
||||
private const PATIENT_BASIC_FIELDS = ['patient_name', 'id_card', 'phone', 'gender', 'age'];
|
||||
|
||||
public static function edit(array $params, array $adminInfo = []): bool
|
||||
{
|
||||
try {
|
||||
if (!empty($params['id']) && $adminInfo !== []) {
|
||||
$existing = Diagnosis::find((int) $params['id']);
|
||||
if ($existing && !self::canEditPatientBasicInfo((int) $params['id'], $adminInfo)) {
|
||||
if (self::patientBasicFieldsChanged($existing, $params)) {
|
||||
self::setError('最近业务订单未完成,无法修改患者基本信息');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查手机号是否重复(排除当前记录)
|
||||
if (!empty($params['phone'])) {
|
||||
$exists = Diagnosis::where('phone', $params['phone'])
|
||||
@@ -250,7 +270,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
* @param $params
|
||||
* @return array
|
||||
*/
|
||||
public static function detail($params): array
|
||||
public static function detail($params, array $adminInfo = []): array
|
||||
{
|
||||
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
|
||||
|
||||
@@ -317,7 +337,17 @@ class DiagnosisLogic extends BaseLogic
|
||||
$diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date'];
|
||||
}
|
||||
$diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0;
|
||||
|
||||
|
||||
$latestPo = self::getLatestPrescriptionOrderRowForDiagnosis((int) ($diagnosis['id'] ?? 0));
|
||||
$patientBasicLocked = $latestPo !== null && (int) ($latestPo['fulfillment_status'] ?? 0) !== 3;
|
||||
$diagnosis['patient_basic_locked'] = $patientBasicLocked;
|
||||
$diagnosis['can_edit_patient_basic'] = self::canEditPatientBasicInfo((int) ($diagnosis['id'] ?? 0), $adminInfo);
|
||||
$diagnosis['latest_prescription_order'] = $latestPo !== null ? [
|
||||
'id' => (int) ($latestPo['id'] ?? 0),
|
||||
'fulfillment_status' => (int) ($latestPo['fulfillment_status'] ?? 0),
|
||||
'fulfillment_status_text'=> PrescriptionOrderLogic::fulfillmentStatusLabel((int) ($latestPo['fulfillment_status'] ?? 0)),
|
||||
] : null;
|
||||
|
||||
return $diagnosis;
|
||||
}
|
||||
|
||||
@@ -486,11 +516,11 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
||||
* 诊单下「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
||||
*
|
||||
* @return array{creator_id: int, create_time: int}|null
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
|
||||
private static function getLatestPrescriptionOrderRowForDiagnosis(int $diagnosisId): ?array
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return null;
|
||||
@@ -500,19 +530,88 @@ class DiagnosisLogic extends BaseLogic
|
||||
->whereNull('delete_time')
|
||||
->order('create_time', 'desc')
|
||||
->order('id', 'desc')
|
||||
->field(['creator_id', 'create_time'])
|
||||
->field(['id', 'creator_id', 'create_time', 'fulfillment_status'])
|
||||
->find();
|
||||
|
||||
if ($row === null || $row === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
||||
*
|
||||
* @return array{creator_id: int, create_time: int}|null
|
||||
*/
|
||||
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
|
||||
{
|
||||
$row = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'creator_id' => (int) ($row['creator_id'] ?? 0),
|
||||
'create_time' => (int) ($row['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单权限 tcm.diagnosis/editPatientBasic:最近业务订单未完成时仍可改患者基本信息
|
||||
*/
|
||||
public static function hasEditPatientBasicPermission(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||
|
||||
return in_array('tcm.diagnosis/editPatientBasic', $perms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许编辑诊单患者基本信息(无业务单 / 最近一单已完成 / 具备 editPatientBasic 权限)
|
||||
*/
|
||||
public static function canEditPatientBasicInfo(int $diagnosisId, array $adminInfo): bool
|
||||
{
|
||||
$latest = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
|
||||
if ($latest === null) {
|
||||
return true;
|
||||
}
|
||||
if ((int) ($latest['fulfillment_status'] ?? 0) === 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::hasEditPatientBasicPermission($adminInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Diagnosis $existing
|
||||
*/
|
||||
private static function patientBasicFieldsChanged($existing, array $params): bool
|
||||
{
|
||||
foreach (self::PATIENT_BASIC_FIELDS as $field) {
|
||||
if (!array_key_exists($field, $params)) {
|
||||
continue;
|
||||
}
|
||||
$old = $existing->{$field};
|
||||
$new = $params[$field];
|
||||
if ($field === 'age' || $field === 'gender') {
|
||||
if ((int) $old !== (int) $new) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((string) $old !== (string) $new) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
|
||||
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
|
||||
@@ -649,6 +748,49 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 诊单指派医助操作记录列表
|
||||
*/
|
||||
public static function setRevisitSlotStartOffset(int $diagnosisId, int $offset, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
if ($diagnosisId <= 0) {
|
||||
self::setError('诊单不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($offset < 0 || $offset > 20) {
|
||||
self::setError('起始偏移须在 0~20 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
|
||||
if (!$diagnosis) {
|
||||
self::setError('诊单不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
$old = (int) ($diagnosis->revisit_slot_start_offset ?? 0);
|
||||
if ($old < 0) {
|
||||
$old = 0;
|
||||
}
|
||||
if ($old > 20) {
|
||||
$old = 20;
|
||||
}
|
||||
if ($old === $offset) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 诊单指派医助操作记录列表
|
||||
*/
|
||||
@@ -3092,8 +3234,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
|
||||
|
||||
$patientId = $params['patient_id'] ?? 0;
|
||||
if (!$patientId) {
|
||||
$params['patient_id'] = self::generatePatientId();
|
||||
$isNewPatient = !$patientId;
|
||||
if ($isNewPatient) {
|
||||
$params['patient_id'] = 0;
|
||||
}
|
||||
$params['status'] = 1;
|
||||
|
||||
@@ -3123,6 +3266,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
$model = Diagnosis::create($params);
|
||||
if ($isNewPatient) {
|
||||
$model = self::syncPatientIdWithDiagnosisId($model);
|
||||
}
|
||||
self::createPatientTrtcAccount($model->patient_id);
|
||||
|
||||
// 图片写入 doctor_note
|
||||
|
||||
@@ -2324,7 +2324,8 @@ class PrescriptionOrderLogic
|
||||
->where(function ($query) {
|
||||
$query->where('payment_method', 'manual')
|
||||
->whereOr('payment_method', 'fubei')
|
||||
->whereOr('create_type', 'fubei');
|
||||
->whereOr('create_type', 'fubei')
|
||||
->whereOr('create_type', 'express_cod');
|
||||
})
|
||||
->update([
|
||||
'status' => 5, // 待审核
|
||||
@@ -2374,6 +2375,123 @@ class PrescriptionOrderLogic
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工新增操作日志;可选单独调整处方审核 / 支付单审核状态(不触发常规审核流程副作用)
|
||||
*
|
||||
* @param array<string,mixed> $params id, summary, prescription_audit_status?, payment_slip_audit_status?, prescription_audit_remark?, payment_slip_audit_remark?
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function addLog(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
$summary = mb_substr(trim((string) ($params['summary'] ?? '')), 0, 500);
|
||||
if ($summary === '') {
|
||||
self::$error = '请填写日志内容';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
self::$error = '订单不存在';
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限操作';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$changeParts = [];
|
||||
$hasRxChange = array_key_exists('prescription_audit_status', $params)
|
||||
&& $params['prescription_audit_status'] !== ''
|
||||
&& $params['prescription_audit_status'] !== null;
|
||||
$hasPayChange = array_key_exists('payment_slip_audit_status', $params)
|
||||
&& $params['payment_slip_audit_status'] !== ''
|
||||
&& $params['payment_slip_audit_status'] !== null;
|
||||
|
||||
if ($hasRxChange) {
|
||||
if (!self::canAuditPrescriptionOrder($adminInfo)) {
|
||||
self::$error = '无处方审核权限,不能调整处方审核状态';
|
||||
|
||||
return false;
|
||||
}
|
||||
$newRx = (int) $params['prescription_audit_status'];
|
||||
if (!in_array($newRx, [0, 1, 2], true)) {
|
||||
self::$error = '处方审核状态无效';
|
||||
|
||||
return false;
|
||||
}
|
||||
$oldRx = (int) $order->prescription_audit_status;
|
||||
if ($newRx !== $oldRx) {
|
||||
$order->prescription_audit_status = $newRx;
|
||||
$changeParts[] = '处方审核:' . self::auditStatusLabelForLog($oldRx)
|
||||
. ' → ' . self::auditStatusLabelForLog($newRx);
|
||||
}
|
||||
if (array_key_exists('prescription_audit_remark', $params)) {
|
||||
$order->prescription_audit_remark = mb_substr(trim((string) $params['prescription_audit_remark']), 0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasPayChange) {
|
||||
if (!self::canAuditPaymentSlipOrder($adminInfo)) {
|
||||
self::$error = '无支付单审核权限,不能调整支付单审核状态';
|
||||
|
||||
return false;
|
||||
}
|
||||
$newPay = (int) $params['payment_slip_audit_status'];
|
||||
if (!in_array($newPay, [0, 1, 2], true)) {
|
||||
self::$error = '支付单审核状态无效';
|
||||
|
||||
return false;
|
||||
}
|
||||
$oldPay = (int) $order->payment_slip_audit_status;
|
||||
if ($newPay !== $oldPay) {
|
||||
$order->payment_slip_audit_status = $newPay;
|
||||
$changeParts[] = '支付单审核:' . self::auditStatusLabelForLog($oldPay)
|
||||
. ' → ' . self::auditStatusLabelForLog($newPay);
|
||||
}
|
||||
if (array_key_exists('payment_slip_audit_remark', $params)) {
|
||||
$order->payment_slip_audit_remark = mb_substr(trim((string) $params['payment_slip_audit_remark']), 0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasRxChange || $hasPayChange) {
|
||||
self::syncFulfillmentStatus($order);
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = $e->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$logSummary = $summary;
|
||||
if ($changeParts !== []) {
|
||||
$logSummary .= '(' . implode(';', $changeParts) . ')';
|
||||
}
|
||||
self::writeLog($id, $adminId, $adminInfo, 'manual_log', $logSummary);
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function auditStatusLabelForLog(int $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
1 => '已通过',
|
||||
2 => '已驳回',
|
||||
default => '待审核',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为「已发货/已签收」(fulfillment_status=5/6) 的业务订单新增一条关联支付单(zyt_order),
|
||||
* 创建后将支付单链接到业务订单,并将处方/支付审核状态重置为待审核以启动再次审核流程。
|
||||
@@ -2403,6 +2521,9 @@ class PrescriptionOrderLogic
|
||||
$amount = round((float) ($params['pay_amount'] ?? 0), 2);
|
||||
$remark = mb_substr(trim((string) ($params['pay_remark'] ?? '')), 0, 200);
|
||||
$completionRequest = (int) ($params['completion_request'] ?? 0);
|
||||
// 创建方式:付呗(默认) 或 快递代收(express_cod)
|
||||
$payCreateType = (string) ($params['pay_create_type'] ?? 'fubei');
|
||||
$isExpressCod = $payCreateType === 'express_cod';
|
||||
|
||||
if ($amount < 0) {
|
||||
self::$error = '支付单金额不能为负数';
|
||||
@@ -2417,8 +2538,13 @@ class PrescriptionOrderLogic
|
||||
$payOrder->order_type = $orderType;
|
||||
$payOrder->amount = $amount;
|
||||
$payOrder->status = 5; // 待审核
|
||||
$payOrder->payment_method = 'fubei'; // 补齐支付单按「付呗」记账
|
||||
$payOrder->create_type = 'fubei';
|
||||
if ($isExpressCod) {
|
||||
// 快递代收:payment_method 留空,审核通过/到账后再写;用 create_type 标记
|
||||
$payOrder->create_type = 'express_cod';
|
||||
} else {
|
||||
$payOrder->payment_method = 'fubei'; // 补齐支付单按「付呗」记账
|
||||
$payOrder->create_type = 'fubei';
|
||||
}
|
||||
$payOrder->payment_time = null; // 审核通过后再设置支付时间
|
||||
$payOrder->remark = $remark;
|
||||
|
||||
@@ -3098,6 +3224,207 @@ class PrescriptionOrderLogic
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:处方药材明细(主方/辅方分行,与处方笺一致)
|
||||
*
|
||||
* @param array<string, mixed> $rx
|
||||
*/
|
||||
public static function formatPrescriptionHerbsForExport(array $rx): string
|
||||
{
|
||||
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
|
||||
$formatList = static function (array $herbs): string {
|
||||
$parts = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!\is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($h['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$parts[] = $name . ' ' . self::formatExportDosageNumber((float) ($h['dosage'] ?? 0)) . 'g';
|
||||
}
|
||||
|
||||
return implode('、', $parts);
|
||||
};
|
||||
|
||||
$sections = [];
|
||||
$mainText = $formatList($mainHerbs);
|
||||
if ($mainText !== '') {
|
||||
$sections[] = '主方:' . $mainText;
|
||||
}
|
||||
$auxText = $formatList($auxHerbs);
|
||||
if ($auxText !== '') {
|
||||
$sections[] = '辅方:' . $auxText;
|
||||
}
|
||||
|
||||
return implode("\n", $sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:主方/辅方服用方式(与前端 buildUsageSegmentText / 处方笺同口径)
|
||||
*
|
||||
* @param array<string, mixed> $usage
|
||||
*/
|
||||
public static function formatUsageSegmentForExport(
|
||||
array $usage,
|
||||
string $prescriptionType = '浓缩水丸',
|
||||
string $fallbackWay = '',
|
||||
string $fallbackTime = ''
|
||||
): string {
|
||||
$pt = trim($prescriptionType) !== '' ? trim($prescriptionType) : '浓缩水丸';
|
||||
$times = (int) ($usage['times_per_day'] ?? 0);
|
||||
if ($times <= 0) {
|
||||
$times = 3;
|
||||
}
|
||||
$amount = isset($usage['dosage_amount']) && $usage['dosage_amount'] !== '' && $usage['dosage_amount'] !== null
|
||||
? (float) $usage['dosage_amount']
|
||||
: 10.0;
|
||||
$unit = trim((string) ($usage['usage_dosage_unit'] ?? ($usage['dosage_unit'] ?? '')));
|
||||
if ($unit === '') {
|
||||
$unit = $pt === '饮片' ? 'ml' : 'g';
|
||||
}
|
||||
$usageWay = trim((string) ($usage['usage_way'] ?? ''));
|
||||
if ($usageWay === '') {
|
||||
$usageWay = $fallbackWay !== '' ? $fallbackWay : '温水送服';
|
||||
}
|
||||
$usageTime = trim((string) ($usage['usage_time'] ?? ''));
|
||||
if ($usageTime === '') {
|
||||
$usageTime = $fallbackTime;
|
||||
}
|
||||
|
||||
$seg = ['每天' . $times . '次'];
|
||||
if ($pt === '浓缩水丸') {
|
||||
$bags = (int) ($usage['dosage_bag_count'] ?? 0);
|
||||
if ($bags <= 0) {
|
||||
$bags = 1;
|
||||
}
|
||||
$seg[] = '一次' . $bags . '袋';
|
||||
$seg[] = '每袋' . self::formatExportDosageNumber($amount) . $unit;
|
||||
} else {
|
||||
$seg[] = '一次' . self::formatExportDosageNumber($amount) . $unit;
|
||||
}
|
||||
$seg[] = $usageWay;
|
||||
if ($usageTime !== '') {
|
||||
$seg[] = $usageTime;
|
||||
}
|
||||
|
||||
return implode(', ', $seg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:辅方用法 JSON 规范化(与前端 normalizeSlipAuxUsageForm 默认值一致)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeAuxUsageForExport($raw, string $prescriptionType): array
|
||||
{
|
||||
$pt = trim($prescriptionType) !== '' ? trim($prescriptionType) : '浓缩水丸';
|
||||
if ($pt === '饮片') {
|
||||
$base = [
|
||||
'dosage_amount' => 50.0,
|
||||
'dosage_bag_count' => 1,
|
||||
'times_per_day' => 3,
|
||||
'usage_days' => 7,
|
||||
];
|
||||
} elseif ($pt === '浓缩水丸') {
|
||||
$base = [
|
||||
'dosage_amount' => 5.0,
|
||||
'dosage_bag_count' => 1,
|
||||
'times_per_day' => 3,
|
||||
'usage_days' => 7,
|
||||
];
|
||||
} else {
|
||||
$base = [
|
||||
'dosage_amount' => 1.0,
|
||||
'dosage_bag_count' => 1,
|
||||
'times_per_day' => 3,
|
||||
'usage_days' => 7,
|
||||
];
|
||||
}
|
||||
|
||||
if (\is_string($raw) && $raw !== '') {
|
||||
$decoded = json_decode($raw, true);
|
||||
$raw = \is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
if (!\is_array($raw)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return [
|
||||
'dosage_amount' => isset($raw['dosage_amount']) && $raw['dosage_amount'] !== '' && $raw['dosage_amount'] !== null
|
||||
? (float) $raw['dosage_amount']
|
||||
: $base['dosage_amount'],
|
||||
'dosage_bag_count' => (int) ($raw['dosage_bag_count'] ?? 0) > 0
|
||||
? (int) $raw['dosage_bag_count']
|
||||
: $base['dosage_bag_count'],
|
||||
'times_per_day' => (int) ($raw['times_per_day'] ?? 0) > 0
|
||||
? (int) $raw['times_per_day']
|
||||
: $base['times_per_day'],
|
||||
'usage_days' => (int) ($raw['usage_days'] ?? 0) > 0
|
||||
? (int) $raw['usage_days']
|
||||
: $base['usage_days'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private static function splitPrescriptionHerbsFromRx(array $rx): array
|
||||
{
|
||||
$herbs = $rx['herbs'] ?? null;
|
||||
if (\is_string($herbs) && $herbs !== '') {
|
||||
$decoded = json_decode($herbs, true);
|
||||
$herbs = \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!\is_array($herbs)) {
|
||||
$herbs = [];
|
||||
}
|
||||
|
||||
$mainHerbs = [];
|
||||
$auxHerbs = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!\is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
if (((string) ($h['formula_type'] ?? '')) === '辅方') {
|
||||
$auxHerbs[] = $h;
|
||||
} else {
|
||||
$mainHerbs[] = $h;
|
||||
}
|
||||
}
|
||||
|
||||
return [$mainHerbs, $auxHerbs];
|
||||
}
|
||||
|
||||
private static function formatExportDosageNumber(float $dosage): string
|
||||
{
|
||||
if (floor($dosage) === $dosage) {
|
||||
return (string) (int) $dosage;
|
||||
}
|
||||
|
||||
return rtrim(rtrim(number_format($dosage, 4, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:主方/辅方开立天数(与详情侧栏「处方开立」同口径:主方取处方 usage_days,辅方取 aux_usage.usage_days)
|
||||
* 订单服用天数单独导出在 export_medication_days 列,不在此混用。
|
||||
*
|
||||
* @param array<string, mixed> $rx
|
||||
* @param array<string, mixed>|null $auxUsage
|
||||
*/
|
||||
private static function resolveExportUsageDays(array $rx, ?array $auxUsage, bool $isAux): string
|
||||
{
|
||||
if ($isAux) {
|
||||
$days = (int) ($auxUsage['usage_days'] ?? 0);
|
||||
|
||||
return $days > 0 ? (string) $days : '';
|
||||
}
|
||||
$days = (int) ($rx['usage_days'] ?? 0);
|
||||
|
||||
return $days > 0 ? (string) $days : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出列:挂号表渠道来源展示(与 AppointmentLists channel_source_desc 同字典口径)
|
||||
*
|
||||
@@ -3152,6 +3479,197 @@ class PrescriptionOrderLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:统一解析时间(支持 Unix 秒/毫秒与 Y-m-d H:i:s 字符串,与前端 formatOrderTime 同口径)
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
private static function formatDateTimeForExport($value, string $format = 'Y-m-d H:i'): string
|
||||
{
|
||||
if ($value === null || $value === '' || $value === false) {
|
||||
return '';
|
||||
}
|
||||
if (\is_int($value) || \is_float($value)) {
|
||||
$ts = (int) $value;
|
||||
if ($ts > 9999999999) {
|
||||
$ts = (int) floor($ts / 1000);
|
||||
}
|
||||
|
||||
return $ts > 0 ? date($format, $ts) : '';
|
||||
}
|
||||
$str = trim((string) $value);
|
||||
if ($str === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d+$/', $str)) {
|
||||
$ts = (int) $str;
|
||||
if ($ts > 9999999999) {
|
||||
$ts = (int) floor($ts / 1000);
|
||||
}
|
||||
|
||||
return $ts > 0 ? date($format, $ts) : '';
|
||||
}
|
||||
if (str_contains($str, '-') || str_contains($str, '/')) {
|
||||
$ts = strtotime($str);
|
||||
|
||||
return $ts !== false ? date($format, $ts) : $str;
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:支付单来源/方式(与前端 formatPayOrderSource 同口径)
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private static function formatPayOrderSourceForExport(array $row): string
|
||||
{
|
||||
$createType = trim((string) ($row['create_type'] ?? ''));
|
||||
if ($createType === 'wechat_work') {
|
||||
return '企业微信对外收款';
|
||||
}
|
||||
if ($createType === 'fubei') {
|
||||
return '付呗';
|
||||
}
|
||||
if ($createType === 'express_cod') {
|
||||
return '快递代收';
|
||||
}
|
||||
$paymentMethod = trim((string) ($row['payment_method'] ?? ''));
|
||||
$methodMap = [
|
||||
'alipay' => '支付宝',
|
||||
'wechat' => '微信',
|
||||
'wechat_work' => '企业微信',
|
||||
'fubei' => '付呗',
|
||||
'express_cod' => '快递代收',
|
||||
'manual' => '手动确认到账',
|
||||
];
|
||||
if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) {
|
||||
return $methodMap[$paymentMethod];
|
||||
}
|
||||
|
||||
return '普通订单';
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:单条关联收款可读文本(两行结构,便于 Excel 自动换行阅读)
|
||||
*
|
||||
* @param array<string, mixed> $pay
|
||||
* @param array<int|string, string> $creatorNames
|
||||
*/
|
||||
private static function formatLinkedPayRecordLineForExport(array $pay, array $creatorNames, int $index = 1): string
|
||||
{
|
||||
$typeMap = [
|
||||
1 => '挂号费',
|
||||
2 => '问诊费',
|
||||
3 => '药品费用',
|
||||
4 => '首付费用',
|
||||
5 => '尾款费用',
|
||||
6 => '其他费用',
|
||||
7 => '全部费用',
|
||||
8 => '驼奶费用',
|
||||
];
|
||||
$statusMap = [
|
||||
1 => '待支付',
|
||||
2 => '已支付',
|
||||
3 => '已取消',
|
||||
4 => '已退款',
|
||||
5 => '待审核',
|
||||
];
|
||||
$orderNo = trim((string) ($pay['order_no'] ?? ''));
|
||||
$amount = number_format(round((float) ($pay['amount'] ?? 0), 2), 2, '.', '');
|
||||
$typeDesc = $typeMap[(int) ($pay['order_type'] ?? 0)] ?? '—';
|
||||
$statusDesc = $statusMap[(int) ($pay['status'] ?? 0)] ?? '—';
|
||||
$source = self::formatPayOrderSourceForExport($pay);
|
||||
$cid = (int) ($pay['creator_id'] ?? 0);
|
||||
$creator = $cid > 0 ? (string) ($creatorNames[$cid] ?? '') : '—';
|
||||
$timeStr = self::formatDateTimeForExport($pay['create_time'] ?? '') ?: '—';
|
||||
|
||||
$line1 = sprintf('[%d] %s ¥%s %s %s', $index, $orderNo, $amount, $typeDesc, $statusDesc);
|
||||
$line2 = sprintf(' %s %s %s', $source, $creator, $timeStr);
|
||||
|
||||
return $line1 . "\n" . $line2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:批量解析业务订单关联收款记录(与详情 linked_pay_orders 同口径:status ∈ {2,4,5})
|
||||
*
|
||||
* @param int[] $poIds
|
||||
*
|
||||
* @return array<int, string> prescription_order_id => 多笔以空行分隔的可读文本
|
||||
*/
|
||||
private static function batchLinkedPayRecordsExportTextByPoIds(array $poIds): array
|
||||
{
|
||||
$poIds = array_values(array_filter(array_unique(array_map('intval', $poIds)), static fn (int $id): bool => $id > 0));
|
||||
if ($poIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linksByPo = [];
|
||||
$allPayIds = [];
|
||||
$linkRows = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds)
|
||||
->field(['prescription_order_id', 'pay_order_id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($linkRows as $l) {
|
||||
$poid = (int) ($l['prescription_order_id'] ?? 0);
|
||||
$payId = (int) ($l['pay_order_id'] ?? 0);
|
||||
if ($poid <= 0 || $payId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$linksByPo[$poid][] = $payId;
|
||||
$allPayIds[$payId] = true;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($poIds as $poid) {
|
||||
$out[$poid] = '';
|
||||
}
|
||||
if ($allPayIds === []) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$payRows = Order::whereIn('id', array_keys($allPayIds))
|
||||
->whereNull('delete_time')
|
||||
->whereIn('status', [2, 4, 5])
|
||||
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'payment_method', 'create_type'])
|
||||
->select()
|
||||
->toArray();
|
||||
$payById = [];
|
||||
$creatorIds = [];
|
||||
foreach ($payRows as $r) {
|
||||
$id = (int) ($r['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$payById[$id] = $r;
|
||||
$cid = (int) ($r['creator_id'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
$creatorIds[$cid] = true;
|
||||
}
|
||||
}
|
||||
$creatorNames = $creatorIds !== []
|
||||
? Admin::whereIn('id', array_keys($creatorIds))->column('name', 'id')
|
||||
: [];
|
||||
|
||||
foreach ($poIds as $poid) {
|
||||
$lines = [];
|
||||
$seq = 0;
|
||||
foreach ($linksByPo[$poid] ?? [] as $payId) {
|
||||
$pay = $payById[$payId] ?? null;
|
||||
if (!\is_array($pay)) {
|
||||
continue;
|
||||
}
|
||||
++$seq;
|
||||
$lines[] = self::formatLinkedPayRecordLineForExport($pay, $creatorNames, $seq);
|
||||
}
|
||||
$out[$poid] = implode("\n\n", $lines);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单列表导出:写入与 PrescriptionOrderLists::setExcelFields 对应的字段(export=2 时由列表类调用)
|
||||
* 「挂号渠道来源」取值与详情/列表解析一致:处方 appointment_id → 否则诊单下 MAX(挂号.id);业绩抽屉带渠道时与列表同源高亮。
|
||||
@@ -3203,7 +3721,12 @@ class PrescriptionOrderLogic
|
||||
$rxById = [];
|
||||
if ($rxIdList !== []) {
|
||||
$rxRows = Prescription::whereIn('id', $rxIdList)->whereNull('delete_time')
|
||||
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id', 'prescription_name', 'aux_usage', 'herbs', 'creator_id'])
|
||||
->field([
|
||||
'id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id',
|
||||
'prescription_name', 'aux_usage', 'herbs', 'creator_id',
|
||||
'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'times_per_day', 'usage_days',
|
||||
'usage_way', 'usage_time',
|
||||
])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rxRows as $xr) {
|
||||
@@ -3383,6 +3906,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
|
||||
$assistantDeptCache = [];
|
||||
$linkedPayExportByPo = self::batchLinkedPayRecordsExportTextByPoIds($poIds);
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$poId = (int) ($item['id'] ?? 0);
|
||||
@@ -3436,6 +3960,38 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$item['export_prescription_name'] = implode(' ', $rxNameParts);
|
||||
|
||||
$rxArr = \is_array($rx) ? $rx : [];
|
||||
$rxType = trim((string) ($rxArr['prescription_type'] ?? '')) ?: '浓缩水丸';
|
||||
$item['export_prescription_herbs'] = self::formatPrescriptionHerbsForExport($rxArr);
|
||||
$item['export_main_usage'] = $rxArr !== []
|
||||
? self::formatUsageSegmentForExport($rxArr, $rxType)
|
||||
: '';
|
||||
[, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rxArr);
|
||||
$auxUsageNorm = $auxHerbs !== []
|
||||
? self::normalizeAuxUsageForExport($rxArr['aux_usage'] ?? null, $rxType)
|
||||
: null;
|
||||
if ($auxHerbs !== [] && $auxUsageNorm !== null) {
|
||||
$item['export_aux_usage'] = self::formatUsageSegmentForExport(
|
||||
[
|
||||
'dosage_amount' => $auxUsageNorm['dosage_amount'],
|
||||
'dosage_bag_count' => $auxUsageNorm['dosage_bag_count'],
|
||||
'times_per_day' => $auxUsageNorm['times_per_day'],
|
||||
'usage_dosage_unit' => $rxArr['dosage_unit'] ?? '',
|
||||
'usage_way' => $rxArr['usage_way'] ?? '',
|
||||
'usage_time' => $rxArr['usage_time'] ?? '',
|
||||
],
|
||||
$rxType,
|
||||
(string) ($rxArr['usage_way'] ?? ''),
|
||||
(string) ($rxArr['usage_time'] ?? '')
|
||||
);
|
||||
} else {
|
||||
$item['export_aux_usage'] = '';
|
||||
}
|
||||
$item['export_main_usage_days'] = self::resolveExportUsageDays($rxArr, $auxUsageNorm, false);
|
||||
$item['export_aux_usage_days'] = $auxHerbs !== []
|
||||
? self::resolveExportUsageDays($rxArr, $auxUsageNorm, true)
|
||||
: '';
|
||||
|
||||
$item['export_service_package'] = self::formatServicePackageForExport(
|
||||
$item['service_package'] ?? '',
|
||||
$packageNameByValue
|
||||
@@ -3455,6 +4011,7 @@ class PrescriptionOrderLogic
|
||||
$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_linked_pay_records'] = $linkedPayExportByPo[$poId] ?? '';
|
||||
$refundStored = round((float) ($item['refund_amount'] ?? 0), 2);
|
||||
$item['export_refund_amount'] = $refundStored > 0
|
||||
? number_format($refundStored, 2, '.', '')
|
||||
@@ -3605,27 +4162,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$doctorId = (int) ($rx['creator_id'] ?? 0);
|
||||
|
||||
$herbs = $rx['herbs'] ?? null;
|
||||
if (\is_string($herbs) && $herbs !== '') {
|
||||
$decoded = json_decode($herbs, true);
|
||||
$herbs = \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!\is_array($herbs)) {
|
||||
$herbs = [];
|
||||
}
|
||||
|
||||
$mainHerbs = [];
|
||||
$auxHerbs = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!\is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
if (((string) ($h['formula_type'] ?? '')) === '辅方') {
|
||||
$auxHerbs[] = $h;
|
||||
} else {
|
||||
$mainHerbs[] = $h;
|
||||
}
|
||||
}
|
||||
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
|
||||
|
||||
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic): string {
|
||||
if ($hs === []) {
|
||||
@@ -4207,6 +4744,112 @@ class PrescriptionOrderLogic
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量将处方业务订单「创建人(医助归属)」改派给其他医助。
|
||||
* 仅改写 creator_id(业务归属/医助筛选口径),逐单记操作日志;与支付单批量改派口径一致。
|
||||
*
|
||||
* @param int[] $orderIds
|
||||
* @param int $assistantAdminId 目标医助 admin_id(须含医助角色)
|
||||
* @return array{success:int, fail:int, errors:string[], per_log:array<int,array{order_id:int,summary:string}>}|false
|
||||
*/
|
||||
public static function batchAssignAssistant(array $orderIds, int $assistantAdminId, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$assistantAdminId = (int) $assistantAdminId;
|
||||
if ($assistantAdminId <= 0) {
|
||||
self::$error = '请选择医助';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 目标须为医助角色(与支付单批量改派一致)
|
||||
$assistantRoleId = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
||||
if (
|
||||
\app\common\model\auth\AdminRole::where('admin_id', $assistantAdminId)
|
||||
->where('role_id', $assistantRoleId)
|
||||
->count() === 0
|
||||
) {
|
||||
self::$error = '目标账号不是医助角色';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $orderIds),
|
||||
static fn (int $id) => $id > 0
|
||||
)));
|
||||
if ($ids === []) {
|
||||
self::$error = '请选择订单';
|
||||
|
||||
return false;
|
||||
}
|
||||
if (count($ids) > 200) {
|
||||
self::$error = '单次最多改派200单';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetName = (string) Admin::where('id', $assistantAdminId)->whereNull('delete_time')->value('name');
|
||||
if ($targetName === '') {
|
||||
$targetName = (string) $assistantAdminId;
|
||||
}
|
||||
|
||||
$perLog = [];
|
||||
$errors = [];
|
||||
$success = 0;
|
||||
foreach ($ids as $oid) {
|
||||
$order = PrescriptionOrder::where('id', $oid)->whereNull('delete_time')->find();
|
||||
if (! $order) {
|
||||
$errors[] = "订单{$oid}不存在或已删除";
|
||||
continue;
|
||||
}
|
||||
$label = (string) ($order->order_no ?: $oid);
|
||||
if (! self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
$errors[] = "订单{$label}无操作权限";
|
||||
continue;
|
||||
}
|
||||
$oldCreatorId = (int) $order->creator_id;
|
||||
if ($oldCreatorId === $assistantAdminId) {
|
||||
$errors[] = "订单{$label}创建人已是该医助,已跳过";
|
||||
continue;
|
||||
}
|
||||
$oldName = $oldCreatorId > 0
|
||||
? (string) Admin::where('id', $oldCreatorId)->whereNull('delete_time')->value('name')
|
||||
: '';
|
||||
if ($oldName === '' && $oldCreatorId > 0) {
|
||||
$oldName = (string) $oldCreatorId;
|
||||
} elseif ($oldName === '') {
|
||||
$oldName = '—';
|
||||
}
|
||||
|
||||
$order->creator_id = $assistantAdminId;
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "订单{$label}保存失败";
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = '创建人(医助)由「' . $oldName . '」改派为「' . $targetName . '」';
|
||||
self::writeLog($oid, $adminId, $adminInfo, 'assign_assistant', $summary);
|
||||
$perLog[] = ['order_id' => $oid, 'summary' => $summary];
|
||||
$success++;
|
||||
}
|
||||
|
||||
if ($success === 0) {
|
||||
self::$error = $errors !== [] ? implode(';', $errors) : '未能改派任何订单';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'fail' => count($errors),
|
||||
'errors' => $errors,
|
||||
'per_log' => $perLog,
|
||||
];
|
||||
}
|
||||
|
||||
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
|
||||
{
|
||||
$adminName = $adminInfo['name'] ?? '';
|
||||
@@ -4238,4 +4881,175 @@ class PrescriptionOrderLogic
|
||||
{
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_patient', $summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
self::setError('订单不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::setError('无权限操作');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status === 4) {
|
||||
self::setError('已取消的订单不可修改');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) ($order->prescription_id ?? 0);
|
||||
if ($rxId <= 0) {
|
||||
self::setError('该订单未关联处方');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
||||
if (!$rx) {
|
||||
self::setError('处方不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!PrescriptionLogic::canViewPrescription($rx, $adminId, $adminInfo)) {
|
||||
self::setError('无权限修改此处方');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
||||
$usageDays = (int) ($params['usage_days'] ?? 0);
|
||||
$medDays = (int) ($params['medication_days'] ?? 0);
|
||||
if ($timesPerDay < 1 || $timesPerDay > 6) {
|
||||
self::setError('主方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($usageDays < 1 || $usageDays > 999) {
|
||||
self::setError('主方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($medDays < 1 || $medDays > 999) {
|
||||
self::setError('订单服用天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasAux = self::prescriptionHasAuxFormula($rx);
|
||||
$auxTimesPerDay = null;
|
||||
$auxUsageDays = null;
|
||||
if ($hasAux) {
|
||||
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
||||
self::setError('含辅方处方须填写辅方服用参数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
||||
$auxUsageDays = (int) $params['aux_usage_days'];
|
||||
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
|
||||
self::setError('辅方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
|
||||
self::setError('辅方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$oldTimes = (int) ($rx->times_per_day ?? 0);
|
||||
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
||||
$oldMedDays = (int) ($order->medication_days ?? 0);
|
||||
$oldAuxUsage = $rx->aux_usage;
|
||||
if (is_string($oldAuxUsage)) {
|
||||
$decoded = json_decode($oldAuxUsage, true);
|
||||
$oldAuxUsage = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!is_array($oldAuxUsage)) {
|
||||
$oldAuxUsage = [];
|
||||
}
|
||||
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
||||
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
|
||||
|
||||
try {
|
||||
$rxUpdates = [
|
||||
'times_per_day' => $timesPerDay,
|
||||
'usage_days' => $usageDays,
|
||||
];
|
||||
if ($hasAux) {
|
||||
$auxUsage = $oldAuxUsage;
|
||||
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
||||
$auxUsage['usage_days'] = $auxUsageDays;
|
||||
$rxUpdates['aux_usage'] = $auxUsage;
|
||||
}
|
||||
$rx->save($rxUpdates);
|
||||
|
||||
$order->medication_days = $medDays;
|
||||
$order->save();
|
||||
|
||||
$parts = [
|
||||
sprintf(
|
||||
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldTimes > 0 ? $oldTimes : 0,
|
||||
$oldUsageDays > 0 ? $oldUsageDays : 0,
|
||||
$timesPerDay,
|
||||
$usageDays
|
||||
),
|
||||
];
|
||||
if ($hasAux) {
|
||||
$parts[] = sprintf(
|
||||
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
|
||||
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
|
||||
$auxTimesPerDay,
|
||||
$auxUsageDays
|
||||
);
|
||||
}
|
||||
$parts[] = sprintf(
|
||||
'订单设置 %d天 → %d天',
|
||||
$oldMedDays > 0 ? $oldMedDays : 0,
|
||||
$medDays
|
||||
);
|
||||
$summary = '服用参数:' . implode(';', $parts);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
|
||||
*/
|
||||
private static function prescriptionHasAuxFormula(Prescription $rx): bool
|
||||
{
|
||||
$herbs = $rx->herbs;
|
||||
if (is_string($herbs)) {
|
||||
$decoded = json_decode($herbs, true);
|
||||
$herbs = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!is_array($herbs)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($herbs as $h) {
|
||||
if (is_array($h) && (string) ($h['formula_type'] ?? '') === '辅方') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class DiagnosisValidate extends BaseValidate
|
||||
'gender' => 'require|in:0,1',
|
||||
'age' => 'require|number|between:0,150',
|
||||
'diagnosis_type' => 'require',
|
||||
'local_hospital_name' => 'require|max:255',
|
||||
'status' => 'in:0,1',
|
||||
'show_card' => 'in:0,1',
|
||||
'create_source' => 'max:32',
|
||||
@@ -43,6 +44,7 @@ class DiagnosisValidate extends BaseValidate
|
||||
'end_date' => 'date|checkDateRange',
|
||||
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
||||
'tracking_content' => 'require|length:1,1000',
|
||||
'revisit_slot_start_offset' => 'integer|between:0,20',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
@@ -50,6 +52,7 @@ class DiagnosisValidate extends BaseValidate
|
||||
'patient_name.require' => '请输入患者姓名',
|
||||
'patient_name.length' => '患者姓名长度须在1-50位字符',
|
||||
'id_card.length' => '身份证号长度不正确',
|
||||
'id_card.require' => '请输入身份证号',
|
||||
'phone.require' => '请输入手机号',
|
||||
'phone.mobile' => '手机号格式不正确',
|
||||
'gender.require' => '请选择性别',
|
||||
@@ -58,6 +61,8 @@ class DiagnosisValidate extends BaseValidate
|
||||
'age.number' => '年龄必须为数字',
|
||||
'age.between' => '年龄范围0-150',
|
||||
'diagnosis_type.require' => '请选择诊断类型',
|
||||
'local_hospital_name.require' => '请输入当地就诊医院名称',
|
||||
'local_hospital_name.max' => '当地就诊医院名称最多255个字符',
|
||||
'status.in' => '状态参数错误',
|
||||
'create_source.max' => '渠道来源长度不能超过32个字符',
|
||||
'current_medications.max' => '在用药物最多2000个字符',
|
||||
@@ -135,6 +140,13 @@ class DiagnosisValidate extends BaseValidate
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
/** 业务订单 tab:设置复诊接诊率统计起始偏移 */
|
||||
public function sceneSetRevisitSlotStartOffset()
|
||||
{
|
||||
return $this->only(['id', 'revisit_slot_start_offset'])
|
||||
->append('revisit_slot_start_offset', 'require|integer|between:0,20');
|
||||
}
|
||||
|
||||
protected function checkDiagnosis($value)
|
||||
{
|
||||
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||
|
||||
@@ -32,6 +32,11 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'remark_assistant' => 'max:500',
|
||||
'action' => 'require|in:approve,reject',
|
||||
'remark' => 'max:500',
|
||||
'summary' => 'require|max:500',
|
||||
'prescription_audit_status' => 'in:0,1,2',
|
||||
'payment_slip_audit_status' => 'in:0,1,2',
|
||||
'prescription_audit_remark' => 'max:500',
|
||||
'payment_slip_audit_remark' => 'max:500',
|
||||
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
|
||||
'reason' => 'require|max:500',
|
||||
'refund_amount' => 'float|egt:0',
|
||||
@@ -74,6 +79,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'withdraw' => ['id'],
|
||||
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
|
||||
'logs' => ['id'],
|
||||
'addLog' => ['id', 'summary'],
|
||||
'paidPayOrders' => ['diagnosis_id'],
|
||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||
@@ -83,6 +89,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||
'updateAmount' => ['id', 'amount'],
|
||||
'setShipMode' => ['id', 'ship_mode'],
|
||||
];
|
||||
@@ -91,6 +98,17 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
{
|
||||
return $this->only(['id', 'amount'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('amount', 'require|float|gt:0');
|
||||
->append('amount', 'require|float|egt:0');
|
||||
}
|
||||
|
||||
public function patchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('times_per_day', 'require|integer|between:1,6')
|
||||
->append('usage_days', 'require|integer|between:1,999')
|
||||
->append('medication_days', 'require|integer|between:1,999')
|
||||
->append('aux_times_per_day', 'integer|between:1,6')
|
||||
->append('aux_usage_days', 'integer|between:1,999');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派(每天定时执行)
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:auto-assign-pending # 正式执行
|
||||
* php think tcm:auto-assign-pending --dry-run # 只演练输出分配计划,不写库
|
||||
*
|
||||
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
|
||||
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
|
||||
*
|
||||
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
|
||||
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
|
||||
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
||||
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
|
||||
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
|
||||
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
|
||||
*
|
||||
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
|
||||
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
||||
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
||||
* 第一轮 3+7+5=15 条;剩余 5 条进第二轮:>70% 再各 1 条(3 条),余 2 条给 60%~70% 档前 2 人。
|
||||
*
|
||||
* 日上限跨执行累计:同一天重复执行命令时,会先从 tcm_diagnosis_auto_assign_log 扣减当日已自动分配数,不会超额。
|
||||
*
|
||||
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
|
||||
* 成功分配同时写 tcm_diagnosis_assign_log(is_inherit=0,计入次月接诊率分母),与手动指派同口径。
|
||||
*/
|
||||
class AutoAssignPendingDiagnosis extends Command
|
||||
{
|
||||
/** 档位标识 */
|
||||
private const TIER_GT70 = 'gt70';
|
||||
private const TIER_60_70 = '60_70';
|
||||
private const TIER_50_60 = '50_60';
|
||||
|
||||
/** 各档位当日每人分配上限(按优先级排列,先高档后低档) */
|
||||
private const TIER_DAILY_CAPS = [
|
||||
self::TIER_GT70 => 3,
|
||||
self::TIER_60_70 => 2,
|
||||
self::TIER_50_60 => 1,
|
||||
];
|
||||
|
||||
private const TIER_LABELS = [
|
||||
self::TIER_GT70 => '>70%',
|
||||
self::TIER_60_70 => '60%~70%',
|
||||
self::TIER_50_60 => '50%~60%',
|
||||
];
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:auto-assign-pending')
|
||||
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$now = time();
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$runDate = date('Y-m-d', $now);
|
||||
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
|
||||
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
|
||||
date('Y-m-d H:i:s', $now),
|
||||
$batchNo,
|
||||
$statMonth,
|
||||
$dryRun ? '(演练模式,不写库)'
|
||||
: ''
|
||||
));
|
||||
|
||||
try {
|
||||
// 1. 上月二诊复诊接诊率 → 医助分档
|
||||
$tiers = $this->buildAssistantTiers($statMonth);
|
||||
$tierTotal = array_sum(array_map('count', $tiers));
|
||||
$output->writeln(sprintf(
|
||||
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
|
||||
\count($tiers[self::TIER_GT70]),
|
||||
\count($tiers[self::TIER_60_70]),
|
||||
\count($tiers[self::TIER_50_60])
|
||||
));
|
||||
|
||||
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
||||
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
||||
|
||||
// 3. 待指派池:与「待分配医助」Tab 同口径(assistant_id 空/0 + 当月有业务订单),先到先分
|
||||
[$pool, $ineligible] = $this->fetchPendingPool($now);
|
||||
$output->writeln(sprintf('待指派池:符合条件 %d 条,不符合条件 %d 条', \count($pool), \count($ineligible)));
|
||||
|
||||
$logRows = [];
|
||||
|
||||
// 不符合条件的待指派诊单:不分配,逐条记录原因
|
||||
foreach ($ineligible as $item) {
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diag'], [
|
||||
'action' => 0,
|
||||
'reason' => $item['reason'],
|
||||
], $now);
|
||||
}
|
||||
|
||||
if ($pool === []) {
|
||||
$this->flushLogs($logRows, $dryRun, $output);
|
||||
$output->writeln('待指派池为空,本次无需分配。');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($tierTotal === 0) {
|
||||
$reason = sprintf('未分配:上月(%s)无二诊复诊接诊率≥50%%的医助,本批次不执行分配', $statMonth);
|
||||
foreach ($pool as $diag) {
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||
'action' => 0,
|
||||
'reason' => $reason,
|
||||
], $now);
|
||||
}
|
||||
$this->flushLogs($logRows, $dryRun, $output);
|
||||
$output->writeln($reason);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 轮询排分配计划
|
||||
[$plan, $leftover] = $this->buildAssignPlan($pool, $tiers, $remaining);
|
||||
|
||||
// 5. 执行计划(逐条事务 + 行锁复核,落库诊单/指派日志/自动指派日志)
|
||||
$assigned = 0;
|
||||
$skipped = 0;
|
||||
foreach ($plan as $item) {
|
||||
if ($dryRun) {
|
||||
$assigned++;
|
||||
$output->writeln(sprintf(
|
||||
'[演练] 诊单#%d(%s) → %s(%s,第%d轮,当日第%d/%d条)',
|
||||
$item['diagnosis']['id'],
|
||||
(string) $item['diagnosis']['patient_name'],
|
||||
$item['assistant']['name'],
|
||||
self::TIER_LABELS[$item['tier']],
|
||||
$item['round'],
|
||||
$item['day_seq'],
|
||||
self::TIER_DAILY_CAPS[$item['tier']]
|
||||
));
|
||||
continue;
|
||||
}
|
||||
$ok = $this->applyAssignment($item, $now);
|
||||
if ($ok) {
|
||||
$assigned++;
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||
'action' => 1,
|
||||
'assistant_id' => $item['assistant']['id'],
|
||||
'assistant_name' => $item['assistant']['name'],
|
||||
'tier' => $item['tier'],
|
||||
'visit2_rate' => $item['assistant']['rate'],
|
||||
'round_no' => $item['round'],
|
||||
'reason' => sprintf(
|
||||
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
|
||||
$item['assistant']['name'],
|
||||
$item['assistant']['id'],
|
||||
$statMonth,
|
||||
$item['assistant']['rate'],
|
||||
self::TIER_LABELS[$item['tier']],
|
||||
self::TIER_DAILY_CAPS[$item['tier']],
|
||||
$item['round'],
|
||||
$item['day_seq']
|
||||
),
|
||||
], $now);
|
||||
} else {
|
||||
$skipped++;
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||
'action' => 0,
|
||||
'reason' => '未分配:执行时诊单已被指派给其他医助(并发/人工抢先),本次跳过',
|
||||
], $now);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 额度用尽后剩余的待指派诊单:不分配,记录原因
|
||||
foreach ($leftover as $diag) {
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||
'action' => 0,
|
||||
'reason' => '未分配:各档位医助当日剩余额度已用尽(>70%每人3条、60%~70%每人2条、50%~60%每人1条),顺延至下次执行',
|
||||
], $now);
|
||||
}
|
||||
|
||||
$this->flushLogs($logRows, $dryRun, $output);
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'执行完成。计划分配: %d, 实际分配: %d, 并发跳过: %d, 额度不足未分: %d, 不符合条件: %d',
|
||||
\count($plan),
|
||||
$assigned,
|
||||
$skipped,
|
||||
\count($leftover),
|
||||
\count($ineligible)
|
||||
));
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('待分配诊单自动指派异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
$output->error('执行异常: ' . $e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
|
||||
*
|
||||
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
|
||||
*/
|
||||
private function buildAssistantTiers(string $statMonth): array
|
||||
{
|
||||
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
|
||||
|
||||
/** @var list<array{id:int,name:string,rate:float}> $candidates */
|
||||
$candidates = [];
|
||||
foreach ($overview['rows'] ?? [] as $deptRow) {
|
||||
foreach ($deptRow['children'] ?? [] as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$rate = $row['visit2_rate'] ?? null;
|
||||
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
|
||||
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
||||
'rate' => (float) $rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($candidates === []) {
|
||||
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||
}
|
||||
|
||||
// 只分配给当前在职可用的医助账号(role_id=2 且未禁用未删除),
|
||||
// 且当前部门须在「二中心」子树内(统计月在二中心、后来调离的不再参与)
|
||||
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
if ($erDeptSet === []) {
|
||||
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||
}
|
||||
$activeIds = Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||
->join('admin_dept ad', 'a.id = ad.admin_id')
|
||||
->where('ar.role_id', 2)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->whereIn('ad.dept_id', array_keys($erDeptSet))
|
||||
->whereIn('a.id', array_column($candidates, 'id'))
|
||||
->group('a.id')
|
||||
->column('a.id');
|
||||
$activeSet = array_fill_keys(array_map('intval', $activeIds), true);
|
||||
|
||||
$tiers = [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||
foreach ($candidates as $c) {
|
||||
if (!isset($activeSet[$c['id']])) {
|
||||
continue;
|
||||
}
|
||||
if ($c['rate'] > 70.0) {
|
||||
$tiers[self::TIER_GT70][] = $c;
|
||||
} elseif ($c['rate'] > 60.0) {
|
||||
$tiers[self::TIER_60_70][] = $c;
|
||||
} else { // 50 <= rate <= 60
|
||||
$tiers[self::TIER_50_60][] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
// 档内按接诊率降序、id 升序,保证分配顺序确定可复现
|
||||
foreach ($tiers as &$list) {
|
||||
usort($list, static function (array $a, array $b): int {
|
||||
if ($a['rate'] !== $b['rate']) {
|
||||
return $b['rate'] <=> $a['rate'];
|
||||
}
|
||||
|
||||
return $a['id'] <=> $b['id'];
|
||||
});
|
||||
}
|
||||
unset($list);
|
||||
|
||||
return $tiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各医助当日剩余额度 = 档位日上限 - 当日已自动分配条数(同日重复执行不超额)。
|
||||
*
|
||||
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||
*
|
||||
* @return array<int, int> assistant_id => 剩余额度
|
||||
*/
|
||||
private function buildRemainingQuota(array $tiers, string $runDate): array
|
||||
{
|
||||
$usedToday = Db::name('tcm_diagnosis_auto_assign_log')
|
||||
->where('run_date', $runDate)
|
||||
->where('action', 1)
|
||||
->where('assistant_id', '>', 0)
|
||||
->group('assistant_id')
|
||||
->column('COUNT(*)', 'assistant_id');
|
||||
|
||||
$remaining = [];
|
||||
foreach (self::TIER_DAILY_CAPS as $tier => $cap) {
|
||||
foreach ($tiers[$tier] as $assistant) {
|
||||
$used = (int) ($usedToday[$assistant['id']] ?? 0);
|
||||
$remaining[$assistant['id']] = max(0, $cap - $used);
|
||||
}
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 待指派池:与后台「待分配医助」Tab 同口径 —— assistant_id 为空/0、未删除,
|
||||
* 且当月内存在业务订单(order.patient_id = 诊单 id,Tab 默认按当月过滤)。先到先分。
|
||||
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
|
||||
*
|
||||
* @return array{0: list<array<string,mixed>>, 1: list<array{diag:array<string,mixed>,reason:string}>}
|
||||
*/
|
||||
private function fetchPendingPool(int $now): array
|
||||
{
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereRaw('(assistant_id IS NULL OR assistant_id = 0)')
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'patient_name', 'phone', 'status', 'create_time'])
|
||||
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
// 当月存在业务订单的诊单集合(order.create_time 兼容整型时间戳与 datetime 字符串,与 DiagnosisLists 一致)
|
||||
$tStart = (int) strtotime(date('Y-m-01 00:00:00', $now));
|
||||
$tEnd = (int) strtotime(date('Y-m-t 23:59:59', $now));
|
||||
$dsStart = date('Y-m-d H:i:s', $tStart);
|
||||
$dsEnd = date('Y-m-d H:i:s', $tEnd);
|
||||
$hasOrderSet = [];
|
||||
foreach (array_chunk(array_column($rows, 'id'), 2000) as $chunk) {
|
||||
$ids = Db::name('order')
|
||||
->whereIn('patient_id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->where(static function ($q) use ($tStart, $tEnd, $dsStart, $dsEnd) {
|
||||
$q->whereBetween('create_time', [$tStart, $tEnd])
|
||||
->whereOr(static function ($q2) use ($dsStart, $dsEnd) {
|
||||
$q2->where('create_time', '>=', $dsStart)->where('create_time', '<=', $dsEnd);
|
||||
});
|
||||
})
|
||||
->group('patient_id')
|
||||
->column('patient_id');
|
||||
foreach ($ids as $id) {
|
||||
$hasOrderSet[(int) $id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$curMonth = date('Y-m', $now);
|
||||
$eligible = [];
|
||||
$ineligible = [];
|
||||
foreach ($rows as $r) {
|
||||
$did = (int) ($r['id'] ?? 0);
|
||||
if (!isset($hasOrderSet[$did])) {
|
||||
$ineligible[] = [
|
||||
'diag' => $r,
|
||||
'reason' => sprintf('未分配:诊单当月(%s)无业务订单,不在「待分配医助」列表范围内,不满足自动分配条件', $curMonth),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if ((int) ($r['status'] ?? 0) !== 1) {
|
||||
$ineligible[] = [
|
||||
'diag' => $r,
|
||||
'reason' => '未分配:诊单未启用(status≠1),不满足自动分配条件',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$eligible[] = $r;
|
||||
}
|
||||
|
||||
return [$eligible, $ineligible];
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询排分配计划:每轮内先 >70% 档每人 1 条,再 60%~70%,再 50%~60%;受各自当日剩余额度约束。
|
||||
*
|
||||
* @param list<array<string,mixed>> $pool 待指派诊单(先到先分)
|
||||
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||
* @param array<int, int> $remaining assistant_id => 剩余额度(会被消耗)
|
||||
*
|
||||
* @return array{
|
||||
* 0: list<array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>,
|
||||
* 1: list<array<string,mixed>>
|
||||
* } [分配计划, 额度用尽后剩余诊单]
|
||||
*/
|
||||
private function buildAssignPlan(array $pool, array $tiers, array $remaining): array
|
||||
{
|
||||
$plan = [];
|
||||
$poolIdx = 0;
|
||||
$poolCount = \count($pool);
|
||||
/** @var array<int, int> $daySeq 医助当日已排序号(含历史已用额度) */
|
||||
$daySeq = [];
|
||||
foreach ($remaining as $aid => $left) {
|
||||
// 起始序号 = 日上限 - 剩余额度(同日多次执行时序号衔接)
|
||||
$cap = 0;
|
||||
foreach (self::TIER_DAILY_CAPS as $tier => $tierCap) {
|
||||
foreach ($tiers[$tier] as $assistant) {
|
||||
if ($assistant['id'] === $aid) {
|
||||
$cap = $tierCap;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$daySeq[$aid] = $cap - $left;
|
||||
}
|
||||
|
||||
$round = 0;
|
||||
while ($poolIdx < $poolCount) {
|
||||
$round++;
|
||||
$assignedThisRound = 0;
|
||||
foreach (self::TIER_DAILY_CAPS as $tier => $_cap) {
|
||||
foreach ($tiers[$tier] as $assistant) {
|
||||
if ($poolIdx >= $poolCount) {
|
||||
break 2;
|
||||
}
|
||||
$aid = $assistant['id'];
|
||||
if (($remaining[$aid] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$remaining[$aid]--;
|
||||
$daySeq[$aid]++;
|
||||
$plan[] = [
|
||||
'diagnosis' => $pool[$poolIdx],
|
||||
'assistant' => $assistant,
|
||||
'tier' => $tier,
|
||||
'round' => $round,
|
||||
'day_seq' => $daySeq[$aid],
|
||||
];
|
||||
$poolIdx++;
|
||||
$assignedThisRound++;
|
||||
}
|
||||
}
|
||||
if ($assignedThisRound === 0) {
|
||||
// 所有医助额度用尽,剩余诊单不再分配
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [$plan, \array_slice($pool, $poolIdx)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 落库单条分配:事务 + 行锁复核诊单仍未指派,更新诊单并写指派日志(与手动指派 DiagnosisLogic::assign 同口径)。
|
||||
*
|
||||
* @param array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float}} $item
|
||||
*/
|
||||
private function applyAssignment(array $item, int $now): bool
|
||||
{
|
||||
$diagnosisId = (int) $item['diagnosis']['id'];
|
||||
$toAssistantId = (int) $item['assistant']['id'];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$diagLock = Db::name('tcm_diagnosis')
|
||||
->where('id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->field(['id', 'assistant_id'])
|
||||
->find();
|
||||
if ($diagLock === null || $diagLock === [] || (int) ($diagLock['assistant_id'] ?? 0) > 0) {
|
||||
Db::rollback();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Db::name('tcm_diagnosis')
|
||||
->where('id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'assistant_id' => $toAssistantId,
|
||||
'assign_read_at' => null,
|
||||
]);
|
||||
|
||||
$poSnap = Db::name('tcm_prescription_order')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->order(['create_time' => 'desc', 'id' => 'desc'])
|
||||
->field(['creator_id', 'create_time'])
|
||||
->find();
|
||||
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
|
||||
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
|
||||
if ($relatedPoCreateTime <= 0) {
|
||||
$relatedPoCreateTime = $now;
|
||||
$relatedPoCreatorId = 0;
|
||||
}
|
||||
|
||||
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'from_assistant_id' => 0,
|
||||
'to_assistant_id' => $toAssistantId,
|
||||
'operator_admin_id' => 0,
|
||||
'operator_name' => '系统自动分配',
|
||||
'operator_account' => 'system',
|
||||
'ip' => '',
|
||||
'related_po_creator_id' => $relatedPoCreatorId,
|
||||
'related_po_create_time' => $relatedPoCreateTime,
|
||||
'is_inherit' => 0,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::error(sprintf('自动指派落库失败 diagnosis_id=%d assistant_id=%d msg=%s', $diagnosisId, $toAssistantId, $e->getMessage()));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
|
||||
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildLogRow(string $batchNo, string $runDate, string $statMonth, array $diag, array $extra, int $now): array
|
||||
{
|
||||
return [
|
||||
'batch_no' => $batchNo,
|
||||
'run_date' => $runDate,
|
||||
'stat_month' => $statMonth,
|
||||
'diagnosis_id' => (int) ($diag['id'] ?? 0),
|
||||
'patient_name' => mb_substr(trim((string) ($diag['patient_name'] ?? '')), 0, 64),
|
||||
'patient_phone' => mb_substr(trim((string) ($diag['phone'] ?? '')), 0, 32),
|
||||
'action' => (int) ($extra['action'] ?? 0),
|
||||
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
||||
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
||||
'tier' => (string) ($extra['tier'] ?? ''),
|
||||
'visit2_rate' => $extra['visit2_rate'] ?? null,
|
||||
'round_no' => (int) ($extra['round_no'] ?? 0),
|
||||
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
||||
'create_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $logRows
|
||||
*/
|
||||
private function flushLogs(array $logRows, bool $dryRun, Output $output): void
|
||||
{
|
||||
if ($logRows === []) {
|
||||
return;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('[演练] 应写入自动指派日志 %d 条(未落库)', \count($logRows)));
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (array_chunk($logRows, 500) as $chunk) {
|
||||
Db::name('tcm_diagnosis_auto_assign_log')->insertAll($chunk);
|
||||
}
|
||||
$output->writeln(sprintf('已写入自动指派日志 %d 条', \count($logRows)));
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,7 @@ return [
|
||||
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
||||
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
|
||||
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
||||
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
|
||||
'tcm:auto-assign-pending' => 'app\\command\\AutoAssignPendingDiagnosis',
|
||||
],
|
||||
];
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+1
-1
@@ -1 +1 @@
|
||||
import r from"./error-3-P5389h.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-Ds3qCU_G.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-D49jsFPW.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-CYLw0J2V.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};
|
||||
import r from"./error-B96pOmhv.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-Bolc0EfP.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-B0jSCQ-G.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-CzSP4TPL.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
@@ -1 +1 @@
|
||||
import o from"./error-3-P5389h.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Ds3qCU_G.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-D49jsFPW.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-CYLw0J2V.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};
|
||||
import o from"./error-B96pOmhv.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.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-B0jSCQ-G.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-CzSP4TPL.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};
|
||||
Executable → Regular
Executable → Regular
+1
-1
File diff suppressed because one or more lines are too long
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{M as T,N as C,T as $,r as D,d as L,L as k}from"./element-plus-Ds3qCU_G.js";import{X as E}from"./@element-plus/icons-vue-D49jsFPW.js";import{a5 as P}from"./tcm-BqLeG7XT.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-CYLw0J2V.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 q={class:"assign-log-panel"},z={key:1,class:"text-gray-400"},G=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(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 x(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 a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",q,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",z,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(G,[["__scopeId","data-v-f670e3e6"]]);export{Ct as default};
|
||||
import{M as T,N as C,T as $,r as D,d as L,L as k}from"./element-plus-Bolc0EfP.js";import{Y as E}from"./@element-plus/icons-vue-B0jSCQ-G.js";import{a8 as P}from"./tcm-B2eDN42y.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-CzSP4TPL.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 Y={class:"assign-log-panel"},q={key:1,class:"text-gray-400"},z=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(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 x(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 a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",Y,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",q,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(z,[["__scopeId","data-v-f670e3e6"]]);export{Ct as default};
|
||||
Executable → Regular
+1
-1
File diff suppressed because one or more lines are too long
Executable → Regular
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Executable → Regular
+1
-1
@@ -1 +1 @@
|
||||
import{i as L,L as T,N as V,T as D,M as z,W as M}from"./element-plus-Ds3qCU_G.js";import{ad as O}from"./tcm-BqLeG7XT.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}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-CYLw0J2V.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-D49jsFPW.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"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=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()}),j(()=>_.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})])),A((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",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}克`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_: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(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,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(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
|
||||
import{i as L,L as T,N as V,T as D,M as z,W as M}from"./element-plus-Bolc0EfP.js";import{ag as O}from"./tcm-B2eDN42y.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}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-CzSP4TPL.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-B0jSCQ-G.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"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=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()}),j(()=>_.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})])),A((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",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}克`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_: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(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,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(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
|
||||
Executable → Regular
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable → Regular
File diff suppressed because one or more lines are too long
Executable → Regular
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user