Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
@@ -212,6 +227,11 @@ export function revisitRateVisitOrderLines(params: {
|
||||
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 })
|
||||
@@ -518,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) => {
|
||||
|
||||
+413
-28
@@ -327,7 +327,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>服用方式</span>
|
||||
<el-button
|
||||
v-if="
|
||||
!readonly &&
|
||||
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">
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div>
|
||||
@@ -378,6 +398,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 ? '已作废' : '正常' }}
|
||||
@@ -628,7 +651,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="showInternalCost" label="内部成本">
|
||||
@@ -777,7 +800,18 @@
|
||||
class="po-panel border-gray-100 mt-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="font-medium text-[15px]">操作日志</span>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-[15px]">操作日志</span>
|
||||
<el-button
|
||||
v-if="canAddPrescriptionOrderLog()"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="openAddLogDialog"
|
||||
>
|
||||
新增日志
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
||||
<el-timeline-item
|
||||
@@ -797,19 +831,182 @@
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
||||
</el-card>
|
||||
|
||||
<!-- 新增操作日志 -->
|
||||
<el-dialog
|
||||
v-model="addLogVisible"
|
||||
title="新增操作日志"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
@closed="resetAddLogForm"
|
||||
>
|
||||
<el-form ref="addLogFormRef" :model="addLogForm" :rules="addLogRules" label-width="108px">
|
||||
<el-form-item label="日志内容" prop="summary">
|
||||
<el-input
|
||||
v-model="addLogForm.summary"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="记录本次操作说明、沟通结果等"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canSetRxAuditOnAddLog" label="处方审核">
|
||||
<el-select v-model="addLogForm.prescription_audit_status" class="w-full" clearable placeholder="不修改">
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已驳回" :value="2" />
|
||||
</el-select>
|
||||
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
|
||||
当前:{{ auditStatusText(detailData.prescription_audit_status) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="canSetRxAuditOnAddLog && addLogForm.prescription_audit_status !== '' && addLogForm.prescription_audit_status !== null && addLogForm.prescription_audit_status !== undefined"
|
||||
label="处方审核意见"
|
||||
>
|
||||
<el-input
|
||||
v-model="addLogForm.prescription_audit_remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canSetPayAuditOnAddLog" label="支付单审核">
|
||||
<el-select v-model="addLogForm.payment_slip_audit_status" class="w-full" clearable placeholder="不修改">
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已驳回" :value="2" />
|
||||
</el-select>
|
||||
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
|
||||
当前:{{ auditStatusText(detailData.payment_slip_audit_status) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="canSetPayAuditOnAddLog && addLogForm.payment_slip_audit_status !== '' && addLogForm.payment_slip_audit_status !== null && addLogForm.payment_slip_audit_status !== undefined"
|
||||
label="支付审核意见"
|
||||
>
|
||||
<el-input
|
||||
v-model="addLogForm.payment_slip_audit_remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addLogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addLogSaving" @click="submitAddLog">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改服用参数:主方 / 辅方 / 订单设置 -->
|
||||
<el-dialog
|
||||
v-model="patchUsageVisible"
|
||||
title="修改服用参数"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="resetPatchUsageForm"
|
||||
>
|
||||
<el-form
|
||||
ref="patchUsageFormRef"
|
||||
:model="patchUsageForm"
|
||||
:rules="patchUsageRules"
|
||||
label-width="108px"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
|
||||
import {
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderAddLog,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderLogisticsJdUpdate,
|
||||
prescriptionOrderPaidPayOrders
|
||||
prescriptionOrderPaidPayOrders,
|
||||
prescriptionOrderPatchPrescriptionUsage
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
@@ -828,6 +1025,7 @@ import {
|
||||
consumerRxAuditTag,
|
||||
expressCompanyLabel,
|
||||
logActionText,
|
||||
auditStatusText,
|
||||
formatPayOrderSource,
|
||||
normalizeBizPhone,
|
||||
recipientVsPrescriptionPhoneMismatch,
|
||||
@@ -837,7 +1035,11 @@ import {
|
||||
logisticsTraceLineUrgent,
|
||||
analyzeLogisticsPayloadUrgent,
|
||||
parseLogisticsTracePayload,
|
||||
canUpdateAmount
|
||||
canUpdateAmount,
|
||||
formatDietaryTaboo,
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
formatServicePackageLabels
|
||||
} from './prescription-order-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -869,6 +1071,7 @@ const emit = defineEmits<{
|
||||
(e: 'view-prescription'): void
|
||||
(e: 'test-gancao-preview'): void
|
||||
(e: 'view-patient'): void
|
||||
(e: 'detail-changed'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -896,6 +1099,8 @@ const detailPrescription = computed(() => {
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
|
||||
|
||||
const detailLinkedPayOrders = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return []
|
||||
@@ -1082,36 +1287,24 @@ const detailFullAddress = computed(() => {
|
||||
})
|
||||
|
||||
// ─── 服务套餐字典 ───
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||
|
||||
async function loadServicePackageOptions() {
|
||||
if (servicePackageOptions.value.length > 0) return
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'server_order' })
|
||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
||||
const opts = normalizeServicePackageOptions(data?.server_order)
|
||||
if (opts.length > 0) {
|
||||
servicePackageOptions.value = opts
|
||||
}
|
||||
} catch {
|
||||
servicePackageOptions.value = []
|
||||
/* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
|
||||
}
|
||||
}
|
||||
|
||||
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('、')
|
||||
}
|
||||
const detailServicePackageText = computed(() =>
|
||||
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadServicePackageOptions()
|
||||
@@ -1136,6 +1329,196 @@ async function fetchLogs(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasPerm(perm: string) {
|
||||
const p = userStore.perms || []
|
||||
return p.includes('*') || p.includes(perm)
|
||||
}
|
||||
|
||||
function canAddPrescriptionOrderLog() {
|
||||
return hasPerm('tcm.prescriptionOrder/addLog')
|
||||
}
|
||||
|
||||
/** 与后端 canAuditPrescriptionOrder 同档:超管或 prescription_audit_roles */
|
||||
const canSetRxAuditOnAddLog = 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)) : []
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
/** 与后端 canAuditPaymentSlipOrder 同档:超管或 prescription_order_payment_audit_roles 默认 0,3 */
|
||||
const canSetPayAuditOnAddLog = 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)) : []
|
||||
return ids.some((id) => [0, 3].includes(id))
|
||||
})
|
||||
|
||||
const addLogVisible = ref(false)
|
||||
const addLogSaving = ref(false)
|
||||
const addLogFormRef = ref<FormInstance>()
|
||||
const addLogForm = reactive({
|
||||
summary: '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
prescription_audit_remark: '',
|
||||
payment_slip_audit_remark: ''
|
||||
})
|
||||
const addLogRules: FormRules = {
|
||||
summary: [{ 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 refresh()
|
||||
emit('detail-changed')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
patchUsageSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAddLogForm() {
|
||||
addLogForm.summary = ''
|
||||
addLogForm.prescription_audit_status = ''
|
||||
addLogForm.payment_slip_audit_status = ''
|
||||
addLogForm.prescription_audit_remark = ''
|
||||
addLogForm.payment_slip_audit_remark = ''
|
||||
addLogFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openAddLogDialog() {
|
||||
if (!detailData.value?.id) return
|
||||
resetAddLogForm()
|
||||
const d = detailData.value
|
||||
addLogForm.prescription_audit_remark = String(d.prescription_audit_remark || '')
|
||||
addLogForm.payment_slip_audit_remark = String(d.payment_slip_audit_remark || '')
|
||||
addLogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAddLog() {
|
||||
if (!addLogFormRef.value || !detailData.value?.id) return
|
||||
await addLogFormRef.value.validate()
|
||||
addLogSaving.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: detailData.value.id,
|
||||
summary: addLogForm.summary.trim()
|
||||
}
|
||||
if (
|
||||
canSetRxAuditOnAddLog.value &&
|
||||
addLogForm.prescription_audit_status !== '' &&
|
||||
addLogForm.prescription_audit_status !== null &&
|
||||
addLogForm.prescription_audit_status !== undefined
|
||||
) {
|
||||
payload.prescription_audit_status = addLogForm.prescription_audit_status
|
||||
payload.prescription_audit_remark = addLogForm.prescription_audit_remark
|
||||
}
|
||||
if (
|
||||
canSetPayAuditOnAddLog.value &&
|
||||
addLogForm.payment_slip_audit_status !== '' &&
|
||||
addLogForm.payment_slip_audit_status !== null &&
|
||||
addLogForm.payment_slip_audit_status !== undefined
|
||||
) {
|
||||
payload.payment_slip_audit_status = addLogForm.payment_slip_audit_status
|
||||
payload.payment_slip_audit_remark = addLogForm.payment_slip_audit_remark
|
||||
}
|
||||
await prescriptionOrderAddLog(payload as any)
|
||||
feedback.msgSuccess('日志已添加')
|
||||
addLogVisible.value = false
|
||||
await refresh()
|
||||
emit('detail-changed')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
addLogSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 未关联支付单 ───
|
||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
||||
if (!diagnosisId) {
|
||||
@@ -1260,6 +1643,8 @@ async function updateJdLogistics() {
|
||||
|
||||
// ─── 打开 / 刷新 ───
|
||||
async function open(id: number) {
|
||||
// 页面级同参数字典请求会取消抽屉挂载时的那次(axios 去重取消),打开时兜底重试
|
||||
void loadServicePackageOptions()
|
||||
// 显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
|
||||
@@ -135,24 +135,38 @@ export function logActionText(act: string) {
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
patch_rx_usage: '服用参数',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
refund: '退款'
|
||||
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]) {
|
||||
@@ -340,3 +354,100 @@ export function canUpdateAmount(row: { id?: number; fulfillment_status?: number
|
||||
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: ''
|
||||
|
||||
@@ -283,6 +283,12 @@
|
||||
<el-option label="未指派" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[140px]" label="辅方">
|
||||
<el-select v-model="queryParams.has_aux_formula" clearable placeholder="全部" class="!w-full">
|
||||
<el-option label="含辅方" value="1" />
|
||||
<el-option label="不含辅方" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
@@ -292,7 +298,7 @@
|
||||
:fetch-fun="prescriptionOrderExport"
|
||||
:params="prescriptionOrderExportParams"
|
||||
:page-size="pager.size"
|
||||
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
|
||||
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「处方」导出主方/辅方药材明细;「主方/辅方服用方式、天数」与详情侧栏同口径(主方/辅方天数分别取处方 usage_days、辅方 aux_usage.usage_days;「天数」列为订单 medication_days)。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -303,6 +309,17 @@
|
||||
<div class="po-table-toolbar__title-wrap">
|
||||
<span class="po-table-title text-gray-800">订单列表</span>
|
||||
<span class="po-table-hint text-gray-400">共 {{ pager.count }} 条</span>
|
||||
<el-button
|
||||
v-if="canBatchReassign"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
class="ml-3"
|
||||
:disabled="selectedOrders.length === 0"
|
||||
@click="openReassignDialog"
|
||||
>
|
||||
批量改派医助{{ selectedOrders.length ? `(${selectedOrders.length})` : '' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="po-focus-board">
|
||||
<div
|
||||
@@ -339,7 +356,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="pager.lists" size="default" stripe class="po-data-table" :row-class-name="orderRowClassName">
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
size="default"
|
||||
stripe
|
||||
class="po-data-table"
|
||||
:row-class-name="orderRowClassName"
|
||||
@selection-change="onOrderSelectionChange"
|
||||
>
|
||||
<el-table-column v-if="canBatchReassign" type="selection" width="44" :selectable="() => true" />
|
||||
<el-table-column label="订单号" min-width="178" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="po-order-no-cell">
|
||||
@@ -406,16 +431,41 @@
|
||||
</div>
|
||||
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
|
||||
<span class="text-gray-500">内部成本:</span>
|
||||
<span class="font-medium text-orange-600">¥{{ formatMoney(row.internal_cost) }}</span>
|
||||
<span
|
||||
class="font-medium cursor-pointer select-none"
|
||||
:class="internalCostVisible ? 'text-orange-600' : 'text-gray-400'"
|
||||
title="点击显示/隐藏"
|
||||
@click.stop="internalCostVisible = !internalCostVisible"
|
||||
>
|
||||
{{ internalCostVisible ? `¥${formatMoney(row.internal_cost)}` : '****' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canViewFinanceFields()" label="内部成本" width="92">
|
||||
<el-table-column v-if="canViewFinanceFields()" width="100">
|
||||
<template #header>
|
||||
<span
|
||||
class="cursor-pointer select-none hover:text-primary"
|
||||
title="点击显示/隐藏内部成本"
|
||||
@click.stop="internalCostVisible = !internalCostVisible"
|
||||
>
|
||||
内部成本
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
|
||||
<span
|
||||
v-if="row.internal_cost != null && row.internal_cost !== ''"
|
||||
class="cursor-pointer select-none"
|
||||
:class="internalCostVisible ? 'text-orange-600 font-medium' : 'text-gray-400'"
|
||||
title="点击显示/隐藏"
|
||||
@click.stop="internalCostVisible = !internalCostVisible"
|
||||
>
|
||||
{{ internalCostVisible ? `¥${row.internal_cost}` : '****' }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方审核" width="110">
|
||||
@@ -573,6 +623,7 @@
|
||||
@view-prescription="detailData && openPrescriptionView(detailData)"
|
||||
@test-gancao-preview="testGancaoPreviewFromDetail"
|
||||
@view-patient="openDiagnosisPatientDetailFromOrder"
|
||||
@detail-changed="getLists"
|
||||
>
|
||||
<template #header-extra="{ detail }">
|
||||
<div class="flex items-center gap-2 ml-4 shrink-0">
|
||||
@@ -704,7 +755,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"
|
||||
@@ -974,10 +1025,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>
|
||||
@@ -1898,6 +1950,7 @@
|
||||
<p v-if="rxAuxUsageText">辅服法:{{ rxAuxUsageText }}</p>
|
||||
</template>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -2113,6 +2166,34 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 批量改派医助 -->
|
||||
<el-dialog v-model="reassignVisible" title="批量改派医助" width="440px" append-to-body>
|
||||
<div class="text-sm text-gray-600 mb-3">
|
||||
已选 <strong class="text-primary">{{ selectedOrders.length }}</strong> 单,将订单「创建人(医助归属)」改派为:
|
||||
</div>
|
||||
<el-select
|
||||
v-model="reassignAssistantId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择目标医助"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in assistantOptions"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
<p class="text-xs text-gray-400 mt-2 leading-relaxed">
|
||||
仅变更订单的业务归属(医助筛选 / 业绩口径),并写入操作日志;不影响处方、支付单及审核状态。已是该医助的订单会自动跳过。
|
||||
</p>
|
||||
<template #footer>
|
||||
<el-button @click="reassignVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="reassignSubmitting" @click="submitReassign">确定改派</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
||||
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
||||
</div>
|
||||
@@ -2144,8 +2225,13 @@ import {
|
||||
analyzeLogisticsPayloadUrgent,
|
||||
parseLogisticsTracePayload,
|
||||
canUpdateAmount,
|
||||
formatDietaryTaboo,
|
||||
type SlipFormulaType,
|
||||
type SlipAuxUsageForm
|
||||
type SlipAuxUsageForm,
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
parseServicePackageValues,
|
||||
mergeServicePackageSelectOptions
|
||||
} from './components/prescription-order-utils'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
@@ -2167,6 +2253,7 @@ import {
|
||||
prescriptionOrderRefund,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderBatchAssignAssistant,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
@@ -2266,11 +2353,75 @@ const canViewFinanceFields = () => {
|
||||
return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
}
|
||||
|
||||
/** 内部成本默认隐藏,点击表头或单元格切换显示 */
|
||||
const internalCostVisible = ref(false)
|
||||
|
||||
/** 是否可批量改派医助(与后端 canSeeAllPrescriptionOrders 一致:超管 / 全量编辑角色) */
|
||||
const canBatchReassign = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ORDER_EDIT_ALL_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 批量改派:表格多选 */
|
||||
const selectedOrders = ref<Array<{ id: number; order_no?: string; creator_id?: number }>>([])
|
||||
function onOrderSelectionChange(rows: Array<{ id: number; order_no?: string; creator_id?: number }>) {
|
||||
selectedOrders.value = Array.isArray(rows) ? rows : []
|
||||
}
|
||||
|
||||
/** 批量改派弹窗 */
|
||||
const reassignVisible = ref(false)
|
||||
const reassignAssistantId = ref<number | ''>('')
|
||||
const reassignSubmitting = ref(false)
|
||||
|
||||
function openReassignDialog() {
|
||||
if (selectedOrders.value.length === 0) {
|
||||
feedback.msgWarning('请先勾选要改派的订单')
|
||||
return
|
||||
}
|
||||
reassignAssistantId.value = ''
|
||||
reassignVisible.value = true
|
||||
}
|
||||
|
||||
async function submitReassign() {
|
||||
const assistantId = Number(reassignAssistantId.value)
|
||||
if (!assistantId) {
|
||||
feedback.msgWarning('请选择目标医助')
|
||||
return
|
||||
}
|
||||
const orderIds = selectedOrders.value.map((r) => Number(r.id)).filter((id) => id > 0)
|
||||
if (orderIds.length === 0) {
|
||||
feedback.msgWarning('请先勾选要改派的订单')
|
||||
return
|
||||
}
|
||||
reassignSubmitting.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderBatchAssignAssistant({
|
||||
order_ids: orderIds,
|
||||
assistant_id: assistantId
|
||||
})
|
||||
const ok = Number(res?.success ?? 0)
|
||||
const errs: string[] = Array.isArray(res?.errors) ? res.errors : []
|
||||
let msg = `已改派 ${ok} 单`
|
||||
if (errs.length) msg += `;${errs.join(';')}`
|
||||
feedback.msgSuccess(msg)
|
||||
reassignVisible.value = false
|
||||
selectedOrders.value = []
|
||||
getLists()
|
||||
} catch {
|
||||
// request 拦截器已弹错误提示
|
||||
} finally {
|
||||
reassignSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 省市区数据
|
||||
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 }>>([])
|
||||
@@ -2391,8 +2542,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 = []
|
||||
@@ -2469,6 +2619,8 @@ const queryParams = reactive({
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||
service_channel: '' as '' | '0',
|
||||
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
||||
has_aux_formula: '' as '' | '0' | '1',
|
||||
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
|
||||
exclude_fulfillment_cancelled: 0 as number,
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
@@ -2616,6 +2768,11 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
|
||||
p.service_channel = String(p.service_channel).trim()
|
||||
if (p.service_channel === '') delete p.service_channel
|
||||
}
|
||||
if (p.has_aux_formula === '' || p.has_aux_formula === undefined || p.has_aux_formula === null) {
|
||||
delete p.has_aux_formula
|
||||
} else {
|
||||
p.has_aux_formula = Number(p.has_aux_formula)
|
||||
}
|
||||
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
|
||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||
delete p.start_time
|
||||
@@ -2854,6 +3011,7 @@ function handleReset() {
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.supply_mode = ''
|
||||
queryParams.service_channel = ''
|
||||
queryParams.has_aux_formula = ''
|
||||
queryParams.audit_admin_id = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
resetParams()
|
||||
@@ -3042,10 +3200,17 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
|
||||
}
|
||||
}
|
||||
|
||||
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
||||
// 已发货(5) / 已签收(6) 状态可补齐支付单
|
||||
function canAddPayOrderRow(row: {
|
||||
fulfillment_status?: number
|
||||
amount?: number | string
|
||||
linked_pay_paid_total?: number | string
|
||||
}) {
|
||||
// 已发货(5) / 已签收(6) 状态可补齐支付单;总金额已付清则不允许
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs === 5 || fs === 6
|
||||
if (fs !== 5 && fs !== 6) return false
|
||||
const orderAmount = Math.round((Number(row.amount) || 0) * 100) / 100
|
||||
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
|
||||
return paidTotal < orderAmount
|
||||
}
|
||||
|
||||
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
||||
@@ -3104,8 +3269,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()
|
||||
@@ -3162,7 +3327,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())
|
||||
}
|
||||
@@ -3295,6 +3461,11 @@ 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()
|
||||
@@ -3527,18 +3698,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
|
||||
@@ -4355,7 +4515,18 @@ async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: n
|
||||
}
|
||||
}
|
||||
|
||||
function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) {
|
||||
function openAddPayOrder(row: {
|
||||
id: number
|
||||
diagnosis_id?: number
|
||||
pay_order_ids?: number[]
|
||||
fulfillment_status?: number
|
||||
amount?: number | string
|
||||
linked_pay_paid_total?: number | string
|
||||
}) {
|
||||
if (!canAddPayOrderRow(row)) {
|
||||
feedback.msgWarning('订单总金额与已付金额一致,无需补齐支付单')
|
||||
return
|
||||
}
|
||||
addPayOrderRowId.value = row.id
|
||||
addPayOrderForm.add_mode = 'create'
|
||||
addPayOrderForm.order_type = 3
|
||||
@@ -4544,12 +4715,7 @@ const slipAuxHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
@@ -2148,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"
|
||||
@@ -2311,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 }}
|
||||
@@ -2561,6 +2716,7 @@ import {
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderPatchPrescriptionUsage,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
@@ -2569,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'
|
||||
@@ -2664,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 }>>([])
|
||||
@@ -2785,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 = []
|
||||
@@ -3194,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 '已驳回'
|
||||
@@ -3468,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
|
||||
@@ -3579,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()
|
||||
@@ -3622,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)
|
||||
|
||||
@@ -3726,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()
|
||||
@@ -3848,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
|
||||
@@ -3879,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())
|
||||
}
|
||||
@@ -4052,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()
|
||||
@@ -4097,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]) {
|
||||
@@ -4300,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
|
||||
@@ -5185,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"
|
||||
@@ -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: '接待' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<el-tree-select
|
||||
v-model="deptId"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="全部部门"
|
||||
placeholder="二中心(全部)"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
@@ -43,10 +43,10 @@
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重。
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||
</p>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序的全局序号,<b>跨月累计不重置</b>——如 5 月指派后旗下成交 4 单为二诊~五诊,下月再成交即为六诊。
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||
@@ -55,7 +55,7 @@
|
||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;选定部门时含其组织下级。
|
||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
@@ -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>
|
||||
@@ -63,9 +117,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
formatTime,
|
||||
fulfillmentText,
|
||||
@@ -91,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) {
|
||||
@@ -102,6 +178,47 @@ const buildParams = () => {
|
||||
queryParams.scene = 'diagnosis_edit'
|
||||
}
|
||||
|
||||
async function loadRevisitSlotStartOffset() {
|
||||
if (props.diagnosisId <= 0) return
|
||||
offsetLoading.value = true
|
||||
try {
|
||||
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 {
|
||||
revisitSlotStartOffset.value = 0
|
||||
savedRevisitSlotStartOffset.value = 0
|
||||
} finally {
|
||||
offsetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
await tcmDiagnosisSetRevisitSlotStartOffset({
|
||||
id: props.diagnosisId,
|
||||
revisit_slot_start_offset: offset
|
||||
})
|
||||
savedRevisitSlotStartOffset.value = offset
|
||||
feedback.msgSuccess('保存成功')
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
offsetSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 详情抽屉(共享组件,数据拉取/展示全部在组件内) ───
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
|
||||
@@ -117,8 +234,13 @@ const formatAmount = (value: unknown) => {
|
||||
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 }
|
||||
@@ -134,4 +256,21 @@ defineExpose({ refresh: () => getLists() })
|
||||
.po-empty-tip {
|
||||
padding: 24px 0;
|
||||
}
|
||||
.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;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
.po-revisit-offset-bar__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -103,11 +103,30 @@
|
||||
</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="!canEditPatientBasicFields"
|
||||
:disabled="!canEditIdCard"
|
||||
@focus="handleIdCardFocus"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
@@ -822,6 +841,21 @@ const showPhoneMaskedEdit = computed(() => {
|
||||
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 '诊单详情'
|
||||
@@ -961,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) {
|
||||
@@ -1031,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)
|
||||
}
|
||||
@@ -1075,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)
|
||||
}
|
||||
}
|
||||
@@ -1099,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
|
||||
}
|
||||
@@ -1114,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' }],
|
||||
}
|
||||
|
||||
@@ -1213,7 +1270,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
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 ?? ''
|
||||
@@ -1234,6 +1291,7 @@ const open = async (type: string, id?: number) => {
|
||||
visible.value = true
|
||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
|
||||
// 加载字典数据
|
||||
@@ -1257,6 +1315,7 @@ const openViewOnly = async (id: number) => {
|
||||
visible.value = true
|
||||
activeTab.value = 'basic'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
await getDictOptions()
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
@@ -1282,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等字段正确
|
||||
@@ -1296,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)
|
||||
}
|
||||
@@ -1398,6 +1460,7 @@ const handleClose = () => {
|
||||
isPhoneFocused.value = false
|
||||
isIdCardFocused.value = false
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
viewOnly.value = false
|
||||
|
||||
visible.value = false
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,24 @@ class DiagnosisController extends BaseAdminController
|
||||
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
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
@@ -12,9 +13,11 @@ use think\facade\Db;
|
||||
* 口径说明:
|
||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
||||
* 同一诊单当月被多次指派给同一医助只计 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 即不再归属;
|
||||
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
||||
@@ -22,7 +25,8 @@ use think\facade\Db;
|
||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
|
||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||
*/
|
||||
class RevisitRateLogic
|
||||
@@ -294,14 +298,19 @@ class RevisitRateLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门下拉(全量未删除部门,前端组树)。
|
||||
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 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')
|
||||
@@ -322,9 +331,9 @@ class RevisitRateLogic
|
||||
/**
|
||||
* 核心统计上下文:
|
||||
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||
* 2. 分母:当月非继承指派的「医助 × 诊单」;
|
||||
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助;
|
||||
* 4. 应用部门筛选(含组织下级)。
|
||||
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
||||
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
|
||||
*
|
||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||
*
|
||||
@@ -375,9 +384,35 @@ class RevisitRateLogic
|
||||
}
|
||||
}
|
||||
|
||||
// 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助
|
||||
// 分母:剔除名下存在拒收(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,
|
||||
@@ -387,6 +422,7 @@ class RevisitRateLogic
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = 0;
|
||||
foreach ($orderRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
@@ -397,8 +433,10 @@ class RevisitRateLogic
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
}
|
||||
$seq++;
|
||||
$effectiveSlot = $seq + $offset;
|
||||
$ct = (int) ($r['create_time'] ?? 0);
|
||||
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||
$tl = $timeline[$did] ?? [];
|
||||
@@ -407,24 +445,27 @@ class RevisitRateLogic
|
||||
$holder = (int) $tl[$ptr]['to'];
|
||||
$ptr++;
|
||||
}
|
||||
if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
|
||||
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||
continue;
|
||||
}
|
||||
if ($ct < $startTs || $ct > $endTs) {
|
||||
continue;
|
||||
}
|
||||
if ($holder > 0) {
|
||||
$slotOrdersByAssistant[$holder][$seq][] = $r;
|
||||
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 医助归属部门 + 部门筛选(含组织下级)
|
||||
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||
$deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null);
|
||||
if ($deptFilterIds !== []) {
|
||||
$subtreeSet = self::expandDeptSubtreeSet($deptFilterIds);
|
||||
$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])) {
|
||||
@@ -543,6 +584,45 @@ class RevisitRateLogic
|
||||
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[] | 逗号分隔字符串
|
||||
*
|
||||
@@ -596,6 +676,35 @@ class RevisitRateLogic
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
|
||||
*
|
||||
@@ -619,6 +728,48 @@ class RevisitRateLogic
|
||||
->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
|
||||
*
|
||||
|
||||
@@ -2616,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 映射同源)。
|
||||
*/
|
||||
|
||||
@@ -748,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 诊单指派医助操作记录列表
|
||||
*/
|
||||
|
||||
@@ -2375,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),
|
||||
* 创建后将支付单链接到业务订单,并将处方/支付审核状态重置为待审核以启动再次审核流程。
|
||||
@@ -3107,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 同字典口径)
|
||||
*
|
||||
@@ -3161,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);业绩抽屉带渠道时与列表同源高亮。
|
||||
@@ -3212,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) {
|
||||
@@ -3392,6 +3906,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
|
||||
$assistantDeptCache = [];
|
||||
$linkedPayExportByPo = self::batchLinkedPayRecordsExportTextByPoIds($poIds);
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$poId = (int) ($item['id'] ?? 0);
|
||||
@@ -3445,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
|
||||
@@ -3464,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, '.', '')
|
||||
@@ -3614,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 === []) {
|
||||
@@ -4216,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'] ?? '';
|
||||
@@ -4247,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-DHymvwbQ.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-FY-B1i4b.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-DPF4_ftF.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-DsiMl32K.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-DHymvwbQ.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-FY-B1i4b.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-DPF4_ftF.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-DsiMl32K.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
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-Bolc0EfP.js";import{Y as E}from"./@element-plus/icons-vue-B0jSCQ-G.js";import{a5 as P}from"./tcm-CoK1wKQ6.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-FY-B1i4b.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};
|
||||
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-4eHdADUG.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-DsiMl32K.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
+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
Executable → Regular
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-Bolc0EfP.js";import{ad as O}from"./tcm-CoK1wKQ6.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-FY-B1i4b.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};
|
||||
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-4eHdADUG.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-DsiMl32K.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};
|
||||
+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
+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
File diff suppressed because one or more lines are too long
Executable → Regular
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