Compare commits

...
48 Commits
Author SHA1 Message Date
Your Name 97fefbae8f 更新 2026-07-16 09:43:07 +08:00
Your Name 541fe59ad1 Merge branch 'xuhao-20260715' 2026-07-16 09:37:30 +08:00
longandCursor b6c5b422c2 test: 空提交验证 Gitea SSH 推送
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:25:10 +08:00
Your Name 500038601c 更新 2026-07-16 09:04:40 +08:00
Your Name f6688a740d 更新 2026-07-10 14:19:01 +08:00
Your Name a347e0642c Merge branch 'AI-20260710' 2026-07-10 14:16:51 +08:00
Your Name 13adbac3fd 更新 2026-07-10 14:16:07 +08:00
Your Name 333a18c37e 更新 2026-07-10 14:10:48 +08:00
Your Name e3a91a91f7 i
Merge branch 'AI-20260710'
2026-07-10 14:08:47 +08:00
Your Name 5ca5f06255 更新 2026-07-10 14:08:27 +08:00
Your Name e01f3e7072 更新 2026-07-10 10:58:14 +08:00
Your Name ed78803015 更新 2026-07-10 10:52:36 +08:00
Your Name 546138d60e 更新 2026-07-10 10:28:21 +08:00
Your Name fdddfdb3dd AI 客服 2026-07-10 10:25:51 +08:00
Your Name f5a89b4310 更新 2026-07-09 17:58:02 +08:00
Your Name a650747fd3 更新 2026-07-09 11:27:20 +08:00
Your Name 46b62229a6 i
Merge branch '20260708'
2026-07-09 11:25:25 +08:00
Your Name 0f33f2ad28 更新 2026-07-09 11:24:18 +08:00
Your Name a677b69e97 更新 2026-07-08 15:00:28 +08:00
Your Name 6432e9990c 更新 2026-07-08 14:53:41 +08:00
Your Name 4d6125268a 更新 2026-07-08 14:46:39 +08:00
Your Name 7f93cf5480 更新 2026-07-07 11:06:19 +08:00
Your Name f8f1953a2a Merge branch 'bug-20260706' 2026-07-07 11:04:43 +08:00
Your Name 99eca8b2e5 更新 2026-07-07 09:24:40 +08:00
Your Name bde396189a Please enter the commit message for your changes. Lines starting 2026-07-06 16:58:01 +08:00
Your Name 55e3b5d9e0 更新 2026-07-06 16:34:42 +08:00
long eb0efba739 build 2026-07-03 12:07:34 +08:00
long c7e7022181 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-03 12:04:06 +08:00
long 26e6cc433b Add bulk asset resource deletion 2026-07-03 12:01:49 +08:00
Your Name c22b84cff9 更新 2026-07-03 11:22:07 +08:00
Your Name 4f9486fb3d Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:35:40 +08:00
Your Name d24b04f9e9 更新 2026-07-02 16:35:24 +08:00
long 465cba3788 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:30:24 +08:00
long dc590d37e9 修正医生维度接诊率显示 2026-07-02 16:26:24 +08:00
Your Name d1f587fdd7 更新 2026-07-02 16:19:43 +08:00
Your Name e8f68101c4 更新 2026-07-02 16:13:38 +08:00
Your Name cea9392239 Merge branch 'master' into 20260623 2026-07-02 16:08:14 +08:00
Your Name 3ea3c97c42 更新 2026-07-02 14:47:53 +08:00
Your Name efd058bce5 更新 2026-07-02 14:35:20 +08:00
long e538dd03a1 build 2026-07-02 11:57:54 +08:00
long 4a17fe8a0c 调整综合转化统计医生维度口径 2026-07-02 11:55:15 +08:00
Your Name e2a1e62867 更新 2026-06-29 09:28:00 +08:00
Your Name b6cdd20c56 新增 2026-06-29 09:25:29 +08:00
Your Name c1be0aa3e5 更新 2026-06-24 10:07:39 +08:00
Your Name 6c87b72ce4 更新 2026-06-23 11:31:49 +08:00
Your Name e28480af32 更新 2026-06-16 16:16:31 +08:00
Your Name 37b2945c44 更新 2026-06-16 15:47:44 +08:00
Your Name c9c2b4608f 更新 2026-06-16 15:23:59 +08:00
569 changed files with 7244 additions and 916 deletions
@@ -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 }
+20 -4
View File
@@ -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' },
+19 -2
View File
@@ -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
+258 -1
View File
@@ -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.09 一一对应 */
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))
}
+283
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+20
View File
@@ -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
+36
View File
@@ -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 })
+26 -3
View File
@@ -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) => {
@@ -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>
@@ -631,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="内部成本">
@@ -780,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
@@ -800,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'
@@ -831,6 +1025,7 @@ import {
consumerRxAuditTag,
expressCompanyLabel,
logActionText,
auditStatusText,
formatPayOrderSource,
normalizeBizPhone,
recipientVsPrescriptionPhoneMismatch,
@@ -841,7 +1036,10 @@ import {
analyzeLogisticsPayloadUrgent,
parseLogisticsTracePayload,
canUpdateAmount,
formatDietaryTaboo
formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
formatServicePackageLabels
} from './prescription-order-utils'
const props = withDefaults(
@@ -873,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()
@@ -1088,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()
@@ -1142,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) {
@@ -1266,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]) {
@@ -355,3 +369,85 @@ export function formatDietaryTaboo(raw: unknown): string {
}
return ''
}
/** 服务套餐 dictserver_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"
@@ -1857,7 +1857,7 @@ const formData = reactive({
creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
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>
@@ -2114,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>
@@ -2147,7 +2227,11 @@ import {
canUpdateAmount,
formatDietaryTaboo,
type SlipFormulaType,
type SlipAuxUsageForm
type SlipAuxUsageForm,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions
} from './components/prescription-order-utils'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
@@ -2169,6 +2253,7 @@ import {
prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderBatchAssignAssistant,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder,
prescriptionOrderSubmitGancaoRecipel,
@@ -2268,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 }>>([])
@@ -2393,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 = []
@@ -2471,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_* */
@@ -2618,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
@@ -2856,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()
@@ -3044,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 }) {
@@ -3106,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()
@@ -3164,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())
}
@@ -3297,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()
@@ -3529,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
@@ -4357,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
@@ -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>
{{
@@ -1139,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="内部成本">
@@ -1298,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"
@@ -1525,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>
@@ -2151,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"
@@ -2565,6 +2716,7 @@ import {
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderPatchPrescriptionUsage,
prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderSubmitGancaoRecipel,
@@ -2573,7 +2725,16 @@ import {
getDoctors,
getAssistants
} from '@/api/tcm'
import { formatDietaryTaboo } from './components/prescription-order-utils'
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'
@@ -2669,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 }>>([])
@@ -2790,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 = []
@@ -3199,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 '已驳回'
@@ -3473,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
@@ -3629,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)
@@ -3733,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()
@@ -3855,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
@@ -3886,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())
}
@@ -4059,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()
@@ -4104,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]) {
@@ -4307,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
+3 -5
View File
@@ -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,
+224 -2
View File
@@ -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">
由每日定时任务待分配诊单自动指派写入按上月二诊复诊接诊率分档轮询分配&gt;70% 每人3条/60%~70% 每人2条/50%~60% 每人1条/&lt;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>
+152 -59
View File
@@ -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)
+4 -4
View File
@@ -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>
+45 -1
View File
@@ -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;
}
}
}
+6 -1
View File
@@ -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('起始偏移须在 020 之间')
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>
+88 -25
View File
@@ -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
+122 -20
View File
@@ -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(执行日期)/ action1=已分配 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_formula1 含辅方 | 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=3appointment_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 / 业绩看板一致)。
+32 -1
View File
@@ -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);
}
/**
* 名称含「一中心」的部门及其全部下级 idmap)。
*
* @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('起始偏移须在 020 之间');
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('主方每天次数须在 16 之间');
return false;
}
if ($usageDays < 1 || $usageDays > 999) {
self::setError('主方开立天数须在 1999 之间');
return false;
}
if ($medDays < 1 || $medDays > 999) {
self::setError('订单服用天数须在 1999 之间');
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('辅方每天次数须在 16 之间');
return false;
}
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
self::setError('辅方开立天数须在 1999 之间');
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_logis_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 = 诊单 idTab 默认按当月过滤)。先到先分。
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
*
* @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)));
}
}
+2
View File
@@ -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 @@
import r from"./error-CIC7FjML.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-DU4wa6E0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
import r from"./error-B96pOmhv.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CzSP4TPL.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -1 +1 @@
import o from"./error-CIC7FjML.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-DU4wa6E0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
import o from"./error-B96pOmhv.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CzSP4TPL.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -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-c1gDT-v4.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-DU4wa6E0.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-B2eDN42y.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-CzSP4TPL.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const Y={class:"assign-log-panel"},q={key:1,class:"text-gray-400"},z=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",Y,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",q,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(z,[["__scopeId","data-v-f670e3e6"]]);export{Ct as default};
View File
View File
View File
@@ -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-c1gDT-v4.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-DU4wa6E0.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-B2eDN42y.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}from"./@vue/runtime-core-C6bnekPw.js";import{y as d,n as k}from"./@vue/reactivity-DiY1c2vO.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-CzSP4TPL.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,h=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{h("view",e)},B=()=>{h("openPrescription")};return F(()=>{u()}),j(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const b=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(b,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),A((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(b,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
View File
File diff suppressed because one or more lines are too long
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
View File
View File
View File
View File

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