Compare commits
17
Commits
32370df7fe
...
bug-0718
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d6a38e26 | ||
|
|
64d760af1a | ||
|
|
9ba53b6145 | ||
|
|
30cf33c546 | ||
|
|
e1fe818dce | ||
|
|
31312ae975 | ||
|
|
8a6890767e | ||
|
|
d57e60539e | ||
|
|
78fd00fa05 | ||
|
|
fd7e6071a8 | ||
|
|
7192c4db8c | ||
|
|
b02c340348 | ||
|
|
0f21dd4451 | ||
|
|
1ed3e06fdf | ||
|
|
c608145023 | ||
|
|
4f95d9182e | ||
|
|
f662723a25 |
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
import { ref} from "vue";
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
@@ -97,11 +96,7 @@ const video =async (data)=>{
|
||||
},
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
console.log('SDKAppID',SDKAppID)
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
|
||||
@@ -239,7 +239,6 @@
|
||||
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
||||
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
||||
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
||||
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
||||
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
||||
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
|
||||
},
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
patient_data.value=res.data.data
|
||||
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
getApp().globalData.SDKAppID = SDKAppID;
|
||||
getApp().globalData.secretKey = secretKey;
|
||||
uni.setStorageSync('CallManager', userId)
|
||||
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
||||
}
|
||||
|
||||
@@ -186,16 +186,8 @@ const inlineItems = computed(() => {
|
||||
return metro ? [metro, ...rest] : trainingItems
|
||||
})
|
||||
|
||||
const isDevMode = (): boolean => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const visible = ref(isDevMode())
|
||||
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
|
||||
const visible = ref(true)
|
||||
const expanded = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
|
||||
@@ -178,7 +178,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
let userId = ref("");
|
||||
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
|
||||
data: { patient_id: patient_id },
|
||||
}, false)
|
||||
|
||||
const { userId: patientUserId } = res.data
|
||||
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID: patientUserId,
|
||||
})
|
||||
const {
|
||||
userId: patientUserId,
|
||||
userSig: patientUserSig,
|
||||
sdkAppId: patientSDKAppID,
|
||||
} = res.data
|
||||
console.log('获取签名成功:', patientUserSig)
|
||||
|
||||
// 更新ref值
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
|
||||
+17
-19
@@ -26,14 +26,6 @@
|
||||
"navigationBarTitleText": "甄养堂"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "TUICallKit/src/Components/TUICallKit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频问诊",
|
||||
"navigationBarHidden": true,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/monad/monad",
|
||||
"style": {
|
||||
@@ -76,6 +68,19 @@
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "TUICallKit/src/Components",
|
||||
"pages": [
|
||||
{
|
||||
"path": "TUICallKit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频问诊",
|
||||
"navigationBarHidden": true,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "TUIKit",
|
||||
"pages": [
|
||||
@@ -215,22 +220,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/game",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "糖分突袭",
|
||||
"backgroundColor": "#c7d2fe",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控糖消消乐",
|
||||
"navigationBarTitleText": "识糖小课堂",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false
|
||||
"disableScroll": false,
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
import { ref,onMounted,getCurrentInstance} from "vue";
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
const { proxy } = getCurrentInstance()
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
||||
let userID = ref("4");
|
||||
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
|
||||
|
||||
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
console.log('获取签名成功:',userSig);
|
||||
getApp().globalData.userID = userId.value;
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
getApp().globalData.SDKAppID = SDKAppID;
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance,onMounted } from "vue";
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
let userId = ref("");
|
||||
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
|
||||
data: { patient_id: patient_id },
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
console.log('获取签名成功:',userSig);
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
|
||||
@@ -370,7 +370,6 @@
|
||||
import { ref,onMounted,getCurrentInstance } from "vue";
|
||||
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
|
||||
const granted = await requestAVPermissionsInModal();
|
||||
if (!granted) return; // 未授权,停止跳转
|
||||
const app = getApp();
|
||||
// const userID = app.globalData.userID;
|
||||
// const userSig = app.globalData.userSig;
|
||||
// const SDKAppID = app.globalData.SDKAppID;
|
||||
const doctor = 'doctor_' + doctorId.value;
|
||||
await confirmDiagnosis(diagnosisId);
|
||||
if(!diagnosisId.includes('patient')){
|
||||
diagnosisId='patient_'+diagnosisId
|
||||
}
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:diagnosisId,
|
||||
});
|
||||
const signatureRes = await proxy.apiUrl({
|
||||
url: '/api/tcm/getPatientSignature',
|
||||
method: 'GET',
|
||||
data: { patient_id: diagnosisId },
|
||||
}, false);
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
|
||||
if (!doctorId.value) {
|
||||
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(app.globalData, diagnosisId);
|
||||
app.globalData.userID = userId;
|
||||
app.globalData.userSig = userSig;
|
||||
app.globalData.SDKAppID = SDKAppID;
|
||||
console.log(app.globalData, userId);
|
||||
|
||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||
|
||||
showPatientModal.value = false;
|
||||
uni.redirectTo({
|
||||
@@ -707,11 +706,7 @@ const video =async (data)=>{
|
||||
},
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<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="imageMode"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getFoodImgUrl } from '../data/gameFoodImg.js'
|
||||
|
||||
const props = defineProps({
|
||||
food: { type: [Object, String], default: null },
|
||||
size: { type: String, default: 'tile' }
|
||||
})
|
||||
|
||||
const imageFailed = ref(false)
|
||||
|
||||
const fallbackIcon = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
|
||||
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,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onImageError() {
|
||||
imageFailed.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.food-tile-icon {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
|
||||
}
|
||||
|
||||
.food-tile-icon--tile .food-tile-icon__emoji,
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 75%;
|
||||
height: 75%;
|
||||
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));
|
||||
}
|
||||
|
||||
.food-tile-icon--goal {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.food-tile-icon--tooltip {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.food-tile-icon__emoji {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: inherit;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal .food-tile-icon__emoji,
|
||||
.food-tile-icon--tooltip .food-tile-icon__emoji {
|
||||
position: static;
|
||||
transform: none;
|
||||
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;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
|
||||
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
let fxSeq = 0
|
||||
function nextFxId(prefix) {
|
||||
fxSeq += 1
|
||||
return `${prefix}-${fxSeq}-${Date.now()}`
|
||||
}
|
||||
|
||||
// 开心消消乐式糖果色板(彩虹爆裂用)
|
||||
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
|
||||
// 星屑字形
|
||||
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
|
||||
|
||||
export function useGameFx() {
|
||||
const floatingTexts = ref([])
|
||||
const burstParticles = ref([])
|
||||
const ripples = ref([])
|
||||
const comboBanner = ref({ show: false, text: '', tier: 1 })
|
||||
const screenShaking = ref(false)
|
||||
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
|
||||
|
||||
let comboBannerTimer = null
|
||||
let shakeTimer = null
|
||||
let flashTimer = null
|
||||
let flashFadeTimer = null
|
||||
|
||||
function spawnFloatingText(payload) {
|
||||
const id = nextFxId('ft')
|
||||
floatingTexts.value.push({ id, ...payload })
|
||||
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
|
||||
setTimeout(() => {
|
||||
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
|
||||
}, duration)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
|
||||
* @param {number} cx,cy 爆裂中心(屏幕坐标)
|
||||
* @param {string} color 基础颜色
|
||||
* @param {number} count 粒子数量
|
||||
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
|
||||
*/
|
||||
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
|
||||
const {
|
||||
star = true,
|
||||
rainbow = false,
|
||||
gravity = 64,
|
||||
sizeMin = 8,
|
||||
sizeMax = 16,
|
||||
distMin = 36,
|
||||
distMax = 110,
|
||||
life = 850
|
||||
} = opts
|
||||
const dur = (life / 1000).toFixed(2)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = nextFxId('p')
|
||||
const isStar = star && Math.random() < 0.65
|
||||
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = Math.random() * (distMax - distMin) + distMin
|
||||
const tx = Math.cos(angle) * distance
|
||||
// 先上抛、再叠加重力下落,形成喷泉抛物线
|
||||
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
|
||||
const half = pSize / 2
|
||||
const pColor = rainbow
|
||||
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
|
||||
: color
|
||||
const rot = Math.random() * 720 - 360
|
||||
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
|
||||
|
||||
const particle = {
|
||||
id,
|
||||
left: cx - half,
|
||||
top: cy - half,
|
||||
size: pSize,
|
||||
color: pColor,
|
||||
char: glyph,
|
||||
opacity: 1,
|
||||
transform: 'scale(0.4) rotate(0deg)',
|
||||
transition: 'none'
|
||||
}
|
||||
burstParticles.value.push(particle)
|
||||
|
||||
setTimeout(() => {
|
||||
particle.transition =
|
||||
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
|
||||
particle.left = cx - half + tx
|
||||
particle.top = cy - half + ty
|
||||
particle.opacity = 0
|
||||
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
|
||||
}, 16)
|
||||
|
||||
setTimeout(() => {
|
||||
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
|
||||
}, life + 80)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
|
||||
* @param {object} opts { thickness, life }
|
||||
*/
|
||||
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
|
||||
const id = nextFxId('rp')
|
||||
const size = 36 + tier * 18
|
||||
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
|
||||
ripples.value.push({
|
||||
id,
|
||||
left: cx,
|
||||
top: cy,
|
||||
size,
|
||||
color,
|
||||
thickness,
|
||||
opacity: 0.95
|
||||
})
|
||||
setTimeout(() => {
|
||||
ripples.value = ripples.value.filter((r) => r.id !== id)
|
||||
}, life)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
|
||||
*/
|
||||
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
|
||||
if (flashFadeTimer) clearTimeout(flashFadeTimer)
|
||||
if (flashTimer) clearTimeout(flashTimer)
|
||||
flash.value = { active: true, color, opacity: intensity }
|
||||
flashFadeTimer = setTimeout(() => {
|
||||
flash.value = { ...flash.value, opacity: 0 }
|
||||
}, 24)
|
||||
flashTimer = setTimeout(() => {
|
||||
flash.value = { active: false, color, opacity: 0 }
|
||||
}, life)
|
||||
}
|
||||
|
||||
function showComboBanner(chain, points) {
|
||||
if (chain <= 1) return
|
||||
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
|
||||
comboBanner.value = {
|
||||
show: true,
|
||||
text: `连消 ×${chain} +${points}`,
|
||||
tier
|
||||
}
|
||||
if (comboBannerTimer) clearTimeout(comboBannerTimer)
|
||||
comboBannerTimer = setTimeout(() => {
|
||||
comboBanner.value = { ...comboBanner.value, show: false }
|
||||
}, 1100)
|
||||
}
|
||||
|
||||
function triggerShake(intensity = 1) {
|
||||
screenShaking.value = true
|
||||
if (shakeTimer) clearTimeout(shakeTimer)
|
||||
shakeTimer = setTimeout(() => {
|
||||
screenShaking.value = false
|
||||
}, intensity >= 2 ? 420 : 280)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
floatingTexts.value = []
|
||||
burstParticles.value = []
|
||||
ripples.value = []
|
||||
comboBanner.value = { show: false, text: '', tier: 1 }
|
||||
screenShaking.value = false
|
||||
flash.value = { active: false, color: '#ffffff', opacity: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
floatingTexts,
|
||||
burstParticles,
|
||||
ripples,
|
||||
comboBanner,
|
||||
screenShaking,
|
||||
flash,
|
||||
spawnFloatingText,
|
||||
spawnBurst,
|
||||
spawnRipple,
|
||||
showComboBanner,
|
||||
triggerShake,
|
||||
triggerFlash,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 游戏进度持久化:关卡、星级、连胜、每日打卡
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { calcLevelStars } from '../data/gameLevels.js'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_progress_v1'
|
||||
|
||||
const DEFAULT = {
|
||||
currentLevel: 1,
|
||||
maxUnlocked: 1,
|
||||
stars: {},
|
||||
winStreak: 0,
|
||||
dailyStreak: 0,
|
||||
lastPlayDate: '',
|
||||
totalWins: 0,
|
||||
bestCombo: 0,
|
||||
totalStars: 0
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function sumStars(starsMap) {
|
||||
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
}
|
||||
|
||||
function loadRaw() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
|
||||
} catch (_) {}
|
||||
return { ...DEFAULT }
|
||||
}
|
||||
|
||||
function saveRaw(data) {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, data)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function useGameProgress() {
|
||||
const progress = ref(loadRaw())
|
||||
|
||||
function persist() {
|
||||
progress.value.totalStars = sumStars(progress.value.stars)
|
||||
saveRaw(progress.value)
|
||||
}
|
||||
|
||||
/** 进入游戏时更新每日打卡 */
|
||||
function touchDailyPlay() {
|
||||
const today = todayStr()
|
||||
const last = progress.value.lastPlayDate
|
||||
if (last === today) return progress.value.dailyStreak
|
||||
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||
|
||||
if (last === yStr) {
|
||||
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
|
||||
} else {
|
||||
progress.value.dailyStreak = 1
|
||||
}
|
||||
progress.value.lastPlayDate = today
|
||||
persist()
|
||||
return progress.value.dailyStreak
|
||||
}
|
||||
|
||||
function getStars(levelId) {
|
||||
return progress.value.stars[String(levelId)] || 0
|
||||
}
|
||||
|
||||
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
|
||||
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
|
||||
const key = String(levelId)
|
||||
const prev = progress.value.stars[key] || 0
|
||||
if (stars > prev) progress.value.stars[key] = stars
|
||||
|
||||
progress.value.winStreak = (progress.value.winStreak || 0) + 1
|
||||
progress.value.totalWins = (progress.value.totalWins || 0) + 1
|
||||
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
|
||||
|
||||
const next = levelId + 1
|
||||
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
|
||||
progress.value.currentLevel = next
|
||||
persist()
|
||||
return { stars, newBest: stars > prev }
|
||||
}
|
||||
|
||||
function recordLoss() {
|
||||
progress.value.winStreak = 0
|
||||
persist()
|
||||
}
|
||||
|
||||
function setCurrentLevel(levelId) {
|
||||
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
|
||||
persist()
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
touchDailyPlay,
|
||||
getStars,
|
||||
recordWin,
|
||||
recordLoss,
|
||||
setCurrentLevel,
|
||||
persist
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
|
||||
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
|
||||
*/
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_sfx_enabled'
|
||||
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
|
||||
|
||||
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
|
||||
const SRC = {
|
||||
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
|
||||
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
|
||||
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
|
||||
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
|
||||
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
|
||||
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
|
||||
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
|
||||
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
|
||||
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
|
||||
}
|
||||
|
||||
/**
|
||||
* 音效表:src / volume / rate(playbackRate) / pool
|
||||
* rate 不同可在同一素材上听出明显差异
|
||||
*/
|
||||
const SFX = {
|
||||
// —— 交互 ——
|
||||
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
||||
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
|
||||
|
||||
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
|
||||
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
|
||||
|
||||
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
|
||||
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 消除(按连数)——
|
||||
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
|
||||
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
|
||||
|
||||
// —— 消除(按 GI 主色)——
|
||||
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
|
||||
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
|
||||
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
|
||||
|
||||
// —— 连消 ——
|
||||
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
|
||||
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
|
||||
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 道具 ——
|
||||
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
|
||||
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
|
||||
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
|
||||
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
|
||||
|
||||
// —— 反馈 ——
|
||||
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
|
||||
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
|
||||
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
|
||||
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
|
||||
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
|
||||
|
||||
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
|
||||
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
|
||||
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
|
||||
|
||||
// 兼容旧名
|
||||
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
|
||||
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
|
||||
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
|
||||
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
|
||||
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
|
||||
}
|
||||
|
||||
class SfxPool {
|
||||
constructor(src, size, volume, rate = 1) {
|
||||
this.src = src
|
||||
this.size = size
|
||||
this.volume = volume
|
||||
this.rate = rate
|
||||
this.list = []
|
||||
this.cursor = 0
|
||||
this.warmedUp = false
|
||||
}
|
||||
|
||||
create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.volume
|
||||
try {
|
||||
ctx.playbackRate = this.rate
|
||||
} catch (_) {}
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (!this.list.length) this.create()
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.volume
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play(scale = 1, rateMul = 1) {
|
||||
if (!this.list.length) this.create()
|
||||
const ctx = this.list[this.cursor % this.list.length]
|
||||
this.cursor += 1
|
||||
const vol = Math.min(1, this.volume * scale)
|
||||
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
|
||||
try {
|
||||
ctx.volume = vol
|
||||
try {
|
||||
ctx.playbackRate = rate
|
||||
} catch (_) {}
|
||||
ctx.stop()
|
||||
ctx.seek(0)
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
try {
|
||||
ctx.play()
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.destroy()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
function dominantGi(tiles = []) {
|
||||
const cnt = { low: 0, mid: 0, high: 0 }
|
||||
tiles.forEach((t) => {
|
||||
const g = t?.type?.gi
|
||||
if (g && cnt[g] !== undefined) cnt[g] += 1
|
||||
})
|
||||
let best = 'mid'
|
||||
let max = 0
|
||||
Object.entries(cnt).forEach(([k, v]) => {
|
||||
if (v > max) {
|
||||
max = v
|
||||
best = k
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
|
||||
export function useGameSfx() {
|
||||
const enabled = ref(true)
|
||||
const pools = {}
|
||||
|
||||
try {
|
||||
const stored = uni.getStorageSync(STORAGE_KEY)
|
||||
if (stored === false || stored === '0' || stored === 0) {
|
||||
enabled.value = false
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function ensureAudioOption() {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function getPool(name) {
|
||||
const cfg = SFX[name]
|
||||
if (!cfg) return null
|
||||
if (!pools[name]) {
|
||||
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
|
||||
}
|
||||
return pools[name]
|
||||
}
|
||||
|
||||
function warmUp() {
|
||||
if (!enabled.value) return
|
||||
ensureAudioOption()
|
||||
Object.keys(SFX).forEach((name) => {
|
||||
getPool(name)?.warmUp()
|
||||
})
|
||||
}
|
||||
|
||||
function play(name, scale = 1, rateMul = 1) {
|
||||
if (!enabled.value) return
|
||||
getPool(name)?.play(scale, rateMul)
|
||||
}
|
||||
|
||||
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
|
||||
play(primary)
|
||||
if (secondary) {
|
||||
setTimeout(() => play(secondary, secScale), delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
|
||||
if (!enabled.value) return
|
||||
const giTrack = (() => {
|
||||
const gi = dominantGi(tiles)
|
||||
if (gi === 'low') return 'matchLowGi'
|
||||
if (gi === 'high') return 'matchHighGi'
|
||||
return 'matchMidGi'
|
||||
})()
|
||||
|
||||
if (chain >= 3) {
|
||||
playLayer('comboMega', giTrack, 90, 0.55)
|
||||
return
|
||||
}
|
||||
if (chain === 2) {
|
||||
playLayer('combo3', giTrack, 80, 0.5)
|
||||
return
|
||||
}
|
||||
if (chain === 1) {
|
||||
playLayer('combo2', giTrack, 70, 0.45)
|
||||
return
|
||||
}
|
||||
|
||||
if (matchCount >= 5) {
|
||||
playLayer('match5', 'matchHighGi', 85, 0.5)
|
||||
} else if (matchCount >= 4) {
|
||||
playLayer('match4', giTrack, 65, 0.48)
|
||||
} else {
|
||||
play(giTrack, 1, 1)
|
||||
setTimeout(() => play('match3', 0.85), 40)
|
||||
}
|
||||
}
|
||||
|
||||
function playDrop(count = 1) {
|
||||
if (!enabled.value || count <= 0) return
|
||||
const name = count >= 4 ? 'drop' : 'dropLight'
|
||||
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
|
||||
}
|
||||
|
||||
function playMeter(delta, level) {
|
||||
if (!enabled.value) return
|
||||
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
|
||||
else if (level < 30) play('meterWarn', 0.9, 0.95)
|
||||
else if (delta > 8) play('meterWarn', 0.75)
|
||||
else if (delta < -5) play('meterStable', 1.1)
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
enabled.value = !enabled.value
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
|
||||
} catch (_) {}
|
||||
if (enabled.value) {
|
||||
warmUp()
|
||||
play('select')
|
||||
}
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(pools).forEach((key) => {
|
||||
pools[key]?.destroy()
|
||||
delete pools[key]
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyAll()
|
||||
})
|
||||
|
||||
return {
|
||||
enabled,
|
||||
play,
|
||||
playLayer,
|
||||
playMatch,
|
||||
playDrop,
|
||||
playMeter,
|
||||
warmUp,
|
||||
toggleEnabled,
|
||||
destroyAll
|
||||
}
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
/**
|
||||
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
|
||||
*/
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
function gid(key, suffix) {
|
||||
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
|
||||
}
|
||||
|
||||
function wrap(key, body) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
|
||||
}
|
||||
|
||||
function shadow(key) {
|
||||
const id = gid(key, 'sh')
|
||||
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
|
||||
}
|
||||
|
||||
function shine(x, y, rx, ry) {
|
||||
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
|
||||
}
|
||||
|
||||
/** 圆形水果 */
|
||||
function artRound(key, base, dark, light, extra = '') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
|
||||
<stop offset="0%" stop-color="${light}"/>
|
||||
<stop offset="55%" stop-color="${base}"/>
|
||||
<stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
|
||||
${shine(24, 28, 7, 5)}
|
||||
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
|
||||
${extra}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 叶菜 */
|
||||
function artLeafy(key, light, mid, dark) {
|
||||
const g1 = gid(key, 'g1')
|
||||
const g2 = gid(key, 'g2')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
|
||||
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
|
||||
${shine(26, 24, 6, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西兰花 */
|
||||
function artBroccoli(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
|
||||
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
|
||||
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
|
||||
${shine(28, 20, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 长条(黄瓜/香蕉/玉米) */
|
||||
function artLong(key, base, dark, light, type = 'cucumber') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'banana') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
|
||||
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
|
||||
${shine(30, 26, 5, 3)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'corn') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
|
||||
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
|
||||
Array.from({ length: 4 }, (_, c) =>
|
||||
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
|
||||
).join('')
|
||||
).join('')}</g>
|
||||
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
|
||||
${shine(26, 26, 5, 8)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
|
||||
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 茄子 */
|
||||
function artEggplant(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
|
||||
${shine(26, 32, 5, 7)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 番茄 */
|
||||
function artTomato(key) {
|
||||
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
|
||||
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
|
||||
}
|
||||
|
||||
/** 甜椒 */
|
||||
function artPepper(key, color, dark) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
|
||||
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
|
||||
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
|
||||
${shine(28, 26, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 洋葱 */
|
||||
function artOnion(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
|
||||
${shine(24, 30, 6, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 葡萄串 */
|
||||
function artGrapes(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
|
||||
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
|
||||
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
${shine(28, 24, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 樱桃 */
|
||||
function artCherry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
|
||||
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
|
||||
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 草莓 */
|
||||
function artStrawberry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
|
||||
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
|
||||
${shine(26, 28, 4, 6)}
|
||||
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
|
||||
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
|
||||
).join('')}</g>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 豆类 */
|
||||
function artBeans(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
|
||||
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
|
||||
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饮品杯 */
|
||||
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'bottle') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
|
||||
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
|
||||
${shine(28, 30, 4, 10)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'wine') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
|
||||
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
|
||||
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
|
||||
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
|
||||
${shine(26, 32, 4, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蘑菇 */
|
||||
function artMushroom(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
|
||||
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
|
||||
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
|
||||
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
|
||||
${shine(24, 24, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 芹菜 */
|
||||
function artCelery(key) {
|
||||
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
|
||||
}
|
||||
|
||||
/** 四季豆 */
|
||||
function artGreenBean(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
|
||||
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饭碗 */
|
||||
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
|
||||
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
|
||||
${shine(24, 34, 6, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯类 */
|
||||
function artRoot(key, base, dark, light) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
|
||||
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
|
||||
${shine(24, 30, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 肉类 */
|
||||
function artMeat(key, base, dark, type = 'steak') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'chicken') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'fish') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
|
||||
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
|
||||
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
|
||||
${shine(28, 30, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
|
||||
${shine(26, 28, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 虾/蟹 */
|
||||
function artSeafood(key, type = 'shrimp') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'crab') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
|
||||
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
|
||||
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
|
||||
${shine(30, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 菠萝 */
|
||||
function artPineapple(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
|
||||
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
|
||||
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
|
||||
).join('')}</g>
|
||||
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 奇异果 */
|
||||
function artKiwi(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
|
||||
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
|
||||
${shine(26, 28, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蜜瓜 */
|
||||
function artMelon(key) {
|
||||
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
|
||||
}
|
||||
|
||||
/** 蜂蜜 */
|
||||
function artHoney(key) {
|
||||
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
|
||||
}
|
||||
|
||||
/** 面包类 */
|
||||
function artBread(key, base, dark, type = 'loaf') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'donut') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
|
||||
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
${shine(24, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'mantou') {
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
|
||||
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
|
||||
${shine(24, 32, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
|
||||
${shine(22, 28, 8, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 面条 */
|
||||
function artNoodle(key, soup = '#d7ccc8') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
|
||||
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
|
||||
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 南瓜 */
|
||||
function artPumpkin(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
|
||||
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
|
||||
${shine(26, 28, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯条 */
|
||||
function artFries(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
|
||||
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西瓜 */
|
||||
function artWatermelon(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
|
||||
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
|
||||
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
|
||||
${shine(24, 30, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糖果 */
|
||||
function artCandy(key, base, dark, type = 'lollipop') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'wrap') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
|
||||
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
|
||||
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
|
||||
${shine(26, 24, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糯米团 */
|
||||
function artDango(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="22" cy="36" r="8" fill="#fff"/>
|
||||
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="36" r="8" fill="#fff"/>
|
||||
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 爆米花 */
|
||||
function artPopcorn(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
|
||||
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
|
||||
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
const FOOD_ART = {
|
||||
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
|
||||
broccoli: () => artBroccoli('broccoli'),
|
||||
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
|
||||
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
|
||||
eggplant: () => artEggplant('eggplant'),
|
||||
tomato: () => artTomato('tomato'),
|
||||
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
|
||||
onion: () => artOnion('onion'),
|
||||
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
|
||||
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
|
||||
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
|
||||
cherry: () => artCherry('cherry'),
|
||||
grape: () => artGrapes('grape'),
|
||||
strawberry: () => artStrawberry('strawberry'),
|
||||
soybean: () => artBeans('soybean'),
|
||||
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
|
||||
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
|
||||
mushroom: () => artMushroom('mushroom'),
|
||||
celery: () => artCelery('celery'),
|
||||
greenBean: () => artGreenBean('greenBean'),
|
||||
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
|
||||
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
|
||||
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
|
||||
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
|
||||
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
|
||||
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
|
||||
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
|
||||
shrimp: () => artSeafood('shrimp', 'shrimp'),
|
||||
crab: () => artSeafood('crab', 'crab'),
|
||||
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
|
||||
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
|
||||
pineapple: () => artPineapple('pineapple'),
|
||||
kiwi: () => artKiwi('kiwi'),
|
||||
melon: () => artMelon('melon'),
|
||||
honey: () => artHoney('honey'),
|
||||
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
|
||||
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
|
||||
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
|
||||
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
|
||||
udon: () => artNoodle('udon', '#d9b88f'),
|
||||
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
|
||||
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
|
||||
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
|
||||
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
|
||||
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
|
||||
popcorn: () => artPopcorn('popcorn'),
|
||||
ramen: () => artNoodle('ramen', '#e0a96d'),
|
||||
pumpkin: () => artPumpkin('pumpkin'),
|
||||
bakedPotato: () => artFries('bakedPotato'),
|
||||
watermelon: () => artWatermelon('watermelon'),
|
||||
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
|
||||
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
|
||||
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
|
||||
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
|
||||
stickyRice: () => artDango('stickyRice')
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
/** 获取食材插画 data URL(带缓存) */
|
||||
export function getFoodImg(key) {
|
||||
if (imgCache[key]) return imgCache[key]
|
||||
const builder = FOOD_ART[key]
|
||||
if (!builder) return ''
|
||||
imgCache[key] = svgToDataUrl(builder())
|
||||
return imgCache[key]
|
||||
}
|
||||
|
||||
export function warmUpFoodImgs(keys) {
|
||||
keys.forEach((k) => getFoodImg(k))
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 食材图标 URL(Twemoji PNG,小程序 / H5 通用)
|
||||
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
|
||||
*/
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
|
||||
|
||||
/** emoji → Twemoji PNG 地址 */
|
||||
export function emojiToTwemojiUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
const parts = []
|
||||
for (let i = 0; i < emoji.length;) {
|
||||
const cp = emoji.codePointAt(i)
|
||||
if (!cp) break
|
||||
parts.push(cp.toString(16))
|
||||
i += cp > 0xffff ? 2 : 1
|
||||
}
|
||||
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
export function getFoodImgUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
if (imgCache[emoji]) return imgCache[emoji]
|
||||
const url = emojiToTwemojiUrl(emoji)
|
||||
imgCache[emoji] = url
|
||||
return url
|
||||
}
|
||||
|
||||
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
|
||||
export function warmUpFoodImgs(foods) {
|
||||
const list = Array.isArray(foods) ? foods : []
|
||||
list.forEach((f) => {
|
||||
const icon = typeof f === 'string' ? f : f?.icon
|
||||
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||
if (!url) return
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.getImageInfo({ src: url, fail: () => {} })
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
|
||||
*/
|
||||
import { getFoodImgUrl } from './gameFoodImg.js'
|
||||
|
||||
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
|
||||
|
||||
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
|
||||
export function getEatVal(food) {
|
||||
if (food.eatVal != null) return food.eatVal
|
||||
if (food.gi === 'high') return Math.abs(food.val)
|
||||
if (food.gi === 'mid') return Math.max(1, food.val)
|
||||
return Math.max(1, Math.abs(food.val))
|
||||
}
|
||||
|
||||
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
|
||||
export function getClearVal(food) {
|
||||
if (food.clearVal != null) return food.clearVal
|
||||
const eat = getEatVal(food)
|
||||
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
|
||||
if (food.gi === 'mid') return -1
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 点击食材时的升糖说明文案 */
|
||||
export function formatEatLabel(food) {
|
||||
if (food.gi === 'low') return '升糖低'
|
||||
return `若食用 +${getEatVal(food)}`
|
||||
}
|
||||
|
||||
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
|
||||
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
|
||||
|
||||
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
|
||||
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
|
||||
|
||||
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
|
||||
const FOOD_TIP_OVERRIDE = {
|
||||
whiteRice: '精制主食,升糖快,建议小份',
|
||||
stickyRice: '糯米黏软,升糖很快,要少吃',
|
||||
whiteBread: '精制面食,升糖快',
|
||||
mantou: '白面馒头,升糖快,配菜一起吃更稳',
|
||||
youtiao: '油炸又高糖,少吃为好',
|
||||
sugar: '纯糖,升糖最快,尽量不吃',
|
||||
maltose: '糖分很高,升糖快',
|
||||
soda: '含糖饮料,升糖很快,不建议喝',
|
||||
orangeJuice: '果汁含糖高,吃整果更好',
|
||||
watermelon: '很甜,升糖快,一次别吃太多',
|
||||
donut: '又甜又油,升糖高',
|
||||
banana: '偏甜水果,适量吃',
|
||||
sweetPotato: '粗粮但有糖,可代替部分主食',
|
||||
corn: '粗粮主食,适量吃',
|
||||
honey: '本质是糖,别当健康品多吃',
|
||||
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
|
||||
cucumber: '清爽蔬菜,几乎不升糖',
|
||||
lettuce: '绿叶菜,升糖很低,可多吃',
|
||||
celery: '高纤维蔬菜,升糖低',
|
||||
apple: '带皮整果,升糖较慢',
|
||||
milk: '低糖,含蛋白,适量喝好',
|
||||
fish: '优质蛋白,升糖低',
|
||||
beef: '蛋白为主,升糖低',
|
||||
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
|
||||
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||
friedNoodles: '油多又是精面,升糖快',
|
||||
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
|
||||
sandwich: '夹心面包精制碳多,升糖快',
|
||||
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
|
||||
milletCongee: '小米粥熬软升糖快,糖友控量',
|
||||
shandongPancake: '煎饼加油面,升糖中等偏快',
|
||||
cake: '蛋糕又甜又油,升糖快',
|
||||
biscuit: '饼干又甜又碎,升糖快',
|
||||
wonton: '馄饨面皮精白,升糖中等,别多吃',
|
||||
blackCoffee: '不加糖的黑咖啡,升糖很低'
|
||||
}
|
||||
|
||||
/** 含糖等级通用科普文案 */
|
||||
const GI_TIP_DEFAULT = {
|
||||
low: '升糖慢,相对放心,可常吃',
|
||||
mid: '升糖中等,注意分量',
|
||||
high: '含糖高、升糖快,要少吃'
|
||||
}
|
||||
|
||||
/** 点击食物时展示的一句话科普(认知教育用) */
|
||||
export function getFoodTip(food) {
|
||||
if (!food) return ''
|
||||
if (food.tip) return food.tip
|
||||
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
|
||||
return GI_TIP_DEFAULT[food.gi] || ''
|
||||
}
|
||||
|
||||
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
|
||||
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
|
||||
|
||||
const RAW_FOODS = [
|
||||
// 低 GI
|
||||
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
||||
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
|
||||
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
|
||||
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
|
||||
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
|
||||
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
|
||||
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
|
||||
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
|
||||
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
|
||||
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
|
||||
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
|
||||
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
|
||||
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
|
||||
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
|
||||
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
|
||||
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
|
||||
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
|
||||
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
|
||||
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
|
||||
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
|
||||
|
||||
// 中 GI
|
||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||
{ 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' },
|
||||
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
|
||||
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
|
||||
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
|
||||
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
|
||||
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
|
||||
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
|
||||
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
|
||||
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
|
||||
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
|
||||
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
|
||||
{ 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', 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', 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' },
|
||||
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
|
||||
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
|
||||
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
|
||||
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
|
||||
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
||||
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
||||
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
||||
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
|
||||
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
|
||||
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
|
||||
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
|
||||
]
|
||||
|
||||
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
const eatVal = getEatVal(f)
|
||||
const clearVal = getClearVal({ ...f, eatVal })
|
||||
return {
|
||||
id: i + 1,
|
||||
...f,
|
||||
eatVal,
|
||||
clearVal,
|
||||
giLabel: GI_LABEL[f.gi],
|
||||
get img() {
|
||||
// 优先使用自定义图片素材,缺省回退到 emoji PNG
|
||||
return f.img || getFoodImgUrl(f.icon)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 按 key 查找食材(目标栏等用) */
|
||||
export function getFoodByKey(key) {
|
||||
return FOOD_LIBRARY.find((f) => f.key === key) || null
|
||||
}
|
||||
|
||||
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
|
||||
export function cloneFoodType(food) {
|
||||
return {
|
||||
id: food.id,
|
||||
key: food.key,
|
||||
icon: food.icon,
|
||||
name: food.name,
|
||||
gi: food.gi,
|
||||
giLabel: food.giLabel,
|
||||
val: food.val,
|
||||
color: food.color,
|
||||
img: food.img || getFoodImgUrl(food.icon)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
||||
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
||||
*/
|
||||
/**
|
||||
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
||||
* 第一关固定为这 16 种带真实图片素材的食物。
|
||||
*/
|
||||
const FIXED_LEVEL_FOODS = {
|
||||
1: [
|
||||
'sandwich',
|
||||
'eightTreasureCongee',
|
||||
'milkOats',
|
||||
'milletCongee',
|
||||
'shandongPancake',
|
||||
'grainMantou',
|
||||
'riceNoodleSoup',
|
||||
'youtiao',
|
||||
'friedNoodles',
|
||||
'sweetPotato',
|
||||
'centuryEggCongee',
|
||||
'whiteRice',
|
||||
'cake',
|
||||
'biscuit',
|
||||
'wonton',
|
||||
'blackCoffee'
|
||||
]
|
||||
}
|
||||
|
||||
export function getLevelConfig(levelId) {
|
||||
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
||||
const tier = Math.floor((lv - 1) / 5)
|
||||
|
||||
return {
|
||||
id: lv,
|
||||
// 步数充裕:仅作展示,认全目标即可通关
|
||||
moves: Math.max(40, 60 - tier * 2),
|
||||
// 认识目标平缓增长,避免给老人压力
|
||||
goalTargets: [6 + tier, 8 + tier],
|
||||
scoreTarget: 2000 + lv * 300,
|
||||
startGlucose: 50,
|
||||
// 固定食材(可选):存在时本关只用这些食物
|
||||
foods: FIXED_LEVEL_FOODS[lv] || null,
|
||||
boosters: {
|
||||
insulin: 3,
|
||||
fiber: 1,
|
||||
meal: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
|
||||
export function calcLevelStars({ movesLeft, levelConfig }) {
|
||||
const totalMoves = levelConfig?.moves || 1
|
||||
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
|
||||
let stars = 1
|
||||
if (ratio >= 0.3) stars = 2
|
||||
if (ratio >= 0.55) stars = 3
|
||||
return stars
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# 识糖小课堂平台接入说明
|
||||
|
||||
## 已接入能力
|
||||
|
||||
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
||||
- 按周一日期、性别自动分配最多 7 人的同行组。
|
||||
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
||||
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
||||
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
||||
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
|
||||
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
|
||||
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
|
||||
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key`、`learned_count`、`score`、`ended`。
|
||||
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
|
||||
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`。
|
||||
|
||||
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`。
|
||||
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php` 和 `TcmController.php`。
|
||||
3. 重新构建并上传小程序前端。
|
||||
|
||||
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
|
||||
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
|
||||
|
||||
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
|
||||
|
||||
## 上线前检查
|
||||
|
||||
- 用男女各两个测试账号进入,确认被分入对应性别组。
|
||||
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
||||
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
||||
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
||||
- 周一验证新周重新分组,旧周成绩不带入新周。
|
||||
@@ -1,18 +1,18 @@
|
||||
# 控糖消消乐独立功能包
|
||||
# 识糖小课堂独立功能包
|
||||
|
||||
此目录包含“控糖消消乐”无尽三消版的页面与运行代码,可以整体复制到另一个 uni-app Vue 3 项目的 `tongji/endless-game/` 下。食品图片由远端 COS 提供,不再存放于功能目录中。
|
||||
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
|
||||
- `game-endless.scss`:完整页面与动画样式。
|
||||
- `components/TongjiIcon.vue`:本功能包使用的图标组件。
|
||||
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
|
||||
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
|
||||
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
|
||||
- `utils/svgDataUrl.js`:图标编码工具。
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
1. 整体复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录。
|
||||
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue` 与 `tongji/utils/svgDataUrl.js`。
|
||||
2. 在目标项目 `pages.json` 的 `tongji` 分包 `pages` 数组中加入:
|
||||
|
||||
```json
|
||||
@@ -20,9 +20,11 @@
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控糖消消乐",
|
||||
"navigationBarTitleText": "识糖小课堂",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false
|
||||
"disableScroll": false,
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -32,10 +34,10 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前项目另有 `/tongji/pages/game` 页面,不属于本独立功能包,迁移时无需复制。
|
||||
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
|
||||
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
|
||||
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
|
||||
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
|
||||
- 进入游戏时展示“本周 7 人同行榜”界面原型,包含前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。当前榜单明确标注为预览数据,正式用户关系和周榜成绩需要后端接口接入。
|
||||
- 本功能包依赖 uni-app Vue 3 和 `@dcloudio/uni-app`,不依赖当前项目其他业务组件。
|
||||
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`。
|
||||
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
|
||||
- 食物风险等级和科普文案集中在 `index.vue` 的 `FOODS` 配置中,正式上线前仍需由医院医生审核。
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
<template>
|
||||
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
|
||||
<image
|
||||
v-if="!useFallback"
|
||||
class="tj-icon"
|
||||
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
|
||||
:src="iconSrc"
|
||||
mode="aspectFit"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<!-- <text
|
||||
v-else
|
||||
class="tj-icon-fallback"
|
||||
:class="[`tj-icon-fallback--${size}`]"
|
||||
:style="{ color }"
|
||||
>{{ fallbackGlyph }}</text> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: String, default: 'md' },
|
||||
color: { type: String, default: '#204E2B' }
|
||||
})
|
||||
|
||||
const useFallback = ref(false)
|
||||
|
||||
/** Lucide 风格描边路径 */
|
||||
const ICON_PATHS = {
|
||||
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
|
||||
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
|
||||
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
||||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
|
||||
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
|
||||
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
|
||||
plus: '<path d="M5 12h14M12 5v14"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
|
||||
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
|
||||
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
|
||||
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
|
||||
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
|
||||
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
|
||||
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
|
||||
}
|
||||
|
||||
/** 图片加载失败时的 emoji 回退 */
|
||||
const ICON_FALLBACK = {
|
||||
view: '👁',
|
||||
ticket: '🎫',
|
||||
calendar: '📅',
|
||||
flame: '🔥',
|
||||
'check-circle': '✓',
|
||||
glucose: '💧',
|
||||
heart: '❤',
|
||||
activity: '🏃',
|
||||
users: '👥',
|
||||
user: '👤',
|
||||
person: '👤',
|
||||
send: '➤',
|
||||
'alert-triangle': '⚠',
|
||||
minus: '—',
|
||||
droplet: '💧',
|
||||
sparkles: '✨',
|
||||
trophy: '🏆',
|
||||
share: '↗',
|
||||
plus: '+',
|
||||
refresh: '↻',
|
||||
volume: '🔊',
|
||||
pause: '⏸',
|
||||
play: '▶',
|
||||
info: '!',
|
||||
sun: '☀',
|
||||
moon: '🌙',
|
||||
sunset: '☀',
|
||||
mic: '🎤',
|
||||
camera: '📷',
|
||||
bulb: '💡',
|
||||
leaf: '🥬',
|
||||
utensils: '🍴',
|
||||
egg: '🍳',
|
||||
home: '🏠',
|
||||
'plus-circle': '⊕',
|
||||
'chevron-right': '›',
|
||||
'chevron-left': '‹',
|
||||
check: '✓',
|
||||
settings: '⚙',
|
||||
syringe: '💉',
|
||||
zap: '⚡'
|
||||
}
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
const c = String(props.color || '#204E2B').trim()
|
||||
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
const path = ICON_PATHS[props.name] || ICON_PATHS.view
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
return svgToDataUrl(svg)
|
||||
})
|
||||
|
||||
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
|
||||
|
||||
function onImageError() {
|
||||
useFallback.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tj-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tj-icon-wrap--sm {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
.tj-icon-wrap--md {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.tj-icon-wrap--lg {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.tj-icon-wrap--xl {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
.tj-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
|
||||
.tj-icon--send {
|
||||
transform: translate(-10%, 10%);
|
||||
}
|
||||
.tj-icon-fallback {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.tj-icon-fallback--sm {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.tj-icon-fallback--md {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.tj-icon-fallback--lg {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.tj-icon-fallback--xl {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
|
||||
*/
|
||||
export function useGameAuth(proxy) {
|
||||
let loginPromise = null
|
||||
|
||||
function hasToken() {
|
||||
return !!String(uni.getStorageSync('token') || '').trim()
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
uni.removeStorageSync('token')
|
||||
}
|
||||
|
||||
function wxLoginCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithCode(code) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/login/mnpLogin',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}, false)
|
||||
if (res?.code !== 1 || !res.data?.token) {
|
||||
clearToken()
|
||||
throw new Error(res?.msg || '登录失败')
|
||||
}
|
||||
uni.setStorageSync('token', res.data.token)
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
|
||||
async function verifyOrLogin() {
|
||||
if (hasToken()) {
|
||||
try {
|
||||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
|
||||
if (res?.code !== -1) return true
|
||||
} catch (_) {
|
||||
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
|
||||
return true
|
||||
}
|
||||
clearToken()
|
||||
}
|
||||
const code = await wxLoginCode()
|
||||
return loginWithCode(code)
|
||||
}
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
if (loginPromise) return loginPromise
|
||||
loginPromise = verifyOrLogin()
|
||||
.then(ok => !!ok)
|
||||
.catch(() => false)
|
||||
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
|
||||
.finally(() => { loginPromise = null })
|
||||
return loginPromise
|
||||
}
|
||||
|
||||
return { ensureLoggedIn }
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const EMPTY_BOARD = {
|
||||
week_start: '',
|
||||
week_end: '',
|
||||
sex_label: '同行',
|
||||
group_size: 7,
|
||||
member_count: 0,
|
||||
players: [],
|
||||
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
|
||||
invite_code: ''
|
||||
}
|
||||
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
|
||||
|
||||
function createSessionKey() {
|
||||
const random = Math.random().toString(36).slice(2, 12)
|
||||
return `game_${Date.now().toString(36)}_${random}`
|
||||
}
|
||||
|
||||
function readPendingPayloads() {
|
||||
try {
|
||||
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
|
||||
if (!Array.isArray(stored)) return []
|
||||
return stored.filter(item => (
|
||||
item
|
||||
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
|
||||
&& Number(item.learned_count) >= 0
|
||||
)).slice(-8)
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
|
||||
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
|
||||
*/
|
||||
export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
const connected = ref(false)
|
||||
const loading = ref(false)
|
||||
const syncStatus = ref('idle')
|
||||
const leaderboard = ref({ ...EMPTY_BOARD })
|
||||
const confirmedSessionLearned = ref(0)
|
||||
|
||||
let sessionKey = createSessionKey()
|
||||
let syncTimer = null
|
||||
let syncing = false
|
||||
let queuedPayloads = readPendingPayloads()
|
||||
let connectPromise = null
|
||||
|
||||
function persistPendingPayloads() {
|
||||
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
|
||||
}
|
||||
|
||||
async function api(request) {
|
||||
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
|
||||
return proxy.apiUrl(request, false)
|
||||
}
|
||||
|
||||
function applyLeaderboard(data, confirmedForSession = '') {
|
||||
if (!data || !Array.isArray(data.players)) return
|
||||
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
|
||||
if (!inactiveSessionResponse) {
|
||||
leaderboard.value = {
|
||||
...EMPTY_BOARD,
|
||||
...data,
|
||||
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
|
||||
players: data.players
|
||||
}
|
||||
}
|
||||
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
|
||||
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
|
||||
}
|
||||
connected.value = true
|
||||
syncStatus.value = 'synced'
|
||||
}
|
||||
|
||||
async function refreshLeaderboard() {
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameWeeklyLeaderboard',
|
||||
method: 'GET'
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '同行榜加载失败')
|
||||
}
|
||||
applyLeaderboard(res.data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function acceptShare(inviteCode) {
|
||||
const code = String(inviteCode || '').trim().toUpperCase()
|
||||
if (!code) return
|
||||
try {
|
||||
await api({
|
||||
url: '/api/tcm/gameAcceptShare',
|
||||
method: 'POST',
|
||||
data: { invite_code: code }
|
||||
})
|
||||
} catch (_) {
|
||||
// 分享关系是辅助能力,不阻断进入游戏。
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(inviteCode = '') {
|
||||
if (connectPromise) return connectPromise
|
||||
connectPromise = (async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const loggedIn = await ensureLoggedIn()
|
||||
if (!loggedIn) throw new Error('登录失败')
|
||||
await acceptShare(inviteCode)
|
||||
await refreshLeaderboard()
|
||||
if (queuedPayloads.length) flushProgress()
|
||||
return true
|
||||
} catch (_) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
connectPromise = null
|
||||
}
|
||||
})()
|
||||
return connectPromise
|
||||
}
|
||||
|
||||
function beginSession() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
sessionKey = createSessionKey()
|
||||
confirmedSessionLearned.value = 0
|
||||
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
function queueProgress(learnedCount, score, ended = false) {
|
||||
const nextPayload = {
|
||||
session_key: sessionKey,
|
||||
learned_count: Math.max(0, Number(learnedCount) || 0),
|
||||
score: Math.max(0, Number(score) || 0),
|
||||
ended: ended ? 1 : 0
|
||||
}
|
||||
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
|
||||
if (existingIndex >= 0) {
|
||||
const existing = queuedPayloads[existingIndex]
|
||||
queuedPayloads[existingIndex] = {
|
||||
...nextPayload,
|
||||
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
|
||||
score: Math.max(existing.score, nextPayload.score),
|
||||
ended: Math.max(existing.ended, nextPayload.ended)
|
||||
}
|
||||
} else {
|
||||
queuedPayloads.push(nextPayload)
|
||||
}
|
||||
persistPendingPayloads()
|
||||
syncStatus.value = 'pending'
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
if (ended) {
|
||||
flushProgress()
|
||||
} else {
|
||||
syncTimer = setTimeout(flushProgress, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushProgress() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
if (syncing || !queuedPayloads.length) return
|
||||
const payload = queuedPayloads[0]
|
||||
syncing = true
|
||||
syncStatus.value = 'syncing'
|
||||
try {
|
||||
if (!connected.value) {
|
||||
const loggedIn = await ensureLoggedIn()
|
||||
if (!loggedIn) throw new Error('未登录')
|
||||
}
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameSubmitProgress',
|
||||
method: 'POST',
|
||||
data: payload
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '成绩保存失败')
|
||||
}
|
||||
applyLeaderboard(res.data, payload.session_key)
|
||||
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
|
||||
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
|
||||
queuedPayloads = queuedPayloads.filter(item => (
|
||||
item.session_key !== payload.session_key
|
||||
|| Number(item.learned_count) > Number(payload.learned_count)
|
||||
|| Number(item.score) > Number(payload.score)
|
||||
|| Number(item.ended) > Number(payload.ended)
|
||||
))
|
||||
persistPendingPayloads()
|
||||
} catch (_) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
||||
persistPendingPayloads()
|
||||
} finally {
|
||||
syncing = false
|
||||
if (queuedPayloads.length && connected.value) {
|
||||
setTimeout(flushProgress, 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAndRefresh(learnedCount, score) {
|
||||
queueProgress(learnedCount, score, false)
|
||||
await flushProgress()
|
||||
if (!connected.value) {
|
||||
await connect()
|
||||
if (queuedPayloads.length) await flushProgress()
|
||||
} else {
|
||||
try { await refreshLeaderboard() } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function recordShare() {
|
||||
try {
|
||||
if (!connected.value && !(await connect())) return
|
||||
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
|
||||
if (res?.code === 1 && res.data?.invite_code) {
|
||||
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
|
||||
}
|
||||
} catch (_) {
|
||||
// 分享本身仍可进行,统计失败不影响用户。
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
persistPendingPayloads()
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
loading,
|
||||
syncStatus,
|
||||
leaderboard,
|
||||
confirmedSessionLearned,
|
||||
connect,
|
||||
beginSession,
|
||||
queueProgress,
|
||||
flushProgress,
|
||||
syncAndRefresh,
|
||||
refreshLeaderboard,
|
||||
recordShare,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,7 @@
|
||||
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
|
||||
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
|
||||
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
|
||||
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
|
||||
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
|
||||
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
|
||||
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
|
||||
@@ -339,6 +340,7 @@
|
||||
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
||||
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
|
||||
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
|
||||
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
|
||||
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
|
||||
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
|
||||
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
|
||||
@@ -358,6 +360,8 @@
|
||||
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
|
||||
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
|
||||
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
|
||||
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-weekly-share::after { border: 0; }
|
||||
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
|
||||
|
||||
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
|
||||
@@ -394,6 +398,8 @@
|
||||
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
|
||||
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
|
||||
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
|
||||
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-share-btn::after { border: 0; }
|
||||
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
|
||||
@keyframes egPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="eg-title-wrap">
|
||||
<text class="eg-title">控糖消消乐</text>
|
||||
<text class="eg-title">识糖小课堂</text>
|
||||
<text class="eg-subtitle">第{{ themeIndex + 1 }}站 · {{ currentTheme.title }}</text>
|
||||
</view>
|
||||
<view class="eg-nav-actions">
|
||||
@@ -199,9 +199,9 @@
|
||||
<view>
|
||||
<view class="eg-weekly-title-line">
|
||||
<text class="eg-weekly-title">本周7人同行榜</text>
|
||||
<text class="eg-weekly-preview">界面预览</text>
|
||||
<text class="eg-weekly-preview" :class="{ 'is-offline': !platformConnected }">{{ weeklyStatusLabel }}</text>
|
||||
</view>
|
||||
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · 每周一更新</text>
|
||||
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · {{ weeklySexLabel }} · {{ weeklyMemberCount }}/7人</text>
|
||||
</view>
|
||||
<view class="eg-weekly-medal">{{ weeklyMyRank }}</view>
|
||||
</view>
|
||||
@@ -227,7 +227,10 @@
|
||||
:class="{ 'is-me': player.isMe }"
|
||||
>
|
||||
<text class="eg-weekly-place">{{ player.rank }}</text>
|
||||
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">{{ player.avatar }}</view>
|
||||
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">
|
||||
<image v-if="player.avatarUrl" class="eg-weekly-avatar-image" :src="player.avatarUrl" mode="aspectFill" />
|
||||
<text v-else>{{ player.avatar }}</text>
|
||||
</view>
|
||||
<text class="eg-weekly-name">{{ player.name }}</text>
|
||||
<text v-if="player.isMe" class="eg-weekly-me">我</text>
|
||||
<text class="eg-weekly-count">{{ player.count }}</text>
|
||||
@@ -244,7 +247,8 @@
|
||||
</view>
|
||||
|
||||
<view class="eg-weekly-start" @tap="closeWeeklyRank">开始消除</view>
|
||||
<text class="eg-weekly-footnote">当前为排行效果预览,真实好友数据接入后替换</text>
|
||||
<button class="eg-weekly-share" open-type="share">邀请亲友一起认糖</button>
|
||||
<text class="eg-weekly-footnote">使用平台昵称参加真实周榜,每周一重新分组;分享不会自动绑定家庭关系</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -286,6 +290,7 @@
|
||||
<view class="eg-result-score"><text>本局得分</text><text>{{ score }}</text></view>
|
||||
<view class="eg-result-best"><text>历史最高</text><text>{{ bestScore }}</text></view>
|
||||
<view class="eg-primary-btn" @tap="startGame">再玩一局</view>
|
||||
<button class="eg-share-btn" open-type="share">分享本局认糖成绩</button>
|
||||
<view class="eg-secondary-btn" @tap="goBack">返回血糖周报</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -294,10 +299,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import TongjiIcon from './components/TongjiIcon.vue'
|
||||
import { computed, getCurrentInstance, ref } from 'vue'
|
||||
import { onLoad, onShareAppMessage, onShareTimeline, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import TongjiIcon from '../components/TongjiIcon.vue'
|
||||
import { useGameSfx } from './composables/useGameSfx.js'
|
||||
import { useGamePlatform } from './composables/useGamePlatform.js'
|
||||
import { useGameAuth } from './composables/useGameAuth.js'
|
||||
|
||||
const FOOD_IMAGE_BASE_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/'
|
||||
|
||||
@@ -314,20 +321,6 @@ const COMBO_PITY_TURNS = 6
|
||||
const MEGA_COMBO_SCORE = 300
|
||||
const DANGER_DROP_CHANCE = 0.05 + GAME_DIFFICULTY * 0.01
|
||||
const COLUMN_GROUP_BIAS = 0.86 - GAME_DIFFICULTY * 0.028
|
||||
const WEEKLY_PREVIEW_BASE = 208
|
||||
const WEEKLY_PREVIEW_PLAYERS = [
|
||||
{ id: 'li', name: '李阿姨', avatar: '李', count: 286, tone: 1 },
|
||||
{ id: 'health', name: '健康每一天', avatar: '健', count: 248, tone: 2 },
|
||||
{ id: 'wang', name: '王师傅', avatar: '王', count: 226, tone: 3 },
|
||||
{ id: 'me', name: '我', avatar: '我', count: 0, tone: 4, isMe: true },
|
||||
{ id: 'happy', name: '快乐生活', avatar: '乐', count: 191, tone: 5 },
|
||||
{ id: 'chen', name: '陈叔', avatar: '陈', count: 174, tone: 6 },
|
||||
{ id: 'sunny', name: '天天好心情', avatar: '晴', count: 152, tone: 7 }
|
||||
]
|
||||
const WEEKLY_START_RANK = WEEKLY_PREVIEW_PLAYERS
|
||||
.map(player => ({ ...player, count: player.isMe ? WEEKLY_PREVIEW_BASE : player.count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.findIndex(player => player.isMe) + 1
|
||||
const CHEER_MESSAGES = [
|
||||
{ source: '同行鼓励', text: '你需要加油哦!再向前一点,就能超过上一名。', tone: 'warm' },
|
||||
{ source: '同行鼓励', text: '你太棒了!今天也在认真认识食物。', tone: 'bright' },
|
||||
@@ -400,6 +393,23 @@ const {
|
||||
warmUp: warmUpSfx
|
||||
} = useGameSfx()
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { ensureLoggedIn } = useGameAuth(proxy)
|
||||
const {
|
||||
connected: platformConnected,
|
||||
loading: platformLoading,
|
||||
syncStatus: platformSyncStatus,
|
||||
leaderboard: platformLeaderboard,
|
||||
confirmedSessionLearned,
|
||||
connect: connectPlatform,
|
||||
beginSession: beginPlatformSession,
|
||||
queueProgress: queuePlatformProgress,
|
||||
flushProgress: flushPlatformProgress,
|
||||
syncAndRefresh: syncAndRefreshPlatform,
|
||||
recordShare: recordPlatformShare,
|
||||
dispose: disposePlatform
|
||||
} = useGamePlatform(proxy, ensureLoggedIn)
|
||||
|
||||
const board = ref([])
|
||||
const selected = ref(-1)
|
||||
const score = ref(0)
|
||||
@@ -437,7 +447,6 @@ const reshuffleDone = ref(false)
|
||||
const reshuffling = ref(false)
|
||||
const confettiVisible = ref(false)
|
||||
const weeklyRankVisible = ref(false)
|
||||
const weeklyPreviewProgress = ref(0)
|
||||
const cheerIndex = ref(0)
|
||||
const CONFETTI_COLORS = ['#ff3154', '#ffd928', '#28c76f', '#4d8dff', '#a855f7', '#ff7a1a']
|
||||
const confettiPieces = Array.from({ length: 34 }, (_, index) => ({
|
||||
@@ -462,6 +471,7 @@ let taskRewardTimer = null
|
||||
let pendingThemeMatchCount = 0
|
||||
let taskRewardStatusUntil = 0
|
||||
let sfxGesturePrimed = false
|
||||
let incomingInviteCode = ''
|
||||
|
||||
const currentTheme = computed(() => FOOD_GROUPS[themeIndex.value % FOOD_GROUPS.length])
|
||||
const currentTask = computed(() => {
|
||||
@@ -479,13 +489,38 @@ const highCount = computed(() => board.value.filter(tile => tile && foodOf(tile.
|
||||
const boardRows = computed(() => Array.from({ length: ROWS }, (_, row) => (
|
||||
board.value.slice(row * COLS, row * COLS + COLS).map((tile, col) => ({ ...tile, index: row * COLS + col }))
|
||||
)))
|
||||
const weeklyMyCount = computed(() => WEEKLY_PREVIEW_BASE + weeklyPreviewProgress.value)
|
||||
const weeklyRankRows = computed(() => WEEKLY_PREVIEW_PLAYERS
|
||||
.map(player => ({ ...player, count: player.isMe ? weeklyMyCount.value : player.count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((player, index) => ({ ...player, rank: index + 1 })))
|
||||
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 7)
|
||||
const weeklyRankRise = computed(() => Math.max(0, WEEKLY_START_RANK - weeklyMyRank.value))
|
||||
const pendingWeeklyLearned = computed(() => Math.max(0, learned.value - confirmedSessionLearned.value))
|
||||
const weeklyMyCount = computed(() => Number(platformLeaderboard.value.me?.count || 0) + pendingWeeklyLearned.value)
|
||||
const weeklyRankRows = computed(() => {
|
||||
const remote = Array.isArray(platformLeaderboard.value.players)
|
||||
? platformLeaderboard.value.players
|
||||
: []
|
||||
const userData = uni.getStorageSync('userData') || {}
|
||||
const fallbackName = String(userData.nickname || '我')
|
||||
const source = remote.length
|
||||
? remote.map((player, index) => ({
|
||||
...player,
|
||||
isMe: !!(player.is_me || player.isMe),
|
||||
avatarUrl: player.avatar || '',
|
||||
avatar: String(player.name || '同行').slice(0, 1),
|
||||
tone: index % 7 + 1,
|
||||
count: player.is_me || player.isMe ? weeklyMyCount.value : Number(player.count || 0)
|
||||
}))
|
||||
: [{
|
||||
id: 'local-me',
|
||||
name: fallbackName,
|
||||
avatar: fallbackName.slice(0, 1) || '我',
|
||||
avatarUrl: userData.avatar || '',
|
||||
count: weeklyMyCount.value,
|
||||
tone: 1,
|
||||
isMe: true
|
||||
}]
|
||||
return source
|
||||
.sort((a, b) => b.count - a.count || Number(b.best_score || 0) - Number(a.best_score || 0))
|
||||
.map((player, index) => ({ ...player, rank: index + 1 }))
|
||||
})
|
||||
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 1)
|
||||
const weeklyRankRise = computed(() => 0)
|
||||
const weeklyIsFirst = computed(() => weeklyMyRank.value === 1)
|
||||
const weeklyDistance = computed(() => {
|
||||
const mineIndex = weeklyRankRows.value.findIndex(player => player.isMe)
|
||||
@@ -493,7 +528,37 @@ const weeklyDistance = computed(() => {
|
||||
return Math.max(1, weeklyRankRows.value[mineIndex - 1].count - weeklyMyCount.value + 1)
|
||||
})
|
||||
const currentCheer = computed(() => CHEER_MESSAGES[cheerIndex.value % CHEER_MESSAGES.length])
|
||||
const weeklyRangeLabel = computed(() => getWeekRangeLabel(new Date()))
|
||||
const weeklyRangeLabel = computed(() => {
|
||||
const start = platformLeaderboard.value.week_start
|
||||
const end = platformLeaderboard.value.week_end
|
||||
if (!start || !end) return getWeekRangeLabel(new Date())
|
||||
return `${formatWeekDate(start)}—${formatWeekDate(end)}`
|
||||
})
|
||||
const weeklySexLabel = computed(() => platformLeaderboard.value.sex_label || '同行')
|
||||
const weeklyMemberCount = computed(() => Math.max(1, Number(platformLeaderboard.value.member_count || weeklyRankRows.value.length)))
|
||||
const weeklyStatusLabel = computed(() => {
|
||||
if (platformLoading.value) return '连接中'
|
||||
if (!platformConnected.value) return '离线记录中'
|
||||
if (platformSyncStatus.value === 'pending' || platformSyncStatus.value === 'syncing') return '保存中'
|
||||
return '真实周榜'
|
||||
})
|
||||
|
||||
onShareAppMessage(() => {
|
||||
recordPlatformShare()
|
||||
return {
|
||||
title: gameShareTitle(),
|
||||
path: gameSharePath()
|
||||
}
|
||||
})
|
||||
|
||||
onShareTimeline(() => {
|
||||
recordPlatformShare()
|
||||
const inviteCode = String(platformLeaderboard.value.invite_code || '')
|
||||
return {
|
||||
title: gameShareTitle(),
|
||||
query: inviteCode ? `from=share&invite_code=${encodeURIComponent(inviteCode)}` : 'from=share'
|
||||
}
|
||||
})
|
||||
|
||||
function initNavLayout() {
|
||||
let paddingTop = 20
|
||||
@@ -523,14 +588,27 @@ function initNavLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
onLoad((options = {}) => {
|
||||
initNavLayout()
|
||||
bestScore.value = Number(uni.getStorageSync('tongji_endless_best') || 0)
|
||||
incomingInviteCode = String(options.invite_code || '').trim()
|
||||
startGame()
|
||||
weeklyRankVisible.value = true
|
||||
connectPlatform(incomingInviteCode)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
connectPlatform(incomingInviteCode).then((ok) => {
|
||||
if (ok) flushPlatformProgress()
|
||||
})
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
if (learned.value > 0 || gameOver.value) {
|
||||
queuePlatformProgress(learned.value, score.value, gameOver.value)
|
||||
flushPlatformProgress()
|
||||
}
|
||||
disposePlatform()
|
||||
if (hintTimer) clearTimeout(hintTimer)
|
||||
if (jackpotTimer) clearTimeout(jackpotTimer)
|
||||
if (confettiTimer) clearTimeout(confettiTimer)
|
||||
@@ -557,6 +635,25 @@ function getWeekRangeLabel(now) {
|
||||
return `${label(monday)}—${label(sunday)}`
|
||||
}
|
||||
|
||||
function formatWeekDate(value) {
|
||||
const parts = String(value).split('-').map(Number)
|
||||
if (parts.length !== 3) return String(value)
|
||||
return `${parts[1]}月${parts[2]}日`
|
||||
}
|
||||
|
||||
function gameShareTitle() {
|
||||
if (learned.value > 0) {
|
||||
return `我在识糖小课堂认识了${learned.value}份食物,一起来挑战吧`
|
||||
}
|
||||
return '识糖小课堂:边玩边认识常见食物'
|
||||
}
|
||||
|
||||
function gameSharePath() {
|
||||
const inviteCode = String(platformLeaderboard.value.invite_code || '')
|
||||
const query = inviteCode ? `?from=share&invite_code=${encodeURIComponent(inviteCode)}` : '?from=share'
|
||||
return `/tongji/endless-game/index${query}`
|
||||
}
|
||||
|
||||
function openWeeklyRank() {
|
||||
primeSfxFromGesture()
|
||||
if (gameOver.value) return
|
||||
@@ -565,6 +662,7 @@ function openWeeklyRank() {
|
||||
return
|
||||
}
|
||||
weeklyRankVisible.value = true
|
||||
syncAndRefreshPlatform(learned.value, score.value)
|
||||
playSfx('select', .72)
|
||||
}
|
||||
|
||||
@@ -583,6 +681,7 @@ function cycleCheer() {
|
||||
|
||||
function startGame() {
|
||||
if (hintTimer) clearTimeout(hintTimer)
|
||||
beginPlatformSession()
|
||||
themeIndex.value = 0
|
||||
board.value = buildPlayableBoard(openingKeysForTheme(currentTheme.value))
|
||||
selected.value = -1
|
||||
@@ -829,6 +928,7 @@ async function trySwap(a, b, soundPlayed = false) {
|
||||
endedFood.value = foodOf(highTile.key)
|
||||
saveBest()
|
||||
gameOver.value = true
|
||||
queuePlatformProgress(learned.value, score.value, true)
|
||||
busy.value = false
|
||||
return
|
||||
}
|
||||
@@ -884,7 +984,11 @@ async function resolveMatches(initialGroups, movedIndex) {
|
||||
const camelMatchCount = Math.max(bentCamelCount, camelGroup?.indices.length || 0)
|
||||
if (camelMatchCount >= 3) applyCamelJackpot(camelMatchCount, remove)
|
||||
const removedTiles = [...remove].map(i => board.value[i]).filter(Boolean)
|
||||
const removedNormal = removedTiles.filter(tile => !foodOf(tile.key).high)
|
||||
// 排名统计的是正确认识并消除的普通食物;驼乳是道具,不计入认糖数。
|
||||
const removedNormal = removedTiles.filter(tile => {
|
||||
const food = foodOf(tile.key)
|
||||
return !food.high && !food.product
|
||||
})
|
||||
const fireRemovedHigh = fireClearingIndices.value.some(index => foodOf(board.value[index]?.key).high)
|
||||
const largestMatch = Math.max(
|
||||
...groups.map(group => group.indices.length),
|
||||
@@ -903,10 +1007,10 @@ async function resolveMatches(initialGroups, movedIndex) {
|
||||
vibrate(largestMatch >= 5 || wave >= 3 ? 'medium' : 'light')
|
||||
turnConsumed += removedNormal.length
|
||||
learned.value += removedNormal.length
|
||||
weeklyPreviewProgress.value += removedNormal.length
|
||||
updateFoodTask(removedNormal)
|
||||
const multiplier = feastMoves.value > 0 ? 2 : 1
|
||||
score.value += (removedNormal.length * 12 + Math.max(0, wave - 1) * 20 + madeSpecial * 35) * multiplier
|
||||
queuePlatformProgress(learned.value, score.value, false)
|
||||
await wait(specialEffects.hasFire ? 330 : 210)
|
||||
const next = [...board.value]
|
||||
remove.forEach(index => { next[index] = null })
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
|
||||
*/
|
||||
|
||||
function utf8ToBytes(str) {
|
||||
const encoded = encodeURIComponent(str)
|
||||
const bytes = []
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
if (encoded.charCodeAt(i) === 37) {
|
||||
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
return uni.arrayBufferToBase64(bytes.buffer)
|
||||
}
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i]
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
result += chars[a >> 2]
|
||||
result += chars[((a & 3) << 4) | (b >> 4)]
|
||||
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
|
||||
result += i + 2 < bytes.length ? chars[c & 63] : '='
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg) {
|
||||
const normalized = String(svg || '').trim()
|
||||
if (!normalized) return ''
|
||||
const base64 = bytesToBase64(utf8ToBytes(normalized))
|
||||
return `data:image/svg+xml;base64,${base64}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,15 +98,17 @@
|
||||
<view v-if="chartUseMockData" class="st-chart-foot">
|
||||
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
||||
</view>
|
||||
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
|
||||
<view class="st-game-entry-icon">
|
||||
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
||||
<view class="st-game-entry-list">
|
||||
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
|
||||
<view class="st-game-entry-icon">
|
||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<view class="st-game-entry-body">
|
||||
<text class="st-game-entry-title">识糖小课堂</text>
|
||||
<text class="st-game-entry-sub">三消认食物,学习哪些食物含糖高</text>
|
||||
</view>
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
</view>
|
||||
<view class="st-game-entry-body">
|
||||
<text class="st-game-entry-title">识糖小课堂</text>
|
||||
<text class="st-game-entry-sub">认一认:哪些食物含糖高</text>
|
||||
</view>
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="st-training-section">
|
||||
@@ -1512,15 +1514,19 @@ async function goMorePage() {
|
||||
uni.navigateTo({ url: buildMorePageUrl() })
|
||||
}
|
||||
|
||||
function goGamePage() {
|
||||
function openGamePage(url) {
|
||||
uni.navigateTo({
|
||||
url: '/tongji/endless-game/index',
|
||||
url,
|
||||
fail() {
|
||||
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goEndlessGamePage() {
|
||||
openGamePage('/tongji/endless-game/index')
|
||||
}
|
||||
|
||||
function navToUser() {
|
||||
uni.redirectTo({ url: '/pages/user/user' })
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 糖分突袭游戏入口 */
|
||||
/* 游戏入口 */
|
||||
.weekly-page .st-game-entry {
|
||||
|
||||
margin-top: 24rpx;
|
||||
@@ -388,8 +388,15 @@
|
||||
}
|
||||
|
||||
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
|
||||
.weekly-page .st-chart-card .st-game-entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin: 12rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-card .st-game-entry--in-card {
|
||||
margin-top: 12rpx;
|
||||
margin-top: 0;
|
||||
padding: 16rpx 20rpx;
|
||||
gap: 16rpx;
|
||||
border-radius: 24rpx;
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import Optimization from '@uni-ku/bundle-optimizer'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
plugins: [
|
||||
uni(),
|
||||
Optimization({
|
||||
enable: {
|
||||
optimization: true,
|
||||
'async-import': false,
|
||||
'async-component': false,
|
||||
},
|
||||
dts: false,
|
||||
logger: false,
|
||||
}),
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MedicineMappingQuery {
|
||||
page_no: number
|
||||
page_size: number
|
||||
local_name?: string
|
||||
remote_keyword?: string
|
||||
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
|
||||
}
|
||||
|
||||
export interface MedicineMappingRow {
|
||||
local_medicine_id: number
|
||||
local_name: string
|
||||
local_unit: string
|
||||
local_status: number
|
||||
mapping_id: number | null
|
||||
mapping_status: number
|
||||
medicine_code: string | null
|
||||
remote_name: string | null
|
||||
remote_brand: string | null
|
||||
remote_unit: string | null
|
||||
settlement_price: string | number | null
|
||||
retail_price: string | number | null
|
||||
catalog_version: number | null
|
||||
remote_status: number | null
|
||||
remote_deleted: number | null
|
||||
operator_name: string | null
|
||||
mapping_update_time: number | null
|
||||
}
|
||||
|
||||
export interface CatalogOption {
|
||||
medicine_code: string
|
||||
name: string
|
||||
brand: string
|
||||
unit: string
|
||||
settlement_price: string | number
|
||||
retail_price: string | number
|
||||
catalog_version: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncStatus {
|
||||
sync_enabled: boolean
|
||||
cursor: number
|
||||
last_success_time: number
|
||||
last_failure_time: number
|
||||
last_error_summary: string
|
||||
is_syncing: boolean
|
||||
catalog_total: number
|
||||
catalog_active: number
|
||||
unmapped_local: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncResult {
|
||||
pages: number
|
||||
pulled: number
|
||||
received: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
deactivated: number
|
||||
cursor: number
|
||||
}
|
||||
|
||||
export function medicineMappingLists(params: MedicineMappingQuery) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
|
||||
}
|
||||
|
||||
export function medicineMappingStatus() {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/status' })
|
||||
}
|
||||
|
||||
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
|
||||
}
|
||||
|
||||
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/save', params })
|
||||
}
|
||||
|
||||
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
|
||||
}
|
||||
|
||||
export function medicineCatalogSync() {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/sync' })
|
||||
}
|
||||
@@ -232,6 +232,11 @@ export function autoAssignLogLists(params: Record<string, any>) {
|
||||
return request.get({ url: '/stats.autoAssignLog/lists', params })
|
||||
}
|
||||
|
||||
/** 批量回退自动分配:将诊单医助撤回到自动分配前的原医助 */
|
||||
export function autoAssignLogRollback(data: { ids: number[] }) {
|
||||
return request.post({ url: '/stats.autoAssignLog/rollback', data })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
|
||||
@@ -528,6 +528,21 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
|
||||
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
|
||||
}
|
||||
|
||||
/** 人工核对甘草不确定提交结果。 */
|
||||
export function prescriptionOrderConfirmGancaoSubmission(params: {
|
||||
id: number
|
||||
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
|
||||
remote_order_no?: string
|
||||
note: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||
id: number
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '—' }}</span>
|
||||
<el-select
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
:model-value="selectedMedicineId"
|
||||
class="medicine-name-select w-full"
|
||||
filterable
|
||||
remote
|
||||
@@ -14,7 +14,14 @@
|
||||
@visible-change="onVisibleChange"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
|
||||
<div class="medicine-name-option">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="medicine-name-option__meta">
|
||||
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
@@ -24,6 +31,7 @@ import { medicineLists } from '@/api/medicine'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
medicineId?: number | null
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false }
|
||||
@@ -31,18 +39,30 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'update:medicineId': [value: number | undefined]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const options = ref<{ id: number; name: string }[]>([])
|
||||
type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
|
||||
const options = ref<MedicineOption[]>([])
|
||||
|
||||
const selectedMedicineId = computed<number | ''>(() => {
|
||||
const id = Number(props.medicineId)
|
||||
if (Number.isInteger(id) && id > 0) {
|
||||
return id
|
||||
}
|
||||
return (props.modelValue || '').trim() ? 0 : ''
|
||||
})
|
||||
|
||||
function ensureCurrentInOptions() {
|
||||
const v = (props.modelValue || '').trim()
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
if (!options.value.some((o) => o.name === v)) {
|
||||
options.value = [{ id: 0, name: v }, ...options.value]
|
||||
const id = Number(props.medicineId)
|
||||
const currentId = Number.isInteger(id) && id > 0 ? id : 0
|
||||
if (!options.value.some((o) => o.id === currentId)) {
|
||||
options.value = [{ id: currentId, name: v }, ...options.value]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +76,7 @@ const remoteMethod = async (query: string) => {
|
||||
page_size: 100,
|
||||
status: 1
|
||||
})
|
||||
options.value = res.lists || []
|
||||
options.value = (res.lists || []) as MedicineOption[]
|
||||
ensureCurrentInOptions()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -72,13 +92,19 @@ const onVisibleChange = (open: boolean) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdate = (val: string) => {
|
||||
emit('update:modelValue', val || '')
|
||||
const onUpdate = (val: number | string) => {
|
||||
const id = Number(val)
|
||||
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
|
||||
emit('update:modelValue', selected?.name || '')
|
||||
emit('update:medicineId', selected?.id)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => [props.modelValue, props.medicineId],
|
||||
() => {
|
||||
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
|
||||
emit('update:medicineId', undefined)
|
||||
}
|
||||
ensureCurrentInOptions()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -93,4 +119,23 @@ watch(
|
||||
.medicine-name-select.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.medicine-name-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.medicine-name-option__meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -139,7 +139,12 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<MedicineNameSelect
|
||||
v-model="formData.herbs[row.index].name"
|
||||
v-model:medicine-id="formData.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="herb-editor-card__select"
|
||||
/>
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -176,7 +181,12 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">辅方</div>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<MedicineNameSelect
|
||||
v-model="formData.herbs[row.index].name"
|
||||
v-model:medicine-id="formData.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="herb-editor-card__select"
|
||||
/>
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -1045,6 +1055,7 @@ import { Search } from '@element-plus/icons-vue'
|
||||
type FormulaType = '主方' | '辅方'
|
||||
|
||||
interface Herb {
|
||||
medicine_id?: number
|
||||
name: string
|
||||
dosage: number
|
||||
formula_type?: FormulaType
|
||||
@@ -1071,6 +1082,10 @@ function normalizeHerbRow(raw: any): Herb {
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
|
||||
if (Number.isInteger(medicineId) && medicineId > 0) {
|
||||
row.medicine_id = medicineId
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
@@ -1578,8 +1593,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
|
||||
return herbs
|
||||
}
|
||||
|
||||
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测) */
|
||||
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
|
||||
/** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测。 */
|
||||
async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
|
||||
const q = rawName.trim()
|
||||
if (!q) return null
|
||||
try {
|
||||
@@ -1590,8 +1605,10 @@ async function resolveHerbNameFromLibrary(rawName: string): Promise<string | nul
|
||||
status: 1
|
||||
})
|
||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
||||
return exact ? exact.name.trim() : null
|
||||
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||
return exact.length === 1
|
||||
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -1612,12 +1629,12 @@ async function handlePasteRecipeImport() {
|
||||
const resolved: Herb[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const name = await resolveHerbNameFromLibrary(row.name)
|
||||
if (!name) {
|
||||
const medicine = await resolveHerbFromLibrary(row.name)
|
||||
if (!medicine) {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<template v-if="needsReconcile">
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
|
||||
type="danger"
|
||||
size="small"
|
||||
plain
|
||||
@click="openDialog"
|
||||
>核对甘草提交</el-button
|
||||
>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="人工核对甘草提交"
|
||||
width="min(92vw, 520px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px" @submit.prevent="submit">
|
||||
<el-form-item label="核对结果" required>
|
||||
<el-radio-group v-model="form.resolution">
|
||||
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
|
||||
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="form.resolution === 'CONFIRM_SUCCESS'"
|
||||
label="甘草单号"
|
||||
required
|
||||
>
|
||||
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="核对依据" required>
|
||||
<el-input
|
||||
v-model="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="例如:核对时间、甘草后台查询条件及结果"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit"
|
||||
>确认并记录</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
|
||||
const emit = defineEmits<{ resolved: [] }>()
|
||||
|
||||
const needsReconcile = computed(() => {
|
||||
const target = String(props.order?.pharmacy_claim_target || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const status = String(props.order?.pharmacy_claim_status || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
|
||||
const expiredPending =
|
||||
status === 'PENDING' &&
|
||||
leaseExpiresAt > 0 &&
|
||||
leaseExpiresAt <= Math.floor(Date.now() / 1000)
|
||||
return (
|
||||
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
|
||||
)
|
||||
})
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
|
||||
remote_order_no: '',
|
||||
note: ''
|
||||
})
|
||||
|
||||
function openDialog() {
|
||||
form.resolution = 'CONFIRM_SUCCESS'
|
||||
form.remote_order_no = ''
|
||||
form.note = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const id = Number(props.order?.id)
|
||||
if (!id) return
|
||||
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
|
||||
feedback.msgError('请填写甘草药方单号')
|
||||
return
|
||||
}
|
||||
if (!form.note.trim()) {
|
||||
feedback.msgError('请填写甘草后台核对依据')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await prescriptionOrderConfirmGancaoSubmission({
|
||||
id,
|
||||
resolution: form.resolution,
|
||||
remote_order_no:
|
||||
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
|
||||
note: form.note.trim()
|
||||
})
|
||||
feedback.msgSuccess('甘草提交核对已记录')
|
||||
visible.value = false
|
||||
emit('resolved')
|
||||
} catch {
|
||||
// Request interceptor presents the server-side reconciliation error.
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -30,6 +30,13 @@
|
||||
round
|
||||
class="ml-2"
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
<el-tag
|
||||
v-if="detailData?.ej_pharmacy_order_no"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
round
|
||||
class="ml-2"
|
||||
>洛阳 {{ detailData.ej_pharmacy_order_no }}</el-tag>
|
||||
<!-- 完整版:发货类型切换等 -->
|
||||
<slot v-if="detailData" name="header-extra" :detail="detailData" />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,45 @@ export const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!row) return false
|
||||
if (String(row.gancao_reciperl_order_no || '').trim()) return true
|
||||
if (String(row.ej_pharmacy_order_no || '').trim()) return true
|
||||
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
|
||||
return true
|
||||
}
|
||||
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
|
||||
String(row.pharmacy_claim_status || '').toUpperCase()
|
||||
)
|
||||
}
|
||||
|
||||
export type SupplyMode = 'gancao' | 'direct' | 'self'
|
||||
|
||||
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
|
||||
if (
|
||||
String(row?.ship_mode || '')
|
||||
.trim()
|
||||
.toLowerCase() === 'direct'
|
||||
)
|
||||
return 'direct'
|
||||
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
|
||||
return 'self'
|
||||
}
|
||||
|
||||
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
|
||||
const mode = supplyModeKey(row)
|
||||
if (mode === 'direct') return '洛阳直发'
|
||||
return mode === 'gancao' ? '甘草' : '自营'
|
||||
}
|
||||
|
||||
export function supplyModeTagType(
|
||||
row: Record<string, unknown> | null | undefined
|
||||
): 'success' | 'warning' | 'info' {
|
||||
const mode = supplyModeKey(row)
|
||||
if (mode === 'direct') return 'warning'
|
||||
return mode === 'gancao' ? 'success' : 'info'
|
||||
}
|
||||
|
||||
export function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
@@ -134,6 +173,8 @@ export function logActionText(act: string) {
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
ej_pharmacy_submit: '洛阳药房下单',
|
||||
ej_pharmacy_callback: '洛阳药房状态',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
patch_rx_usage: '服用参数',
|
||||
update_amount: '修改订单金额',
|
||||
|
||||
@@ -696,7 +696,12 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
<MedicineNameSelect
|
||||
v-model="editForm.herbs[row.index].name"
|
||||
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -732,7 +737,12 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
<MedicineNameSelect
|
||||
v-model="editForm.herbs[row.index].name"
|
||||
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -1294,6 +1304,14 @@
|
||||
|
||||
<div v-show="createOrderStep === 1" class="create-order-step-panel">
|
||||
<el-row :gutter="20">
|
||||
<el-col v-if="canSelectShipMode" :span="24">
|
||||
<el-form-item label="发货药房" prop="ship_mode">
|
||||
<el-radio-group v-model="createOrderForm.ship_mode">
|
||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||
<el-radio-button label="direct">洛阳药房</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="复诊">
|
||||
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
|
||||
@@ -1697,6 +1715,7 @@ import { roleAll } from '@/api/perms/role'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
@@ -1709,7 +1728,7 @@ import jsPDF from 'jspdf'
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
type FormulaType = '主方' | '辅方'
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
@@ -1814,6 +1833,10 @@ function normalizeHerbRow(raw: any): HerbRow {
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
|
||||
if (Number.isInteger(medicineId) && medicineId > 0) {
|
||||
row.medicine_id = medicineId
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
@@ -2006,6 +2029,7 @@ const createOrderForm = reactive({
|
||||
service_package: [] as string[],
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
ship_mode: 'gancao' as 'gancao' | 'direct',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
internal_cost: undefined as number | undefined,
|
||||
@@ -2014,6 +2038,10 @@ const createOrderForm = reactive({
|
||||
pay_order_ids: [] as number[]
|
||||
})
|
||||
|
||||
// 只有具备「设置发货类型」权限的账号才可在创建业务订单时选择洛阳药房;
|
||||
// 无权限账号保持历史默认逻辑,固定走甘草药房。
|
||||
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
|
||||
|
||||
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
|
||||
const regionOptions = ref([])
|
||||
|
||||
@@ -2252,6 +2280,7 @@ function resetCreateOrderForm() {
|
||||
createOrderForm.service_package = []
|
||||
createOrderForm.express_company = 'auto'
|
||||
createOrderForm.tracking_number = ''
|
||||
createOrderForm.ship_mode = 'gancao'
|
||||
createOrderForm.fee_type = 3
|
||||
createOrderForm.amount = 0
|
||||
createOrderForm.internal_cost = undefined
|
||||
@@ -2425,6 +2454,7 @@ async function submitCreateOrderFromPrescription() {
|
||||
: '',
|
||||
express_company: createOrderForm.express_company || 'auto',
|
||||
tracking_number: createOrderForm.tracking_number || '',
|
||||
ship_mode: createOrderForm.ship_mode,
|
||||
fee_type: createOrderForm.fee_type,
|
||||
amount: createOrderForm.amount,
|
||||
remark_extra: createOrderForm.remark_extra || '',
|
||||
@@ -3601,7 +3631,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
|
||||
return herbs
|
||||
}
|
||||
|
||||
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
|
||||
async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
|
||||
const q = rawName.trim()
|
||||
if (!q) return null
|
||||
try {
|
||||
@@ -3612,8 +3642,10 @@ async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string
|
||||
status: 1
|
||||
})
|
||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
||||
return exact ? exact.name.trim() : null
|
||||
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||
return exact.length === 1
|
||||
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -3634,12 +3666,12 @@ async function handlePasteRecipeImport() {
|
||||
const resolved: HerbRow[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||
if (!name) {
|
||||
const medicine = await resolvePasteHerbFromLibrary(row.name)
|
||||
if (!medicine) {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
|
||||
@@ -157,7 +157,11 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
|
||||
<MedicineNameSelect
|
||||
v-model="row.name"
|
||||
v-model:medicine-id="row.medicine_id"
|
||||
:disabled="editMode === 'view'"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="150">
|
||||
@@ -257,7 +261,7 @@ const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||
is_public: 0,
|
||||
disable_edit: 0
|
||||
})
|
||||
|
||||
@@ -373,9 +373,9 @@
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
||||
:type="supplyModeTagType(row)"
|
||||
>
|
||||
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}
|
||||
{{ supplyModeLabel(row) }}
|
||||
</el-tag>
|
||||
<span class="text-gray-400">#{{ row.id }}</span>
|
||||
</div>
|
||||
@@ -594,13 +594,13 @@
|
||||
@click="confirmWithdraw(row)"
|
||||
>撤回</el-button>
|
||||
<el-button
|
||||
v-if="canUploadGancaoRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
||||
v-if="canUploadPharmacyRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
type="warning"
|
||||
link
|
||||
:loading="gancaoSubmitId === row.id"
|
||||
@click="confirmSubmitGancaoRecipel(row)"
|
||||
>上传药方</el-button>
|
||||
:loading="pharmacySubmitId === row.id"
|
||||
@click="confirmUploadToPharmacy(row)"
|
||||
>上传药房</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -638,7 +638,7 @@
|
||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||
<el-radio-button
|
||||
label="direct"
|
||||
:disabled="isShipModeLockedToGancao(detail)"
|
||||
:disabled="isShipModeLocked(detail)"
|
||||
>洛阳药房</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-tag
|
||||
@@ -650,6 +650,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions="{ detail }">
|
||||
<gancao-submission-reconcile-button
|
||||
:order="detail"
|
||||
@resolved="handleGancaoSubmissionResolved"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canUploadPharmacyRow(detail)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
type="warning"
|
||||
size="small"
|
||||
plain
|
||||
:loading="pharmacySubmitId === detail.id"
|
||||
@click="confirmUploadToPharmacy(detail)"
|
||||
>上传药房</el-button>
|
||||
<el-button
|
||||
v-if="canRxAudit(detail)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
@@ -2205,6 +2218,7 @@ import { useRoute } from 'vue-router'
|
||||
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||
import {
|
||||
TCM_ASSISTANT_ROLE_ID,
|
||||
PRESCRIPTION_AUDIT_ROLE_IDS,
|
||||
@@ -2231,7 +2245,10 @@ import {
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
parseServicePackageValues,
|
||||
mergeServicePackageSelectOptions
|
||||
mergeServicePackageSelectOptions,
|
||||
isRemoteSnapshotLocked,
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
} from './components/prescription-order-utils'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
@@ -2256,7 +2273,7 @@ import {
|
||||
prescriptionOrderBatchAssignAssistant,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderUploadToPharmacy,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
prescriptionLibraryLists,
|
||||
@@ -2576,12 +2593,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||
|
||||
const supplyModeTabs = [
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||
{ label: '甘草', value: 'gancao' as const },
|
||||
{ label: '洛阳直发', value: 'direct' as const },
|
||||
{ label: '自营', value: 'self' as const }
|
||||
]
|
||||
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||
queryParams.supply_mode = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -2615,8 +2633,8 @@ const queryParams = reactive({
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||
service_channel: '' as '' | '0',
|
||||
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
||||
@@ -3071,14 +3089,7 @@ function canEditRow(row: {
|
||||
return false
|
||||
}
|
||||
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
// 甘草已提交:仅允许在待双审/履约中/已发货/进行中下修改快递(已签收 6 不可改单号;后端 edit 亦拦截)
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
|
||||
// 未提交甘草:仅待双审(1)、履约中(2)可全量编辑
|
||||
return fs === 1 || fs === 2
|
||||
@@ -3106,6 +3117,7 @@ function canRevokeRxAudit(row: {
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
@@ -3128,6 +3140,7 @@ function canRevokePayAudit(row: {
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
@@ -3152,13 +3165,13 @@ function shipModeLabel(v: unknown): string {
|
||||
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
|
||||
}
|
||||
|
||||
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) {
|
||||
return String(row.gancao_reciperl_order_no || '').trim() !== ''
|
||||
function isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
|
||||
return isRemoteSnapshotLocked(row as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function canEditShipMode(row: { fulfillment_status?: number }) {
|
||||
function canEditShipMode(row: { fulfillment_status?: number; gancao_reciperl_order_no?: string; ej_pharmacy_order_no?: string }) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
|
||||
}
|
||||
|
||||
const shipModeSaving = ref(false)
|
||||
@@ -3200,6 +3213,11 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGancaoSubmissionResolved() {
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
getLists()
|
||||
}
|
||||
|
||||
function canAddPayOrderRow(row: {
|
||||
fulfillment_status?: number
|
||||
amount?: number | string
|
||||
@@ -3230,14 +3248,27 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadGancaoRow(row: {
|
||||
function canUploadPharmacyRow(row: {
|
||||
prescription_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
ship_mode?: string
|
||||
gancao_reciperl_order_no?: string
|
||||
ej_pharmacy_order_no?: string
|
||||
ej_pharmacy_status?: string
|
||||
ej_pharmacy_review_status?: string
|
||||
can_upload_pharmacy?: boolean
|
||||
}) {
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
if (row.can_upload_pharmacy === false) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
const fs = Number(row.fulfillment_status)
|
||||
const ejRejected = ['REJECTED'].includes(String(row.ej_pharmacy_status || '').toUpperCase())
|
||||
|| ['REJECTED'].includes(String(row.ej_pharmacy_review_status || '').toUpperCase())
|
||||
if (rxStatus !== 1 || ([3, 4, 8, 9, 10, 11, 12].includes(fs) && !(fs === 9 && ejRejected))) return false
|
||||
if (normalizeShipMode(row.ship_mode) === 'direct') {
|
||||
return (String(row.ej_pharmacy_order_no || '').trim() === '' || ejRejected)
|
||||
&& String(row.gancao_reciperl_order_no || '').trim() === ''
|
||||
}
|
||||
return String(row.gancao_reciperl_order_no || '').trim() === '' && String(row.ej_pharmacy_order_no || '').trim() === ''
|
||||
}
|
||||
|
||||
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
||||
@@ -3863,7 +3894,7 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const gancaoSubmitId = ref(0)
|
||||
const pharmacySubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
@@ -4002,18 +4033,24 @@ async function testGancaoPreviewFromDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
|
||||
const isLuoyang = normalizeShipMode(row.ship_mode) === 'direct'
|
||||
const pharmacyName = isLuoyang ? '洛阳药房' : '甘草药房'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
`将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
|
||||
'上传药房',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
pharmacySubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
const no = d?.pharmacy_order_no != null
|
||||
? String(d.pharmacy_order_no)
|
||||
: d?.recipel_order_no != null
|
||||
? String(d.recipel_order_no)
|
||||
: ''
|
||||
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
await detailDrawerRef.value?.refreshIfCurrent(row.id)
|
||||
} catch (e: any) {
|
||||
@@ -4021,7 +4058,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
gancaoSubmitId.value = 0
|
||||
pharmacySubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -365,8 +365,8 @@
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
||||
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag>
|
||||
:type="supplyModeTagType(row)"
|
||||
>{{ supplyModeLabel(row) }}</el-tag>
|
||||
<span class="po-card__id">#{{ row.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +500,11 @@
|
||||
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
|
||||
<span class="text-red-500">退款</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canUploadPharmacyRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
command="uploadPharmacy"
|
||||
>上传药房</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
|
||||
<span class="text-red-500">撤回订单</span>
|
||||
</el-dropdown-item>
|
||||
@@ -544,6 +548,10 @@
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
|
||||
<gancao-submission-reconcile-button
|
||||
:order="detailData"
|
||||
@resolved="handleGancaoSubmissionResolved"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canRxAudit(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
@@ -1885,10 +1893,12 @@
|
||||
/>
|
||||
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
|
||||
<el-form-item label="发货方式">
|
||||
<el-radio-group v-model="shipForm.ship_mode">
|
||||
<el-radio label="gancao">甘草药方发</el-radio>
|
||||
<el-radio label="direct">药房直发</el-radio>
|
||||
</el-radio-group>
|
||||
<el-tag
|
||||
:type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
|
||||
effect="plain"
|
||||
size="default"
|
||||
>{{ shipDialogModeDisplay }}</el-tag>
|
||||
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部「发货类型」中设置,此处不可修改</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="shipForm.express_company" class="w-full">
|
||||
@@ -2697,6 +2707,7 @@ import {
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
@@ -2719,7 +2730,7 @@ import {
|
||||
prescriptionOrderPatchPrescriptionUsage,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderUploadToPharmacy,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
getDoctors,
|
||||
@@ -2733,7 +2744,10 @@ import {
|
||||
mergeServicePackageSelectOptions,
|
||||
formatServicePackageLabels,
|
||||
normalizeSlipAuxUsageForm,
|
||||
prescriptionHasAuxFormula
|
||||
prescriptionHasAuxFormula,
|
||||
isRemoteSnapshotLocked,
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
} from './components/prescription-order-utils'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
@@ -2984,12 +2998,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||
|
||||
const supplyModeTabs = [
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||
{ label: '甘草', value: 'gancao' as const },
|
||||
{ label: '洛阳直发', value: 'direct' as const },
|
||||
{ label: '自营', value: 'self' as const }
|
||||
]
|
||||
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||
queryParams.supply_mode = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -3023,8 +3038,8 @@ const queryParams = reactive({
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
@@ -3459,13 +3474,7 @@ function canEditRow(row: {
|
||||
return false
|
||||
}
|
||||
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
|
||||
return fs === 1 || fs === 2
|
||||
}
|
||||
@@ -3492,6 +3501,7 @@ function canRevokeRxAudit(row: {
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
@@ -3514,6 +3524,7 @@ function canRevokePayAudit(row: {
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
@@ -3544,14 +3555,26 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadGancaoRow(row: {
|
||||
function canUploadPharmacyRow(row: {
|
||||
prescription_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
ej_pharmacy_order_no?: string
|
||||
ej_pharmacy_status?: string
|
||||
ej_pharmacy_review_status?: string
|
||||
can_upload_pharmacy?: boolean
|
||||
}) {
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
if (row.can_upload_pharmacy === false) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const fulfillmentStatus = Number(row.fulfillment_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
const ejOrderNo = String(row.ej_pharmacy_order_no || '').trim()
|
||||
const ejRejected = String(row.ej_pharmacy_status || '').toUpperCase() === 'REJECTED'
|
||||
|| String(row.ej_pharmacy_review_status || '').toUpperCase() === 'REJECTED'
|
||||
return rxStatus === 1
|
||||
&& (![3, 4, 8, 9, 10, 11, 12].includes(fulfillmentStatus) || (fulfillmentStatus === 9 && ejRejected))
|
||||
&& gancaoOrderNo === ''
|
||||
&& (ejOrderNo === '' || ejRejected)
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
@@ -3589,7 +3612,7 @@ const h5ActiveFilterCount = computed(() => {
|
||||
|
||||
// H5: 判断列表卡片「更多」下拉是否需要展示
|
||||
function hasMoreCardActions(row: Record<string, any>) {
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadPharmacyRow(row) || canWithdrawRow(row)
|
||||
}
|
||||
|
||||
// H5: 处理列表卡片「更多」下拉指令
|
||||
@@ -3597,7 +3620,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
|
||||
else if (cmd === 'complete') confirmComplete(row as any)
|
||||
else if (cmd === 'refund') openRefundOrder(row as any)
|
||||
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
|
||||
else if (cmd === 'uploadPharmacy') confirmUploadToPharmacy(row as any)
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
|
||||
@@ -3929,6 +3952,8 @@ function logActionText(act: string) {
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
ej_pharmacy_submit: '洛阳药房下单',
|
||||
ej_pharmacy_callback: '洛阳药房状态',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
@@ -4721,7 +4746,7 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const gancaoSubmitId = ref(0)
|
||||
const pharmacySubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
@@ -4813,18 +4838,19 @@ async function testGancaoPreviewFromDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
|
||||
try {
|
||||
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
|
||||
await ElMessageBox.confirm(
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
`确认将本单关联处方上传至${target}?`,
|
||||
'上传药房',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
pharmacySubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
|
||||
feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
|
||||
try {
|
||||
@@ -4840,7 +4866,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
gancaoSubmitId.value = 0
|
||||
pharmacySubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4943,6 +4969,10 @@ const shipForm = reactive({
|
||||
tracking_number: ''
|
||||
})
|
||||
|
||||
const shipDialogModeDisplay = computed(() =>
|
||||
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
||||
)
|
||||
|
||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
||||
shipRowId.value = row.id
|
||||
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
||||
@@ -4982,6 +5012,13 @@ async function submitShip() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGancaoSubmissionResolved() {
|
||||
getLists()
|
||||
if (detailData.value?.id) {
|
||||
await openDetail(Number(detailData.value.id))
|
||||
}
|
||||
}
|
||||
|
||||
const completeOrderStatusOptions = [
|
||||
{ value: 3, label: '已完成' },
|
||||
{ value: 7, label: '进行中' },
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
<template>
|
||||
<div class="mapping-page">
|
||||
<header class="page-header">
|
||||
<h1>洛阳药房药材映射</h1>
|
||||
<el-button
|
||||
v-if="status.sync_enabled"
|
||||
v-perms="['pharmacy.medicineMapping/sync']"
|
||||
type="primary"
|
||||
:icon="Refresh"
|
||||
:loading="syncing"
|
||||
@click="handleSync"
|
||||
>
|
||||
增量同步
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<section class="status-strip" v-loading="statusLoading">
|
||||
<div class="status-item">
|
||||
<span>目录</span>
|
||||
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
|
||||
<small>启用 / 总数</small>
|
||||
</div>
|
||||
<div class="status-item warning">
|
||||
<span>未映射本地药材</span>
|
||||
<strong>{{ status.unmapped_local }}</strong>
|
||||
<small>仅统计启用药材</small>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>同步游标</span>
|
||||
<strong>{{ status.cursor }}</strong>
|
||||
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
|
||||
</div>
|
||||
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
|
||||
<span>最近同步</span>
|
||||
<strong>{{
|
||||
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
|
||||
}}</strong>
|
||||
<small :title="status.last_error_summary">
|
||||
{{
|
||||
status.last_error_summary ||
|
||||
formatTime(status.last_failure_time) ||
|
||||
'无失败记录'
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-form class="filter-bar" inline @submit.prevent>
|
||||
<el-form-item label="本地药材">
|
||||
<el-input
|
||||
v-model="query.local_name"
|
||||
clearable
|
||||
placeholder="名称"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="远端目录">
|
||||
<el-input
|
||||
v-model="query.remote_keyword"
|
||||
clearable
|
||||
placeholder="名称或编码"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="映射状态">
|
||||
<el-select
|
||||
v-model="query.mapping_status"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="已映射" value="mapped" />
|
||||
<el-option label="未映射" value="unmapped" />
|
||||
<el-option label="远端失效" value="invalid" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
|
||||
<el-table-column label="本地药材" min-width="190" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="medicine-name">{{ row.local_name }}</div>
|
||||
<div class="subline">
|
||||
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本地状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.local_status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="映射状态" width="105" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="mappingTag(row.mapping_status)" size="small">
|
||||
{{ mappingText(row.mapping_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="洛阳药房目录" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.medicine_code">
|
||||
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
|
||||
<div class="subline code">{{ row.medicine_code }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="medicine-name">-</div>
|
||||
<div class="subline">未映射</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="155" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.mapping_status !== 0">
|
||||
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
|
||||
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>-</div>
|
||||
<div class="subline">未映射</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本 / 状态" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.medicine_code">
|
||||
<div>v{{ row.catalog_version || 0 }}</div>
|
||||
<div class="subline">
|
||||
{{
|
||||
row.remote_status === 1 && row.remote_deleted !== 1
|
||||
? '远端启用'
|
||||
: '远端停用'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>-</div>
|
||||
<div class="subline">未映射</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['pharmacy.medicineMapping/save']"
|
||||
type="primary"
|
||||
link
|
||||
:icon="Link"
|
||||
:disabled="row.local_status !== 1"
|
||||
@click="openMapping(row)"
|
||||
>
|
||||
{{ row.mapping_status === 1 ? '更换' : '映射' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.mapping_status === 1 || row.mapping_status === 2"
|
||||
v-perms="['pharmacy.medicineMapping/unlink']"
|
||||
type="danger"
|
||||
link
|
||||
:icon="CloseBold"
|
||||
@click="handleUnlink(row)"
|
||||
>
|
||||
解除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page_no"
|
||||
v-model:page-size="query.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadRows"
|
||||
@current-change="loadRows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
|
||||
width="min(560px, calc(100vw - 32px))"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="local-summary">
|
||||
<span>本地药材</span>
|
||||
<strong>{{ editingRow?.local_name }}</strong>
|
||||
<small
|
||||
>ID {{ editingRow?.local_medicine_id }} ·
|
||||
{{ editingRow?.local_unit || '-' }}</small
|
||||
>
|
||||
</div>
|
||||
<el-form label-position="top" class="mapping-form">
|
||||
<el-form-item label="洛阳药房药材">
|
||||
<el-select
|
||||
v-model="selectedCode"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
:remote-method="searchCatalog"
|
||||
:loading="catalogLoading"
|
||||
placeholder="输入药材名称或编码检索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in catalogOptions"
|
||||
:key="option.medicine_code"
|
||||
:label="`${option.name} · ${option.medicine_code}`"
|
||||
:value="option.medicine_code"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span>{{ option.name }}</span>
|
||||
<small
|
||||
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
|
||||
{{ option.unit }}</small
|
||||
>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!selectedCode"
|
||||
@click="saveMapping"
|
||||
>
|
||||
保存映射
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="pharmacyMedicineMapping">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { createLatestRequestGuard } from './latest-request.mjs'
|
||||
import {
|
||||
medicineCatalogOptions,
|
||||
medicineCatalogSync,
|
||||
medicineMappingLists,
|
||||
medicineMappingSave,
|
||||
medicineMappingStatus,
|
||||
medicineMappingUnlink,
|
||||
type CatalogOption,
|
||||
type MedicineMappingQuery,
|
||||
type MedicineMappingRow,
|
||||
type PharmacySyncResult,
|
||||
type PharmacySyncStatus
|
||||
} from '@/api/pharmacy'
|
||||
|
||||
const emptyStatus = (): PharmacySyncStatus => ({
|
||||
sync_enabled: false,
|
||||
cursor: 0,
|
||||
last_success_time: 0,
|
||||
last_failure_time: 0,
|
||||
last_error_summary: '',
|
||||
is_syncing: false,
|
||||
catalog_total: 0,
|
||||
catalog_active: 0,
|
||||
unmapped_local: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const saving = ref(false)
|
||||
const catalogLoading = ref(false)
|
||||
const rows = ref<MedicineMappingRow[]>([])
|
||||
const total = ref(0)
|
||||
const status = ref<PharmacySyncStatus>(emptyStatus())
|
||||
const query = reactive<MedicineMappingQuery>({
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
local_name: '',
|
||||
remote_keyword: '',
|
||||
mapping_status: ''
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingRow = ref<MedicineMappingRow | null>(null)
|
||||
const selectedCode = ref('')
|
||||
const catalogOptions = ref<CatalogOption[]>([])
|
||||
let catalogTimer: number | undefined
|
||||
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
|
||||
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
|
||||
const statusRequests = createLatestRequestGuard()
|
||||
|
||||
const formatPrice = (value: string | number | null | undefined) => {
|
||||
const number = Number(value || 0)
|
||||
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return ''
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const mappingText = (value: number) => {
|
||||
if (value === 1) return '已映射'
|
||||
if (value === 2) return '远端失效'
|
||||
return '未映射'
|
||||
}
|
||||
|
||||
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
|
||||
if (value === 1) return 'success'
|
||||
if (value === 2) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const loadRows = async () => {
|
||||
const ticket = listRequests.next({ ...query })
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await medicineMappingLists(ticket.snapshot)
|
||||
if (listRequests.isLatest(ticket)) {
|
||||
rows.value = response.lists || []
|
||||
total.value = response.count || 0
|
||||
}
|
||||
} finally {
|
||||
if (listRequests.isLatest(ticket)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadStatus = async () => {
|
||||
const ticket = statusRequests.next(undefined)
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const nextStatus = await medicineMappingStatus()
|
||||
if (statusRequests.isLatest(ticket)) {
|
||||
status.value = nextStatus
|
||||
}
|
||||
} finally {
|
||||
if (statusRequests.isLatest(ticket)) {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
query.page_no = 1
|
||||
void loadRows()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
query.local_name = ''
|
||||
query.remote_keyword = ''
|
||||
query.mapping_status = ''
|
||||
search()
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
syncing.value = true
|
||||
try {
|
||||
const result = (await medicineCatalogSync()) as PharmacySyncResult
|
||||
feedback.msgSuccess(
|
||||
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
|
||||
)
|
||||
await Promise.all([loadRows(), loadStatus()])
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchCatalogNow = async (
|
||||
keyword: string,
|
||||
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
|
||||
) => {
|
||||
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
|
||||
catalogLoading.value = true
|
||||
try {
|
||||
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
|
||||
if (
|
||||
catalogRequests.isLatest(ticket) &&
|
||||
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
|
||||
) {
|
||||
catalogOptions.value = options
|
||||
}
|
||||
} finally {
|
||||
if (catalogRequests.isLatest(ticket)) {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
const searchCatalog = (keyword: string) => {
|
||||
if (catalogTimer) window.clearTimeout(catalogTimer)
|
||||
catalogRequests.invalidate()
|
||||
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
|
||||
}
|
||||
|
||||
const openMapping = async (row: MedicineMappingRow) => {
|
||||
if (catalogTimer) window.clearTimeout(catalogTimer)
|
||||
catalogRequests.invalidate()
|
||||
const rowSnapshot = { ...row }
|
||||
const localMedicineId = Number(rowSnapshot.local_medicine_id)
|
||||
editingRow.value = rowSnapshot
|
||||
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
|
||||
catalogOptions.value = []
|
||||
dialogVisible.value = true
|
||||
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
|
||||
if (
|
||||
!catalogRequests.isLatest(initialTicket) ||
|
||||
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
|
||||
)
|
||||
return
|
||||
if (
|
||||
selectedCode.value &&
|
||||
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
|
||||
) {
|
||||
await searchCatalogNow(selectedCode.value, localMedicineId)
|
||||
}
|
||||
}
|
||||
|
||||
const saveMapping = async () => {
|
||||
if (!editingRow.value || !selectedCode.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await medicineMappingSave({
|
||||
local_medicine_id: editingRow.value.local_medicine_id,
|
||||
medicine_code: selectedCode.value
|
||||
})
|
||||
dialogVisible.value = false
|
||||
await Promise.all([loadRows(), loadStatus()])
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlink = async (row: MedicineMappingRow) => {
|
||||
await ElMessageBox.confirm(
|
||||
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
|
||||
'解除映射',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '解除',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
)
|
||||
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
|
||||
await Promise.all([loadRows(), loadStatus()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([loadRows(), loadStatus()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mapping-page {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
min-height: 88px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.status-item {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.status-item span,
|
||||
.status-item small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-item strong {
|
||||
display: block;
|
||||
margin: 5px 0 2px;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.status-item.warning strong {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
|
||||
.status-item.danger strong,
|
||||
.status-item.danger small {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 8px;
|
||||
padding: 14px 16px 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-bottom: 0;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.filter-bar :deep(.el-input) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-wrap :deep(.el-table) {
|
||||
min-width: 1220px;
|
||||
}
|
||||
|
||||
.medicine-name {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subline,
|
||||
.muted {
|
||||
margin-top: 3px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
overflow-x: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.local-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 12px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.local-summary span,
|
||||
.local-summary small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-summary small {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.mapping-form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-row small {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.status-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status-item:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.status-item:nth-child(-n + 2) {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.mapping-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-item,
|
||||
.status-item:nth-child(2) {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-bar :deep(.el-form-item),
|
||||
.filter-bar :deep(.el-input),
|
||||
.filter-bar :deep(.el-select) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface LatestRequestTicket<T> {
|
||||
readonly generation: number
|
||||
readonly snapshot: T
|
||||
}
|
||||
|
||||
export interface LatestRequestGuard<T> {
|
||||
next(snapshot: T): LatestRequestTicket<T>
|
||||
invalidate(): number
|
||||
isLatest(ticket: LatestRequestTicket<T>): boolean
|
||||
}
|
||||
|
||||
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
|
||||
@@ -0,0 +1,22 @@
|
||||
export function createLatestRequestGuard() {
|
||||
let generation = 0
|
||||
|
||||
return {
|
||||
next(snapshot) {
|
||||
generation += 1
|
||||
const stableSnapshot = Array.isArray(snapshot)
|
||||
? [...snapshot]
|
||||
: snapshot && typeof snapshot === 'object'
|
||||
? { ...snapshot }
|
||||
: snapshot
|
||||
return Object.freeze({ generation, snapshot: stableSnapshot })
|
||||
},
|
||||
invalidate() {
|
||||
generation += 1
|
||||
return generation
|
||||
},
|
||||
isLatest(ticket) {
|
||||
return ticket?.generation === generation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,18 @@
|
||||
<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="timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
clearable
|
||||
@change="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行日期">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
@@ -15,11 +27,29 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-select v-model="queryParams.action" clearable placeholder="全部" class="w-[120px]" @change="doSearch">
|
||||
<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-select
|
||||
v-model="queryParams.is_rollback"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
class="w-[120px]"
|
||||
@change="doSearch"
|
||||
>
|
||||
<el-option label="未回退" value="0" />
|
||||
<el-option label="已回退" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
@@ -36,12 +66,41 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="log-hint">
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70% 每人2条/日,50%~60% 每人1条/日,<50% 不分)。每条待指派诊单一行,含分配 / 不分配原因。
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||
<div class="toolbar mb-3">
|
||||
<el-button
|
||||
v-perms="['stats.autoAssignLog/rollback']"
|
||||
type="warning"
|
||||
:disabled="selectedRows.length === 0"
|
||||
:loading="rollbackLoading"
|
||||
@click="handleBatchRollback"
|
||||
>
|
||||
回退选中({{ selectedRows.length }})
|
||||
</el-button>
|
||||
<span v-if="selectedRows.length > 0" class="toolbar-tip">
|
||||
已选 {{ selectedRows.length }} 条可回退记录
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="pager.lists"
|
||||
v-loading="pager.loading"
|
||||
size="default"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
type="selection"
|
||||
width="48"
|
||||
:selectable="rowSelectable"
|
||||
reserve-selection
|
||||
/>
|
||||
<el-table-column label="记录时间" width="160">
|
||||
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -59,6 +118,21 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回退状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.is_rollback) === 1">
|
||||
<el-tag type="warning" size="small">已回退</el-tag>
|
||||
<div class="cell-sub">{{ row.rollback_time_text }}</div>
|
||||
<div v-if="row.rollback_admin_name" class="cell-sub">
|
||||
{{ row.rollback_admin_name }}
|
||||
</div>
|
||||
</template>
|
||||
<el-tag v-else-if="Number(row.can_rollback) === 1" type="success" size="small">
|
||||
可回退
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医助" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.assistant_id) > 0">
|
||||
@@ -72,17 +146,34 @@
|
||||
<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>
|
||||
<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>
|
||||
<el-table-column label="原因" min-width="280" 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">
|
||||
<el-table-column label="批次" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-sub">{{ row.batch_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="Number(row.can_rollback) === 1"
|
||||
v-perms="['stats.autoAssignLog/rollback']"
|
||||
link
|
||||
type="warning"
|
||||
:loading="rollbackLoading"
|
||||
@click="handleSingleRollback(row)"
|
||||
>
|
||||
回退
|
||||
</el-button>
|
||||
<span v-else class="cell-sub">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
@@ -92,16 +183,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="statsAutoAssignLog">
|
||||
import { autoAssignLogLists } from '@/api/stats'
|
||||
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const timeRange = ref<[string, string] | null>(null)
|
||||
const tableRef = ref()
|
||||
const selectedRows = ref<any[]>([])
|
||||
const rollbackLoading = ref(false)
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
action: '' as '' | '0' | '1',
|
||||
is_rollback: '' as '' | '0' | '1',
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
@@ -110,25 +209,80 @@ watch(dateRange, (val) => {
|
||||
queryParams.end_date = val?.[1] ?? ''
|
||||
})
|
||||
|
||||
watch(timeRange, (val) => {
|
||||
queryParams.start_time = val?.[0] ?? ''
|
||||
queryParams.end_time = val?.[1] ?? ''
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: autoAssignLogLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const doSearch = () => resetPage()
|
||||
const doSearch = () => {
|
||||
selectedRows.value = []
|
||||
tableRef.value?.clearSelection?.()
|
||||
resetPage()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
dateRange.value = null
|
||||
timeRange.value = null
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
queryParams.start_time = ''
|
||||
queryParams.end_time = ''
|
||||
queryParams.action = ''
|
||||
queryParams.is_rollback = ''
|
||||
queryParams.keyword = ''
|
||||
resetPage()
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
||||
|
||||
const onSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows.filter((r) => Number(r.can_rollback) === 1)
|
||||
}
|
||||
|
||||
const runRollback = async (ids: number[]) => {
|
||||
if (ids.length === 0) return
|
||||
await feedback.confirm(
|
||||
`确认回退选中的 ${ids.length} 条自动分配?将把诊单医助撤回到自动分配前的原医助,并写入指派日志。`
|
||||
)
|
||||
rollbackLoading.value = true
|
||||
try {
|
||||
const res: any = await autoAssignLogRollback({ ids })
|
||||
const success = Number(res?.success ?? 0)
|
||||
const failed = Number(res?.failed ?? 0)
|
||||
if (failed > 0 && Array.isArray(res?.messages) && res.messages.length) {
|
||||
feedback.msgWarning(
|
||||
`回退成功 ${success} 条,跳过/失败 ${failed} 条:${res.messages.slice(0, 3).join(';')}`
|
||||
)
|
||||
} else {
|
||||
feedback.msgSuccess(`回退成功 ${success} 条`)
|
||||
}
|
||||
selectedRows.value = []
|
||||
tableRef.value?.clearSelection?.()
|
||||
await getLists()
|
||||
} finally {
|
||||
rollbackLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchRollback = () => {
|
||||
const ids = selectedRows.value.map((r) => Number(r.id)).filter((id) => id > 0)
|
||||
return runRollback(ids)
|
||||
}
|
||||
|
||||
const handleSingleRollback = (row: any) => {
|
||||
const id = Number(row?.id ?? 0)
|
||||
if (id <= 0) return
|
||||
return runRollback([id])
|
||||
}
|
||||
|
||||
onMounted(() => getLists())
|
||||
</script>
|
||||
|
||||
@@ -146,6 +300,17 @@ onMounted(() => getLists())
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="row.name" />
|
||||
<MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -321,7 +321,7 @@ const formData = reactive({
|
||||
pulse: '',
|
||||
pulse_condition: '',
|
||||
clinical_diagnosis: '',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||
dose_count: 7,
|
||||
dose_unit: '剂',
|
||||
usage_days: 7,
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
APP_DEBUG = true
|
||||
|
||||
# 恩济药房 ERP:必须写在第一个 INI 分区之前
|
||||
# 在 ej 后台创建商户后,将一次性返回的 app_key、app_secret、webhook_secret 填入下方
|
||||
EJ_PHARMACY_ENABLED = false
|
||||
EJ_PHARMACY_CATALOG_SYNC_ENABLED = false
|
||||
EJ_PHARMACY_BASE_URL = "https://ej.example.com"
|
||||
EJ_PHARMACY_APP_KEY = ""
|
||||
EJ_PHARMACY_APP_SECRET = ""
|
||||
EJ_PHARMACY_CALLBACK_SECRET = ""
|
||||
# auto:NSS cURL 自动使用 PHP OpenSSL;也可显式填写 openssl 或 curl。
|
||||
EJ_PHARMACY_HTTP_TRANSPORT = "auto"
|
||||
# CentOS/RHEL 7 可填写 /etc/pki/tls/certs/ca-bundle.crt;留空使用 PHP 默认 CA。
|
||||
EJ_PHARMACY_CA_FILE = ""
|
||||
EJ_PHARMACY_SUBMISSION_LEASE_SECONDS = 300
|
||||
EJ_PHARMACY_CONNECT_TIMEOUT = 5
|
||||
EJ_PHARMACY_REQUEST_TIMEOUT = 30
|
||||
|
||||
[APP]
|
||||
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\pharmacy;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\pharmacy\MedicineMappingLists;
|
||||
use app\adminapi\logic\pharmacy\MedicineMappingLogic;
|
||||
use app\adminapi\validate\pharmacy\MedicineMappingValidate;
|
||||
|
||||
class MedicineMappingController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new MedicineMappingLists());
|
||||
}
|
||||
|
||||
public function status()
|
||||
{
|
||||
return $this->data(MedicineMappingLogic::status());
|
||||
}
|
||||
|
||||
public function catalogOptions()
|
||||
{
|
||||
return $this->data(MedicineMappingLogic::catalogOptions(
|
||||
(string) $this->request->get('keyword', ''),
|
||||
(int) $this->request->get('limit', 30)
|
||||
));
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$params = (new MedicineMappingValidate())->post()->goCheck('save');
|
||||
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
|
||||
if (!MedicineMappingLogic::save($params, $this->adminId, $name)) {
|
||||
return $this->fail(MedicineMappingLogic::getError());
|
||||
}
|
||||
return $this->success('映射已保存', [], 1, 1);
|
||||
}
|
||||
|
||||
public function unlink()
|
||||
{
|
||||
$params = (new MedicineMappingValidate())->post()->goCheck('unlink');
|
||||
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
|
||||
if (!MedicineMappingLogic::unlink((int) $params['local_medicine_id'], $this->adminId, $name)) {
|
||||
return $this->fail(MedicineMappingLogic::getError());
|
||||
}
|
||||
return $this->success('映射已解除', [], 1, 1);
|
||||
}
|
||||
|
||||
public function sync()
|
||||
{
|
||||
$result = MedicineMappingLogic::sync();
|
||||
if ($result === false) {
|
||||
return $this->fail(MedicineMappingLogic::getError());
|
||||
}
|
||||
return $this->success('目录同步完成', $result);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@ namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\stats\AutoAssignLogLists;
|
||||
use app\adminapi\logic\stats\AutoAssignLogLogic;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派日志
|
||||
*
|
||||
* - GET stats.autoAssignLog/lists 日志列表(每条待指派诊单一行:分配结果 + 原因)
|
||||
* - GET stats.autoAssignLog/lists 日志列表
|
||||
* - POST stats.autoAssignLog/rollback 批量回退已自动分配的医助
|
||||
*/
|
||||
class AutoAssignLogController extends BaseAdminController
|
||||
{
|
||||
@@ -18,4 +20,25 @@ class AutoAssignLogController extends BaseAdminController
|
||||
{
|
||||
return $this->dataLists(new AutoAssignLogLists());
|
||||
}
|
||||
|
||||
public function rollback()
|
||||
{
|
||||
$ids = $this->request->post('ids', []);
|
||||
if (!is_array($ids)) {
|
||||
$ids = [$ids];
|
||||
}
|
||||
$result = AutoAssignLogLogic::rollback($ids, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(AutoAssignLogLogic::getError());
|
||||
}
|
||||
|
||||
$msg = sprintf('回退成功 %d 条', (int) ($result['success'] ?? 0));
|
||||
$failed = (int) ($result['failed'] ?? 0);
|
||||
if ($failed > 0) {
|
||||
$msg .= sprintf(',跳过/失败 %d 条', $failed);
|
||||
}
|
||||
|
||||
// show=0:由前端按 success/failed 明细提示,避免与部分失败警告重复
|
||||
return $this->success($msg, $result, 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,27 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('已保存', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工核对甘草不确定提交:确认远端成功或确认未创建。
|
||||
*/
|
||||
public function confirmGancaoSubmission()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('confirmGancaoSubmission');
|
||||
$result = PrescriptionOrderLogic::confirmGancaoSubmission(
|
||||
(int) $params['id'],
|
||||
(string) $params['resolution'],
|
||||
(string) ($params['remote_order_no'] ?? ''),
|
||||
(string) $params['note'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('甘草提交核对已记录', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改关联处方的患者姓名与手机号(订单详情场景)
|
||||
*/
|
||||
@@ -451,7 +472,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
{
|
||||
try {
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
|
||||
$result = PrescriptionOrderLogic::submitGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
|
||||
if ($result === false) {
|
||||
$error = PrescriptionOrderLogic::getError();
|
||||
@@ -465,7 +486,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->fail('返回数据格式错误');
|
||||
}
|
||||
|
||||
return $this->success('甘草药方上传成功', $result);
|
||||
return $this->success('药方上传成功', $result);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('submitGancaoRecipel exception', [
|
||||
'message' => $e->getMessage(),
|
||||
@@ -477,6 +498,19 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified pharmacy upload. The order ship_mode decides the target.
|
||||
*/
|
||||
public function uploadToPharmacy()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
|
||||
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
return $this->success('药方上传成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
|
||||
* 用于在编辑订单时测试价格和配置
|
||||
|
||||
@@ -17,6 +17,7 @@ declare (strict_types=1);
|
||||
namespace app\adminapi\http\middleware;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
|
||||
use app\common\{
|
||||
cache\AdminAuthCache,
|
||||
service\JsonService
|
||||
@@ -74,7 +75,8 @@ class AuthMiddleware
|
||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||
|
||||
// 判断该当前访问的uri是否存在,不存在无需验证
|
||||
if (!in_array($accessUri, $allUri)) {
|
||||
if (!in_array($accessUri, $allUri, true)
|
||||
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -109,6 +111,10 @@ class AuthMiddleware
|
||||
*/
|
||||
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
||||
{
|
||||
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
|
||||
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
|
||||
}
|
||||
|
||||
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
|
||||
&& in_array($accessUri, [
|
||||
'tcm.diagnosistodo/lists',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\pharmacy;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use think\facade\Db;
|
||||
|
||||
class MedicineMappingLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->query()
|
||||
->field($this->fields())
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->order('l.id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
foreach ([
|
||||
'local_medicine_id', 'local_status', 'mapping_id', 'mapping_status',
|
||||
'operator_id', 'mapping_update_time', 'catalog_version', 'remote_status', 'remote_deleted',
|
||||
] as $field) {
|
||||
if ($row[$field] !== null) {
|
||||
$row[$field] = (int) $row[$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->query()->count('l.id');
|
||||
}
|
||||
|
||||
private function query()
|
||||
{
|
||||
$query = Db::name('doctor_medicine')->alias('l')
|
||||
->leftJoin(
|
||||
'ej_medicine_mapping m',
|
||||
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
|
||||
)
|
||||
->leftJoin('ej_medicine_catalog c', 'c.medicine_code = m.medicine_code')
|
||||
->whereNull('l.delete_time');
|
||||
|
||||
$localName = trim((string) ($this->params['local_name'] ?? ''));
|
||||
if ($localName !== '') {
|
||||
$query->where('l.name', 'like', '%' . $localName . '%');
|
||||
}
|
||||
$remoteKeyword = trim((string) ($this->params['remote_keyword'] ?? ''));
|
||||
if ($remoteKeyword !== '') {
|
||||
$query->where(function ($nested) use ($remoteKeyword): void {
|
||||
$nested->where('c.name', 'like', '%' . $remoteKeyword . '%')
|
||||
->whereOr('c.medicine_code', 'like', '%' . $remoteKeyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$mappingStatus = trim((string) ($this->params['mapping_status'] ?? ''));
|
||||
if ($mappingStatus === 'mapped') {
|
||||
$query->whereNotNull('m.id')->where('c.status', 1)->where('c.remote_deleted', 0);
|
||||
} elseif ($mappingStatus === 'unmapped') {
|
||||
$query->whereNull('m.id');
|
||||
} elseif ($mappingStatus === 'invalid') {
|
||||
$query->whereNotNull('m.id')
|
||||
->where(function ($nested): void {
|
||||
$nested->whereNull('c.id')
|
||||
->whereOr('c.status', '<>', 1)
|
||||
->whereOr('c.remote_deleted', 1);
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function fields(): string
|
||||
{
|
||||
return implode(',', [
|
||||
'l.id AS local_medicine_id',
|
||||
'l.name AS local_name',
|
||||
'l.unit AS local_unit',
|
||||
'l.status AS local_status',
|
||||
'm.id AS mapping_id',
|
||||
'm.medicine_code',
|
||||
'm.operator_id',
|
||||
'm.operator_name',
|
||||
'm.update_time AS mapping_update_time',
|
||||
'c.name AS remote_name',
|
||||
'c.brand AS remote_brand',
|
||||
'c.unit AS remote_unit',
|
||||
'c.settlement_price',
|
||||
'c.retail_price',
|
||||
'c.catalog_version',
|
||||
'c.status AS remote_status',
|
||||
'c.remote_deleted',
|
||||
"CASE WHEN m.id IS NULL THEN 0 "
|
||||
. "WHEN c.id IS NULL OR c.status <> 1 OR c.remote_deleted = 1 THEN 2 ELSE 1 END AS mapping_status",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use think\facade\Db;
|
||||
* 待分配诊单自动指派日志列表
|
||||
*
|
||||
* 数据来源:tcm_diagnosis_auto_assign_log(定时命令 tcm:auto-assign-pending 写入)
|
||||
* 筛选:run_date(执行日期)/ action(1=已分配 0=未分配)/ assistant_id / keyword(患者姓名、手机号、医助姓名,纯数字兼容诊单ID)
|
||||
* 筛选:run_date / create_time 区间 / action / keyword / rollback 状态
|
||||
*/
|
||||
class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
@@ -49,6 +49,17 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
|
||||
$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;
|
||||
|
||||
$rbTs = (int) ($row['rollback_time'] ?? 0);
|
||||
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
|
||||
$row['is_rollback'] = $rbTs > 0 ? 1 : 0;
|
||||
$row['can_rollback'] = ((int) ($row['action'] ?? 0) === 1 && $rbTs === 0) ? 1 : 0;
|
||||
if ($rbTs > 0) {
|
||||
$row['rollback_status_text'] = '已回退'
|
||||
. (($row['rollback_admin_name'] ?? '') !== '' ? (' · ' . $row['rollback_admin_name']) : '');
|
||||
} else {
|
||||
$row['rollback_status_text'] = ((int) ($row['action'] ?? 0) === 1) ? '可回退' : '—';
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
@@ -91,6 +102,46 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
|
||||
$query->where('run_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
// 记录时间(create_time)区间:支持 Y-m-d 或 Y-m-d H:i:s
|
||||
$startTs = $this->parseTimeParam((string) ($this->params['start_time'] ?? ''), false);
|
||||
if ($startTs > 0) {
|
||||
$query->where('create_time', '>=', $startTs);
|
||||
}
|
||||
$endTs = $this->parseTimeParam((string) ($this->params['end_time'] ?? ''), true);
|
||||
if ($endTs > 0) {
|
||||
$query->where('create_time', '<=', $endTs);
|
||||
}
|
||||
|
||||
$rollback = trim((string) ($this->params['is_rollback'] ?? ''));
|
||||
if ($rollback === '1') {
|
||||
$query->where('rollback_time', '>', 0);
|
||||
} elseif ($rollback === '0') {
|
||||
$query->where('rollback_time', '=', 0);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $endOfDay 仅日期时是否取当天结束
|
||||
*/
|
||||
private function parseTimeParam(string $raw, bool $endOfDay): int
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
|
||||
return (int) strtotime($raw . ($endOfDay ? ' 23:59:59' : ' 00:00:00'));
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $raw) === 1) {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $raw) === 1) {
|
||||
$raw .= $endOfDay ? ':59' : ':00';
|
||||
}
|
||||
|
||||
return (int) strtotime($raw);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use app\common\service\pharmacy\EjPharmacyClient;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\db\Query;
|
||||
@@ -333,9 +334,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
|
||||
* 供货方式:洛阳直发(ship_mode=direct,含尚未上传)/ 甘草(有甘草单号)/ 自营(其余)。
|
||||
*
|
||||
* 入参 supply_mode:gancao | self,空表示不限
|
||||
* 入参 supply_mode:gancao | direct | self,空表示不限
|
||||
*/
|
||||
private function applySupplyModeFilter($query): void
|
||||
{
|
||||
@@ -343,13 +344,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
if ($mode === '') {
|
||||
return;
|
||||
}
|
||||
if ($mode === 'direct') {
|
||||
$query->whereRaw("LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'");
|
||||
|
||||
return;
|
||||
}
|
||||
if ($mode === 'gancao') {
|
||||
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''");
|
||||
$query->whereRaw(
|
||||
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
|
||||
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($mode === 'self') {
|
||||
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''");
|
||||
$query->whereRaw(
|
||||
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
|
||||
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -706,6 +718,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$assistantNames = $assistantIds !== []
|
||||
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
|
||||
: [];
|
||||
$orderIdsForClaims = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'id')))));
|
||||
$claimByOrder = [];
|
||||
if ($orderIdsForClaims !== []) {
|
||||
$claimRows = Db::name('pharmacy_submission_claim')
|
||||
->whereIn('prescription_order_id', $orderIdsForClaims)
|
||||
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
|
||||
->order('source_revision', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($claimRows as $claimRow) {
|
||||
$claimId = (int) $claimRow['prescription_order_id'];
|
||||
if (!isset($claimByOrder[$claimId])) {
|
||||
$claimByOrder[$claimId] = $claimRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($lists as &$item) {
|
||||
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
||||
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
|
||||
@@ -713,12 +741,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
|
||||
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
|
||||
$item['deposit_min_amount'] = $depMin;
|
||||
$claim = $claimByOrder[$pid] ?? [];
|
||||
$item['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
|
||||
$item['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
|
||||
$item['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
|
||||
$item,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiag
|
||||
);
|
||||
$item['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
|
||||
$item,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiag
|
||||
);
|
||||
|
||||
// 添加创建人姓名
|
||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||
@@ -766,6 +804,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$base = [
|
||||
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
||||
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
||||
'ej_pharmacy_enabled' => EjPharmacyClient::isConfigured(),
|
||||
'stats_order_amount' => $s['order_amount'],
|
||||
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
|
||||
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
|
||||
@@ -838,7 +877,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
'export_agency_collect' => '代收金额',
|
||||
'export_tracking_number' => '快递单号',
|
||||
'export_sign_time' => '签收日期',
|
||||
'export_supply_mode' => '甘草还是自营',
|
||||
'export_supply_mode' => '供货方式',
|
||||
'export_gancao_prescription_cost' => '处方成本',
|
||||
'export_first_visit_assistant' => '初诊医助',
|
||||
'export_rx_audit_time' => '审核时间',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\pharmacy;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
|
||||
use app\common\service\pharmacy\EjMedicineMappingPolicy;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use Throwable;
|
||||
|
||||
class MedicineMappingLogic extends BaseLogic
|
||||
{
|
||||
public static function save(array $params, int $operatorId, string $operatorName): bool
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
Db::transaction(function () use ($params, $operatorId, $operatorName): void {
|
||||
$localId = (int) $params['local_medicine_id'];
|
||||
$medicineCode = trim((string) $params['medicine_code']);
|
||||
$local = Db::name('doctor_medicine')->where('id', $localId)->lock(true)->find();
|
||||
$remote = Db::name('ej_medicine_catalog')->where('medicine_code', $medicineCode)->lock(true)->find();
|
||||
EjMedicineMappingPolicy::assertValid($local ?: [], $remote ?: []);
|
||||
|
||||
$now = time();
|
||||
$mapping = Db::name('ej_medicine_mapping')
|
||||
->where('local_medicine_id', $localId)
|
||||
->lock(true)
|
||||
->find();
|
||||
$values = [
|
||||
'medicine_code' => $medicineCode,
|
||||
'status' => 1,
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
if ($mapping) {
|
||||
Db::name('ej_medicine_mapping')->where('id', (int) $mapping['id'])->update($values);
|
||||
return;
|
||||
}
|
||||
Db::name('ej_medicine_mapping')->insert(array_merge($values, [
|
||||
'local_medicine_id' => $localId,
|
||||
'create_time' => $now,
|
||||
]));
|
||||
});
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
self::setError(self::isDuplicateKey($exception)
|
||||
? '该本地药材映射刚被其他操作更新,请刷新后重试'
|
||||
: $exception->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function unlink(int $localMedicineId, int $operatorId, string $operatorName): bool
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
Db::transaction(function () use ($localMedicineId, $operatorId, $operatorName): void {
|
||||
$local = Db::name('doctor_medicine')->where('id', $localMedicineId)->lock(true)->find();
|
||||
$mapping = Db::name('ej_medicine_mapping')
|
||||
->where('local_medicine_id', $localMedicineId)
|
||||
->lock(true)
|
||||
->find();
|
||||
$decision = EjMedicineMappingPolicy::unlinkDecision($local ?: [], $mapping ?: null);
|
||||
if ($decision['already_unlinked']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::name('ej_medicine_mapping')
|
||||
->where('id', $decision['mapping_id'])
|
||||
->where('local_medicine_id', $localMedicineId)
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'status' => 0,
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'update_time' => $now,
|
||||
'delete_time' => $now,
|
||||
]);
|
||||
});
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
self::setError($exception->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|false */
|
||||
public static function sync()
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
return EjMedicineCatalogSyncService::sync(200);
|
||||
} catch (Throwable $exception) {
|
||||
self::setError($exception->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function status(): array
|
||||
{
|
||||
$state = Db::name('ej_pharmacy_sync_state')->where('id', 1)->find() ?: [];
|
||||
$catalogTotal = (int) Db::name('ej_medicine_catalog')->count();
|
||||
$catalogActive = (int) Db::name('ej_medicine_catalog')
|
||||
->where('status', 1)->where('remote_deleted', 0)->count();
|
||||
$unmappedLocal = (int) Db::name('doctor_medicine')->alias('l')
|
||||
->leftJoin(
|
||||
'ej_medicine_mapping m',
|
||||
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
|
||||
)
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereNull('m.id')
|
||||
->count('l.id');
|
||||
|
||||
return [
|
||||
'sync_enabled' => (bool) Config::get('ej_pharmacy.catalog_sync_enabled', false),
|
||||
'cursor' => (int) ($state['cursor'] ?? 0),
|
||||
'last_success_time' => (int) ($state['last_success_time'] ?? 0),
|
||||
'last_failure_time' => (int) ($state['last_failure_time'] ?? 0),
|
||||
'last_error_summary' => (string) ($state['last_error_summary'] ?? ''),
|
||||
'is_syncing' => !empty($state['lock_token']) && (int) ($state['lock_expires_at'] ?? 0) >= time(),
|
||||
'catalog_total' => $catalogTotal,
|
||||
'catalog_active' => $catalogActive,
|
||||
'unmapped_local' => $unmappedLocal,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public static function catalogOptions(string $keyword, int $limit = 30): array
|
||||
{
|
||||
$query = Db::name('ej_medicine_catalog')
|
||||
->where('status', 1)
|
||||
->where('remote_deleted', 0);
|
||||
$keyword = trim($keyword);
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($nested) use ($keyword): void {
|
||||
$nested->where('name', 'like', '%' . $keyword . '%')
|
||||
->whereOr('medicine_code', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
return $query
|
||||
->field('medicine_code,name,brand,unit,settlement_price,retail_price,catalog_version,status')
|
||||
->order('catalog_version', 'desc')
|
||||
->limit(min(max($limit, 1), 50))
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function isDuplicateKey(Throwable $exception): bool
|
||||
{
|
||||
return (string) $exception->getCode() === '23000'
|
||||
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派日志:回退已自动分配的医助
|
||||
*/
|
||||
class AutoAssignLogLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 批量回退:将诊单医助撤回到自动分配前的原医助(指派日志 from_assistant_id),并标记自动分配日志已回退。
|
||||
*
|
||||
* @param list<int|string> $ids 自动分配日志 id
|
||||
* @return array{success:int,failed:int,messages:list<string>}|false
|
||||
*/
|
||||
public static function rollback(array $ids, int $adminId, array $adminInfo = []): array|false
|
||||
{
|
||||
$idList = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id) => $id > 0)));
|
||||
if ($idList === []) {
|
||||
self::setError('请选择要回退的记录');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$adminName = (string) ($adminInfo['name'] ?? '');
|
||||
$adminAccount = (string) ($adminInfo['account'] ?? '');
|
||||
$req = request();
|
||||
$ip = (string) ($req->ip() ?? '');
|
||||
$now = time();
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$messages = [];
|
||||
|
||||
foreach ($idList as $logId) {
|
||||
try {
|
||||
$ret = self::rollbackOne($logId, $adminId, $adminName, $adminAccount, $ip, $now);
|
||||
if ($ret === true) {
|
||||
$success++;
|
||||
} else {
|
||||
$failed++;
|
||||
$messages[] = (string) $ret;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$messages[] = sprintf('日志#%d:%s', $logId, $e->getMessage());
|
||||
Log::warning('auto assign rollback failed: ' . $e->getMessage(), ['log_id' => $logId]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($success === 0 && $failed > 0) {
|
||||
self::setError($messages[0] ?? '回退失败');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
'messages' => $messages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true|string true=成功,string=失败原因
|
||||
*/
|
||||
private static function rollbackOne(
|
||||
int $logId,
|
||||
int $adminId,
|
||||
string $adminName,
|
||||
string $adminAccount,
|
||||
string $ip,
|
||||
int $now
|
||||
): bool|string {
|
||||
Db::startTrans();
|
||||
try {
|
||||
$log = Db::name('tcm_diagnosis_auto_assign_log')
|
||||
->where('id', $logId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($log === null || $log === []) {
|
||||
Db::rollback();
|
||||
|
||||
return sprintf('日志#%d:记录不存在', $logId);
|
||||
}
|
||||
|
||||
if ((int) ($log['action'] ?? 0) !== 1) {
|
||||
Db::rollback();
|
||||
|
||||
return sprintf('日志#%d:仅「已分配」记录可回退', $logId);
|
||||
}
|
||||
if ((int) ($log['rollback_time'] ?? 0) > 0) {
|
||||
Db::rollback();
|
||||
|
||||
return sprintf('日志#%d:已回退,勿重复操作', $logId);
|
||||
}
|
||||
|
||||
$diagnosisId = (int) ($log['diagnosis_id'] ?? 0);
|
||||
$assignedAssistantId = (int) ($log['assistant_id'] ?? 0);
|
||||
if ($diagnosisId <= 0 || $assignedAssistantId <= 0) {
|
||||
Db::rollback();
|
||||
|
||||
return sprintf('日志#%d:数据不完整,无法回退', $logId);
|
||||
}
|
||||
|
||||
$diag = Db::name('tcm_diagnosis')
|
||||
->where('id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->field(['id', 'assistant_id'])
|
||||
->find();
|
||||
if ($diag === null || $diag === []) {
|
||||
Db::rollback();
|
||||
|
||||
return sprintf('日志#%d:诊单#%d 不存在', $logId, $diagnosisId);
|
||||
}
|
||||
|
||||
$currentAssistantId = (int) ($diag['assistant_id'] ?? 0);
|
||||
if ($currentAssistantId !== $assignedAssistantId) {
|
||||
Db::rollback();
|
||||
|
||||
return sprintf(
|
||||
'日志#%d:诊单#%d 当前医助已变更(非自动分配的医助),跳过回退',
|
||||
$logId,
|
||||
$diagnosisId
|
||||
);
|
||||
}
|
||||
|
||||
$prevAssistantId = self::resolvePreviousAssistantIdFromAutoAssign(
|
||||
$diagnosisId,
|
||||
$assignedAssistantId,
|
||||
(int) ($log['create_time'] ?? 0)
|
||||
);
|
||||
|
||||
$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')
|
||||
->where('id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'assistant_id' => $prevAssistantId,
|
||||
'assign_read_at' => $prevAssistantId > 0 ? null : 0,
|
||||
]);
|
||||
|
||||
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'from_assistant_id' => $currentAssistantId,
|
||||
'to_assistant_id' => $prevAssistantId,
|
||||
'operator_admin_id' => $adminId,
|
||||
'operator_name' => $adminName !== '' ? $adminName : '回退自动分配',
|
||||
'operator_account' => $adminAccount,
|
||||
'ip' => $ip,
|
||||
'related_po_creator_id' => $relatedPoCreatorId,
|
||||
'related_po_create_time' => $relatedPoCreateTime,
|
||||
'is_inherit' => 0,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
Db::name('tcm_diagnosis_auto_assign_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'rollback_time' => $now,
|
||||
'rollback_admin_id' => $adminId,
|
||||
'rollback_admin_name' => $adminName,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「系统自动分配」指派日志取 from_assistant_id 作为回退目标。
|
||||
*/
|
||||
private static function resolvePreviousAssistantIdFromAutoAssign(
|
||||
int $diagnosisId,
|
||||
int $assignedAssistantId,
|
||||
int $autoLogCreateTime
|
||||
): int {
|
||||
$query = Db::name('tcm_diagnosis_assign_log')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('to_assistant_id', $assignedAssistantId)
|
||||
->where('operator_name', '系统自动分配');
|
||||
|
||||
if ($autoLogCreateTime > 0) {
|
||||
$query->where('create_time', '>=', $autoLogCreateTime - 30)
|
||||
->where('create_time', '<=', $autoLogCreateTime + 30);
|
||||
}
|
||||
|
||||
$row = $query->order('id', 'desc')->field(['from_assistant_id'])->find();
|
||||
if ($row !== null && $row !== []) {
|
||||
return (int) ($row['from_assistant_id'] ?? 0);
|
||||
}
|
||||
|
||||
// 兜底:不限时间窗再查最近一条系统自动分配
|
||||
$fallback = Db::name('tcm_diagnosis_assign_log')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('to_assistant_id', $assignedAssistantId)
|
||||
->where('operator_name', '系统自动分配')
|
||||
->order('id', 'desc')
|
||||
->field(['from_assistant_id'])
|
||||
->find();
|
||||
|
||||
return (int) ($fallback['from_assistant_id'] ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ namespace app\adminapi\logic\tcm;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\tcm\PrescriptionLibrary;
|
||||
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
@@ -15,6 +17,18 @@ use think\facade\Config;
|
||||
*/
|
||||
class PrescriptionLibraryLogic extends BaseLogic
|
||||
{
|
||||
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
|
||||
private static function normalizeHerbIdentities(array $herbs): array
|
||||
{
|
||||
return PharmacyHerbIdentityResolver::resolve(
|
||||
$herbs,
|
||||
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
|
||||
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 是否可管理全部处方(超级管理员 或 配置中的管理员角色)
|
||||
*/
|
||||
@@ -94,7 +108,10 @@ class PrescriptionLibraryLogic extends BaseLogic
|
||||
|
||||
// 处理药材数据
|
||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
||||
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
|
||||
$params['herbs'] = json_encode(
|
||||
self::normalizeHerbIdentities($params['herbs']),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
$model = PrescriptionLibrary::create($params);
|
||||
@@ -130,7 +147,10 @@ class PrescriptionLibraryLogic extends BaseLogic
|
||||
|
||||
// 处理药材数据
|
||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
||||
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
|
||||
$params['herbs'] = json_encode(
|
||||
self::normalizeHerbIdentities($params['herbs']),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
$model->save($params);
|
||||
|
||||
@@ -6,10 +6,13 @@ namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
@@ -159,6 +162,18 @@ class PrescriptionLogic
|
||||
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
|
||||
private static function normalizeHerbIdentities(array $herbs): array
|
||||
{
|
||||
return PharmacyHerbIdentityResolver::resolve(
|
||||
$herbs,
|
||||
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
|
||||
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
|
||||
*/
|
||||
@@ -255,12 +270,11 @@ class PrescriptionLogic
|
||||
self::setError('请添加中药');
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($herbs as $h) {
|
||||
if (empty($h['name'])) {
|
||||
self::setError('中药名称不能为空');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$herbs = self::normalizeHerbIdentities($herbs);
|
||||
} catch (\DomainException $exception) {
|
||||
self::setError($exception->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
$sn = self::generateSn();
|
||||
@@ -335,6 +349,23 @@ class PrescriptionLogic
|
||||
* 编辑处方
|
||||
*/
|
||||
public static function edit(array $params, int $adminId): bool
|
||||
{
|
||||
$prescriptionId = (int) ($params['id'] ?? 0);
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$prescriptionId,
|
||||
static fn (): bool => self::editLocked($params, $adminId)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function editLocked(array $params, int $adminId): bool
|
||||
{
|
||||
try {
|
||||
$prescription = Prescription::find($params['id']);
|
||||
@@ -342,6 +373,11 @@ class PrescriptionLogic
|
||||
self::setError('处方不存在');
|
||||
return false;
|
||||
}
|
||||
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
|
||||
if ($snapshotLockError !== null) {
|
||||
self::setError($snapshotLockError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已通过且未作废:不可编辑
|
||||
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
|
||||
@@ -381,12 +417,7 @@ class PrescriptionLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($herbs as $h) {
|
||||
if (empty($h['name'])) {
|
||||
self::setError('中药名称不能为空');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$herbs = self::normalizeHerbIdentities($herbs);
|
||||
|
||||
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
|
||||
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
|
||||
@@ -474,6 +505,29 @@ class PrescriptionLogic
|
||||
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
|
||||
*/
|
||||
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$rxId,
|
||||
static fn (): bool => self::patchPatientContactLocked(
|
||||
$rxId,
|
||||
$patientName,
|
||||
$phone,
|
||||
$gender,
|
||||
$adminId,
|
||||
$adminInfo
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function patchPatientContactLocked(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
$prescription = Prescription::find($rxId);
|
||||
@@ -482,6 +536,11 @@ class PrescriptionLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
|
||||
if ($snapshotLockError !== null) {
|
||||
self::setError($snapshotLockError);
|
||||
return false;
|
||||
}
|
||||
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
|
||||
self::setError('无权限修改此处方');
|
||||
|
||||
@@ -578,6 +637,22 @@ class PrescriptionLogic
|
||||
* 删除处方
|
||||
*/
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$id,
|
||||
static fn (): bool => self::deleteLocked($id)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function deleteLocked(int $id): bool
|
||||
{
|
||||
try {
|
||||
$prescription = Prescription::find($id);
|
||||
@@ -1027,6 +1102,22 @@ class PrescriptionLogic
|
||||
* 作废处方
|
||||
*/
|
||||
public static function void(int $id, int $adminId, string $adminName): bool
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$id,
|
||||
static fn (): bool => self::voidLocked($id, $adminId, $adminName)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function voidLocked(int $id, int $adminId, string $adminName): bool
|
||||
{
|
||||
$row = Prescription::find($id);
|
||||
if (!$row) {
|
||||
|
||||
@@ -17,11 +17,25 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\dict\DictData;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\pharmacy\EjMedicineMapping;
|
||||
use app\common\model\pharmacy\EjPharmacySubmission;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\ExpressTrackService;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use app\common\service\pharmacy\EjPharmacyClient;
|
||||
use app\common\service\pharmacy\EjPharmacyPayload;
|
||||
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||
use app\common\service\pharmacy\PharmacyRemoteOutcomeClassifier;
|
||||
use app\common\service\pharmacy\PharmacyRemoteSnapshotPolicy;
|
||||
use app\common\service\pharmacy\PharmacyRemoteRejectedException;
|
||||
use app\common\service\pharmacy\PharmacyReconciliationRequiredException;
|
||||
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
|
||||
use app\common\service\pharmacy\PharmacySubmissionClaimService;
|
||||
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
|
||||
use app\common\service\pharmacy\PharmacySupplyMode;
|
||||
use think\db\Query;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
@@ -41,6 +55,42 @@ class PrescriptionOrderLogic
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
private static function assertRemoteSnapshotMutable(PrescriptionOrder $order): bool
|
||||
{
|
||||
try {
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable(
|
||||
$order->toArray(),
|
||||
PharmacySubmissionClaimService::claimForOrder((int) $order->id)
|
||||
);
|
||||
return true;
|
||||
} catch (\DomainException $exception) {
|
||||
self::$error = $exception->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function remoteSnapshotLockErrorForPrescription(int $prescriptionId): ?string
|
||||
{
|
||||
if ($prescriptionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$orders = PrescriptionOrder::where('prescription_id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->select();
|
||||
foreach ($orders as $order) {
|
||||
try {
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable(
|
||||
$order->toArray(),
|
||||
PharmacySubmissionClaimService::claimForOrder((int) $order->id)
|
||||
);
|
||||
} catch (\DomainException $exception) {
|
||||
return $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门自底向上链式名称,含父级(如:总部 / 华东 / 上海门诊)
|
||||
* 使用 Db 直查 zyt_dept:避免 Dept 软删除全局作用域导致有 dept_id 仍拼不出路径
|
||||
@@ -293,6 +343,21 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许在业务订单中选择发货药房(甘草 / 洛阳)。
|
||||
* 无此权限时沿用历史逻辑,订单固定走甘草药房。
|
||||
*/
|
||||
public static function canSelectShipMode(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||
|
||||
return in_array('tcm.prescriptionOrder/setShipMode', $permissions, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PrescriptionOrder $row
|
||||
*/
|
||||
@@ -972,6 +1037,9 @@ class PrescriptionOrderLogic
|
||||
$order->service_package = (string) ($params['service_package'] ?? '');
|
||||
$order->tracking_number = (string) ($params['tracking_number'] ?? '');
|
||||
$order->express_company = self::normalizeExpressCompany($params['express_company'] ?? 'auto');
|
||||
$order->ship_mode = self::canSelectShipMode($adminInfo)
|
||||
? self::normalizeShipMode((string) ($params['ship_mode'] ?? 'gancao'))
|
||||
: 'gancao';
|
||||
$order->fee_type = (int) $params['fee_type'];
|
||||
$order->amount = round((float) $params['amount'], 2);
|
||||
$order->internal_cost = $internalCost;
|
||||
@@ -1153,6 +1221,17 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
$claim = PharmacySubmissionClaimService::claimForOrder($id);
|
||||
$arr['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
|
||||
$arr['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
|
||||
$arr['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
$arr['can_upload_pharmacy'] = self::canUploadToPharmacy(
|
||||
$arr,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$diagIdForMeta > 0 ? [$diagIdForMeta => (int) ($arr['assistant_id'] ?? 0)] : []
|
||||
);
|
||||
|
||||
return $arr;
|
||||
}
|
||||
|
||||
@@ -1165,6 +1244,33 @@ class PrescriptionOrderLogic
|
||||
string $phone,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): bool {
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionPatientLocked(
|
||||
$prescriptionOrderId,
|
||||
$patientName,
|
||||
$phone,
|
||||
$adminId,
|
||||
$adminInfo
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function patchPrescriptionPatientLocked(
|
||||
int $prescriptionOrderId,
|
||||
string $patientName,
|
||||
string $phone,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): bool {
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
||||
@@ -1178,6 +1284,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) ($order->prescription_id ?? 0);
|
||||
if ($rxId <= 0) {
|
||||
self::setError('该订单未关联处方');
|
||||
@@ -1605,6 +1714,23 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function edit(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::editLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function editLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
@@ -1640,11 +1766,8 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
// 已成功提交甘草 SCM:仅允许更新快递单号与承运商(与甘草侧地址/药方等仍以取消流程为准)
|
||||
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no);
|
||||
$gcSubmitTime = (int) $order->gancao_submit_time;
|
||||
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
|
||||
return self::editGancaoLogisticsOnly($order, $params, $adminId, $adminInfo);
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$medDays = $params['medication_days'] ?? null;
|
||||
@@ -1827,6 +1950,23 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function ship(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::shipLocked($id, $expressCompany, $trackingNumber, $shipMode, $adminId, $adminInfo),
|
||||
false
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function shipLocked(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
|
||||
@@ -1854,12 +1994,11 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$shipMode = self::normalizeShipMode($shipMode);
|
||||
$shipMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
|
||||
|
||||
$order->express_company = self::normalizeExpressCompany($expressCompany);
|
||||
$order->tracking_number = $trackingNumber;
|
||||
$order->ship_mode = $shipMode;
|
||||
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
|
||||
$isFirstShip = false;
|
||||
if ((int) $order->fulfillment_status === 2) {
|
||||
@@ -1915,6 +2054,22 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function withdraw(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::withdrawLocked($id, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function withdrawLocked(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
@@ -2194,6 +2349,22 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function revokeRxAudit(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::revokeRxAuditLocked($id, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function revokeRxAuditLocked(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
@@ -4025,8 +4196,7 @@ class PrescriptionOrderLogic
|
||||
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
|
||||
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
|
||||
$item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : '';
|
||||
$isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '';
|
||||
$item['export_supply_mode'] = $isGc ? '甘草' : '自营';
|
||||
$item['export_supply_mode'] = PharmacySupplyMode::label(PharmacySupplyMode::resolve($item));
|
||||
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
|
||||
if (self::canViewInternalCost($adminInfo)) {
|
||||
$rawCost = $item['internal_cost'] ?? null;
|
||||
@@ -4303,6 +4473,12 @@ class PrescriptionOrderLogic
|
||||
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['ej_pharmacy_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (self::pharmacyClaimBlocksUpload($item, 'gancao')) {
|
||||
return false;
|
||||
}
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
@@ -4321,12 +4497,339 @@ class PrescriptionOrderLogic
|
||||
return $aid === $adminId && $adminId > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified upload eligibility for Gancao and Luoyang pharmacy.
|
||||
* A Luoyang order rejected by EJ may be submitted again with a new
|
||||
* source revision; other successful submissions remain one-shot.
|
||||
*
|
||||
* @param array<string,mixed> $item
|
||||
* @param array<int,int|string> $assistantByDiag
|
||||
*/
|
||||
public static function canUploadToPharmacy(array $item, int $adminId, array $adminInfo, array $assistantByDiag = []): bool
|
||||
{
|
||||
$mode = self::normalizeShipMode((string) ($item['ship_mode'] ?? 'gancao'));
|
||||
if ($mode === 'gancao') {
|
||||
return self::canUploadGancaoRecipel($item, $adminId, $adminInfo, $assistantByDiag);
|
||||
}
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
if ((int) ($item['prescription_audit_status'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$fulfillmentStatus = (int) ($item['fulfillment_status'] ?? 0);
|
||||
if (in_array($fulfillmentStatus, [3, 4, 8, 9, 10, 11, 12], true)
|
||||
&& !($fulfillmentStatus === 9 && self::isEjRejectedState($item))) {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['ej_pharmacy_order_no'] ?? '')) !== ''
|
||||
&& !self::isEjRejectedState($item)) {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (self::pharmacyClaimBlocksUpload($item, 'direct')) {
|
||||
return false;
|
||||
}
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo) || (int) ($item['creator_id'] ?? 0) === $adminId) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
self::canViewOrdersForOwnPrescription($adminInfo)
|
||||
&& self::isPrescriptionOrderPrescriber((int) ($item['prescription_id'] ?? 0), $adminId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
$diagnosisId = (int) ($item['diagnosis_id'] ?? 0);
|
||||
return $adminId > 0 && (int) ($assistantByDiag[$diagnosisId] ?? 0) === $adminId;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $item */
|
||||
private static function pharmacyClaimBlocksUpload(array $item, string $mode): bool
|
||||
{
|
||||
if ($mode === 'direct' && self::isEjRejectedState($item)) {
|
||||
return false;
|
||||
}
|
||||
$status = strtoupper(trim((string) ($item['pharmacy_claim_status'] ?? '')));
|
||||
if (!in_array($status, ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'], true)) {
|
||||
return false;
|
||||
}
|
||||
$target = strtolower(trim((string) ($item['pharmacy_claim_target'] ?? '')));
|
||||
$leaseExpiresAt = (int) ($item['pharmacy_claim_lease_expires_at'] ?? 0);
|
||||
if ($status === 'PENDING'
|
||||
&& $mode === 'direct'
|
||||
&& $target === 'direct'
|
||||
&& $leaseExpiresAt > 0
|
||||
&& $leaseExpiresAt <= time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $item */
|
||||
private static function isEjRejectedState(array $item): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($item['ej_pharmacy_status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($item['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads to the pharmacy selected on the business order.
|
||||
*
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function uploadToPharmacy(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$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;
|
||||
}
|
||||
$target = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao')) === 'direct'
|
||||
? 'direct'
|
||||
: 'gancao';
|
||||
$assistantByDiag = Diagnosis::where('id', (int) $order->diagnosis_id)
|
||||
->whereNull('delete_time')
|
||||
->column('assistant_id', 'id');
|
||||
if (!self::canUploadToPharmacy($order->toArray(), $adminId, $adminInfo, $assistantByDiag)) {
|
||||
self::$error = '须处方审核通过且订单未结束后方可上传药房;洛阳药房审核驳回的订单可重新上传';
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$workflow = new PharmacySubmissionClaimWorkflow(
|
||||
static fn (string $claimTarget): array => PharmacySubmissionClaimService::acquire(
|
||||
$id,
|
||||
0,
|
||||
$claimTarget,
|
||||
$adminId,
|
||||
(string) ($adminInfo['name'] ?? '')
|
||||
),
|
||||
static function (string $claimTarget, string $token, array $claim) use ($id, $adminId, $adminInfo): array {
|
||||
$canonicalOrder = self::canonicalPharmacyOrder($id);
|
||||
if ($claimTarget === 'gancao') {
|
||||
$result = self::submitGancaoRemote((int) $canonicalOrder->id, $adminId, $adminInfo);
|
||||
} else {
|
||||
$result = self::uploadEjRemote($canonicalOrder, $adminId, $adminInfo, $claim);
|
||||
}
|
||||
if ($result === false) {
|
||||
$error = self::$error !== '' ? self::$error : '药房上传失败';
|
||||
throw new PharmacyRemoteRejectedException($error);
|
||||
}
|
||||
return $result;
|
||||
},
|
||||
static fn (string $claimTarget, string $token, array $result, array $claim): bool =>
|
||||
PharmacySubmissionClaimService::markSuccess(
|
||||
$id,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1),
|
||||
$claimTarget,
|
||||
$token,
|
||||
$result
|
||||
),
|
||||
static fn (string $claimTarget, string $token, string $error, array $claim): bool =>
|
||||
PharmacySubmissionClaimService::markFailure(
|
||||
$id,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1),
|
||||
$claimTarget,
|
||||
$token,
|
||||
$error
|
||||
),
|
||||
static fn (string $claimTarget, string $token, string $error, array $claim): bool =>
|
||||
PharmacySubmissionClaimService::markReconcile(
|
||||
$id,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1),
|
||||
$claimTarget,
|
||||
$token,
|
||||
$error
|
||||
)
|
||||
);
|
||||
$result = $workflow->execute($target);
|
||||
$remoteOrderNo = (string) (
|
||||
$result['remote_order_no']
|
||||
?? $result['pharmacy_order_no']
|
||||
?? $result['recipel_order_no']
|
||||
?? ''
|
||||
);
|
||||
self::writeLog(
|
||||
$id,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$target === 'direct' ? 'ej_pharmacy_submit' : 'gancao_submit',
|
||||
'上传' . ($target === 'direct' ? '洛阳药房' : '甘草药房') . '成功 ' . $remoteOrderNo
|
||||
);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('upload pharmacy failed', ['order_id' => $id, 'target' => $target, 'error' => $exception->getMessage()]);
|
||||
self::$error = $exception->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|false */
|
||||
public static function confirmGancaoSubmission(
|
||||
int $id,
|
||||
string $resolution,
|
||||
string $remoteOrderNo,
|
||||
string $note,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
) {
|
||||
self::$error = '';
|
||||
$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;
|
||||
}
|
||||
$operatorName = trim((string) ($adminInfo['name'] ?? $adminInfo['nickname'] ?? ''));
|
||||
try {
|
||||
$result = PharmacySubmissionClaimService::resolveGancao(
|
||||
$id,
|
||||
1,
|
||||
$resolution,
|
||||
$remoteOrderNo,
|
||||
$note,
|
||||
$adminId,
|
||||
$operatorName
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
self::$error = $exception->getMessage();
|
||||
return false;
|
||||
}
|
||||
$summary = $result['status'] === 'SUCCESS'
|
||||
? '人工核对甘草提交:确认成功,药方单号 ' . $result['remote_order_no']
|
||||
: '人工核对甘草提交:确认未创建,可重新上传';
|
||||
self::writeLog($id, $adminId, $adminInfo, 'gancao_submission_reconcile', $summary . ';依据:' . trim($note));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function canonicalPharmacyOrder(int $id): PrescriptionOrder
|
||||
{
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
throw new \DomainException('订单不存在,药房提交状态需要人工对账');
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|false */
|
||||
private static function uploadEjRemote(PrescriptionOrder $order, int $adminId, array $adminInfo, array $claim)
|
||||
{
|
||||
try {
|
||||
$prescription = PrescriptionLogic::detail((int) $order->prescription_id, $adminId, $adminInfo);
|
||||
if ($prescription === null) {
|
||||
throw new \DomainException(PrescriptionLogic::getError() ?: '处方不存在');
|
||||
}
|
||||
|
||||
$herbs = PharmacyHerbIdentityResolver::resolve(
|
||||
is_array($prescription['herbs'] ?? null) ? $prescription['herbs'] : [],
|
||||
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
|
||||
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
|
||||
);
|
||||
$localMedicineIds = array_values(array_unique(array_map(
|
||||
static fn (array $herb): int => (int) $herb['medicine_id'],
|
||||
$herbs
|
||||
)));
|
||||
$mapping = EjMedicineMapping::whereIn('local_medicine_id', $localMedicineIds)
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('medicine_code', 'local_medicine_id');
|
||||
|
||||
$prescription['herbs'] = $herbs;
|
||||
$signature = trim((string) ($prescription['doctor_signature'] ?? ''));
|
||||
$prescription['doctor_signature'] = $signature === '' ? [] : [
|
||||
'url' => $signature,
|
||||
'signed_at' => (int) ($prescription['audit_time'] ?? 0) > 0
|
||||
? date(DATE_ATOM, (int) $prescription['audit_time'])
|
||||
: '',
|
||||
];
|
||||
$prescription['processing_type'] = (int) ($prescription['need_decoction'] ?? 1) === 1
|
||||
? 'decoction'
|
||||
: 'dispensing';
|
||||
$payload = EjPharmacyPayload::build(
|
||||
$order->toArray(),
|
||||
$prescription,
|
||||
$mapping,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1)
|
||||
);
|
||||
$client = new EjPharmacyClient();
|
||||
} catch (\Throwable $exception) {
|
||||
throw new PharmacyRemoteRejectedException($exception->getMessage(), 0, $exception);
|
||||
}
|
||||
if (!empty($claim['reconcile'])) {
|
||||
$query = $client->prescriptionOrder((string) $order->order_no, 1);
|
||||
$queryBody = is_array($query['body'] ?? null) ? $query['body'] : [];
|
||||
$queryData = is_array($queryBody['data'] ?? null) ? $queryBody['data'] : [];
|
||||
$queryRemoteNo = trim((string) ($queryData['pharmacy_order_no'] ?? ''));
|
||||
if ((int) ($query['http_status'] ?? 0) === 200 && (int) ($queryBody['code'] ?? -1) === 0 && $queryRemoteNo !== '') {
|
||||
return $queryData + [
|
||||
'pharmacy' => 'ej',
|
||||
'pharmacy_order_no' => $queryRemoteNo,
|
||||
'remote_order_no' => $queryRemoteNo,
|
||||
'request_id' => (string) ($query['request_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
if ((int) ($query['http_status'] ?? 0) !== 404) {
|
||||
throw new \RuntimeException('洛阳药房对账查询失败,保留待对账状态');
|
||||
}
|
||||
}
|
||||
$response = $client->createPrescriptionOrder($payload);
|
||||
$body = $response['body'];
|
||||
$data = is_array($body['data'] ?? null) ? $body['data'] : [];
|
||||
$remoteOrderNo = trim((string) ($data['pharmacy_order_no'] ?? ''));
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
if ($httpStatus >= 500) {
|
||||
throw new \RuntimeException(trim((string) ($body['message'] ?? '洛阳药房服务异常')));
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300) {
|
||||
$message = trim((string) ($body['message'] ?? '洛阳药房下单响应异常'));
|
||||
if (PharmacyRemoteOutcomeClassifier::isConfirmedEjNoCreateHttpStatus($httpStatus)) {
|
||||
throw new PharmacyRemoteRejectedException($message);
|
||||
}
|
||||
throw new \RuntimeException($message . ',远端结果不确定,需要对账');
|
||||
}
|
||||
if ((int) ($body['code'] ?? -1) !== 0) {
|
||||
throw new PharmacyRemoteRejectedException(trim((string) ($body['message'] ?? '洛阳药房明确拒绝下单')));
|
||||
}
|
||||
if ($remoteOrderNo === '') {
|
||||
throw new \RuntimeException('洛阳药房返回成功但缺少远程订单号,需要对账');
|
||||
}
|
||||
|
||||
return $data + [
|
||||
'pharmacy' => 'ej',
|
||||
'pharmacy_order_no' => $remoteOrderNo,
|
||||
'remote_order_no' => $remoteOrderNo,
|
||||
'request_id' => (string) ($response['request_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** Controlled compatibility alias; ship_mode still selects the pharmacy target. */
|
||||
public static function submitGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
return self::uploadToPharmacy($id, $adminId, $adminInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前业务订单关联处方提交至甘草药管家(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)。
|
||||
*
|
||||
* @return array<string,mixed>|false 成功返回甘草 result 主要字段
|
||||
*/
|
||||
public static function submitGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
private static function submitGancaoRemote(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
|
||||
self::$error = '';
|
||||
@@ -4455,7 +4958,7 @@ class PrescriptionOrderLogic
|
||||
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
|
||||
]);
|
||||
|
||||
return false;
|
||||
throw new PharmacyReconciliationRequiredException(self::$error);
|
||||
}
|
||||
$subBody = $subRet['body'] ?? [];
|
||||
|
||||
@@ -4470,27 +4973,16 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? '');
|
||||
if ($gcNo === '') {
|
||||
self::$error = '甘草返回缺少 recipel_order_no';
|
||||
|
||||
return false;
|
||||
self::$error = '甘草下单可能已成功,但返回缺少 recipel_order_no,必须对账后再操作';
|
||||
throw new PharmacyReconciliationRequiredException(self::$error);
|
||||
}
|
||||
$order->gancao_reciperl_order_no = mb_substr($gcNo, 0, 32);
|
||||
$order->gancao_submit_time = time();
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = '本地保存甘草单号失败:' . $e->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'gancao_submit', '上传甘草药方成功 ' . $gcNo);
|
||||
|
||||
$fee = $subBody['result']['fee'] ?? [];
|
||||
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
||||
|
||||
return [
|
||||
'pharmacy' => 'gancao',
|
||||
'recipel_order_no' => $gcNo,
|
||||
'remote_order_no' => $gcNo,
|
||||
'app_order_no' => $appNo,
|
||||
'fee' => is_array($fee) ? $fee : [],
|
||||
];
|
||||
@@ -4627,6 +5119,22 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function setShipMode(int $id, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::setShipModeLocked($id, $shipMode, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function setShipModeLocked(int $id, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
|
||||
@@ -4641,6 +5149,14 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::canSelectShipMode($adminInfo)) {
|
||||
self::$error = '无权限设置发货药房';
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
$fs = (int) $order->fulfillment_status;
|
||||
if ($fs === 3 || $fs === 4) {
|
||||
self::$error = '已完成或已取消的订单不可修改发货类型';
|
||||
@@ -4654,6 +5170,10 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($order->ej_pharmacy_order_no ?? '')) !== '') {
|
||||
self::$error = '已上传洛阳药房,不可修改发货类型';
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||
if ($oldMode === $shipMode) {
|
||||
@@ -4888,6 +5408,23 @@ class PrescriptionOrderLogic
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function patchPrescriptionUsageLocked(array $params, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||
@@ -4902,6 +5439,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status === 4) {
|
||||
self::setError('已取消的订单不可修改');
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\pharmacy;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
class MedicineMappingValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'local_medicine_id' => 'require|integer|gt:0',
|
||||
'medicine_code' => 'require|max:32',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'local_medicine_id.require' => '本地药材ID不能为空',
|
||||
'local_medicine_id.integer' => '本地药材ID格式错误',
|
||||
'local_medicine_id.gt' => '本地药材ID格式错误',
|
||||
'medicine_code.require' => '请选择洛阳药房药材',
|
||||
'medicine_code.max' => '洛阳药房药材编码不能超过32个字符',
|
||||
];
|
||||
|
||||
public function sceneSave(): self
|
||||
{
|
||||
return $this->only(['local_medicine_id', 'medicine_code']);
|
||||
}
|
||||
|
||||
public function sceneUnlink(): self
|
||||
{
|
||||
return $this->only(['local_medicine_id']);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'tracking_number' => 'max:80',
|
||||
'express_company' => 'max:20',
|
||||
'ship_mode' => 'in:gancao,direct',
|
||||
'resolution' => 'require|in:CONFIRM_SUCCESS,CONFIRM_NOT_CREATED',
|
||||
'remote_order_no' => 'max:64',
|
||||
'note' => 'require|max:1000',
|
||||
'fee_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||
'amount' => 'require|float',
|
||||
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||
@@ -64,7 +67,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'create' => [
|
||||
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
|
||||
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
'tracking_number', 'express_company', 'ship_mode', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
],
|
||||
'detail' => ['id'],
|
||||
'edit' => [
|
||||
@@ -87,11 +90,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'complete' => ['id', 'fulfillment_status'],
|
||||
'refund' => ['id', 'reason', 'refund_amount'],
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'uploadToPharmacy' => ['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'],
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
];
|
||||
|
||||
public function updateAmount(): PrescriptionOrderValidate
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use DomainException;
|
||||
use app\common\model\ExpressTrace;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\model\pharmacy\EjPharmacyCallbackInbox;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
|
||||
use app\common\service\pharmacy\EjPharmacyCallbackFailureTransition;
|
||||
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
|
||||
use app\common\service\pharmacy\EjPharmacySignature;
|
||||
use app\common\service\pharmacy\EjPharmacyShipmentPolicy;
|
||||
use app\common\service\pharmacy\EjPharmacyTrackingPolicy;
|
||||
use app\common\service\pharmacy\PharmacyLogisticsValue;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use Throwable;
|
||||
|
||||
class EjPharmacyCallbackController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['webhook'];
|
||||
|
||||
public function webhook()
|
||||
{
|
||||
$body = $this->callbackBody();
|
||||
if (!$this->isAuthenticCallback($body)) {
|
||||
return $this->callbackResponse(['code' => 401, 'message' => 'invalid signature'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->decodePayload($body);
|
||||
$payload = array_replace($payload, $this->normalizeLogistics($payload));
|
||||
$result = $this->callbackWorkflow()->handle($payload);
|
||||
$httpStatus = (int) ($result['http_status'] ?? 500);
|
||||
$message = (string) ($result['message'] ?? 'callback processing failed');
|
||||
if ($httpStatus >= 500) {
|
||||
$this->logCallbackFailure($message);
|
||||
}
|
||||
|
||||
return $this->callbackResponse([
|
||||
'code' => $httpStatus === 200 ? 0 : $httpStatus,
|
||||
'message' => $message,
|
||||
'data' => ['duplicate' => (bool) ($result['duplicate'] ?? false)],
|
||||
], $httpStatus);
|
||||
} catch (JsonException $exception) {
|
||||
return $this->callbackResponse(['code' => 400, 'message' => 'invalid JSON payload'], 400);
|
||||
} catch (InvalidArgumentException|DomainException $exception) {
|
||||
return $this->callbackResponse(['code' => 422, 'message' => $exception->getMessage()], 422);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logCallbackFailure($exception->getMessage());
|
||||
return $this->callbackResponse(['code' => 500, 'message' => $exception->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
protected function callbackResponse(array $payload, int $httpStatus)
|
||||
{
|
||||
return json($payload, $httpStatus);
|
||||
}
|
||||
|
||||
protected function logCallbackFailure(string $message): void
|
||||
{
|
||||
Log::error('ej pharmacy callback failed', ['error' => $message]);
|
||||
}
|
||||
|
||||
protected function callbackBody(): string
|
||||
{
|
||||
return (string) $this->request->getContent();
|
||||
}
|
||||
|
||||
protected function isAuthenticCallback(string $body): bool
|
||||
{
|
||||
$timestamp = trim((string) $this->request->header('x-timestamp'));
|
||||
$nonce = trim((string) $this->request->header('x-nonce'));
|
||||
$signature = trim((string) $this->request->header('x-signature'));
|
||||
$secret = trim((string) Config::get('ej_pharmacy.callback_secret', ''));
|
||||
$canonical = EjPharmacySignature::canonical('POST', '/api/ej-pharmacy/webhook', $timestamp, $nonce, $body);
|
||||
|
||||
return $secret !== ''
|
||||
&& ctype_digit($timestamp)
|
||||
&& abs(time() - (int) $timestamp) <= 300
|
||||
&& $nonce !== ''
|
||||
&& EjPharmacySignature::verify($secret, $canonical, $signature);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
protected function decodePayload(string $body): array
|
||||
{
|
||||
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($payload)) {
|
||||
throw new InvalidArgumentException('payload must be an object');
|
||||
}
|
||||
foreach (['event_id', 'pharmacy_order_no', 'source_order_no'] as $field) {
|
||||
if (trim((string) ($payload[$field] ?? '')) === '') {
|
||||
throw new InvalidArgumentException($field . ' is required');
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
protected function callbackWorkflow(): EjPharmacyCallbackWorkflow
|
||||
{
|
||||
$loadInbox = static function (string $eventId): ?array {
|
||||
$inbox = EjPharmacyCallbackInbox::where('event_id', $eventId)->find();
|
||||
return $inbox ? $inbox->toArray() : null;
|
||||
};
|
||||
|
||||
return new EjPharmacyCallbackWorkflow(
|
||||
$loadInbox,
|
||||
static function (array $payload): array {
|
||||
$inbox = EjPharmacyCallbackInbox::create([
|
||||
'event_id' => trim((string) $payload['event_id']),
|
||||
'pharmacy_order_no' => trim((string) $payload['pharmacy_order_no']),
|
||||
'source_order_no' => trim((string) $payload['source_order_no']),
|
||||
'event_type' => (string) ($payload['event_type'] ?? ''),
|
||||
'status_version' => (int) ($payload['status_version'] ?? 0),
|
||||
'payload' => json_encode(
|
||||
$payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
||||
),
|
||||
'process_status' => 'PENDING',
|
||||
]);
|
||||
return $inbox->toArray();
|
||||
},
|
||||
$loadInbox,
|
||||
fn (array $inbox, array $payload): array => $this->processCallback($inbox, $payload),
|
||||
static function (array $inbox, string $error): void {
|
||||
$inboxId = (int) ($inbox['id'] ?? 0);
|
||||
$updated = EjPharmacyCallbackFailureTransition::apply(
|
||||
$inboxId,
|
||||
$error,
|
||||
static fn (int $id, array $values, string $protectedStatus): bool =>
|
||||
EjPharmacyCallbackInbox::where('id', $id)
|
||||
->where('process_status', '<>', $protectedStatus)
|
||||
->update($values) === 1
|
||||
);
|
||||
if ($updated) {
|
||||
return;
|
||||
}
|
||||
$currentStatus = EjPharmacyCallbackInbox::where('id', $inboxId)->value('process_status');
|
||||
if ($currentStatus === null) {
|
||||
throw new RuntimeException('callback inbox could not be marked failed');
|
||||
}
|
||||
},
|
||||
static fn (Throwable $exception): bool => (string) $exception->getCode() === '23000'
|
||||
|| str_contains($exception->getMessage(), '1062')
|
||||
|| str_contains(strtolower($exception->getMessage()), 'duplicate')
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $inbox @param array<string,mixed> $payload @return array<string,mixed> */
|
||||
private function processCallback(array $inbox, array $payload): array
|
||||
{
|
||||
$logistics = $payload;
|
||||
$pharmacyOrderNo = trim((string) $payload['pharmacy_order_no']);
|
||||
$sourceOrderNo = trim((string) $payload['source_order_no']);
|
||||
|
||||
return Db::transaction(function () use ($inbox, $payload, $logistics, $pharmacyOrderNo, $sourceOrderNo): array {
|
||||
$inboxModel = EjPharmacyCallbackInbox::where('id', (int) ($inbox['id'] ?? 0))->lock(true)->find();
|
||||
if (!$inboxModel) {
|
||||
throw new RuntimeException('callback inbox does not exist');
|
||||
}
|
||||
if (strtoupper((string) $inboxModel->process_status) === 'PROCESSED') {
|
||||
return $inboxModel->toArray();
|
||||
}
|
||||
|
||||
$order = PrescriptionOrder::where('ej_pharmacy_order_no', $pharmacyOrderNo)
|
||||
->where('order_no', $sourceOrderNo)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立,请稍后重试');
|
||||
}
|
||||
|
||||
$incomingVersion = (int) ($payload['status_version'] ?? 0);
|
||||
$previousFulfillmentStatus = (int) ($order->fulfillment_status ?? 0);
|
||||
$rollbackFulfillmentStatus = (int) ($order->ej_pharmacy_previous_fulfillment_status ?? 0);
|
||||
$versionAdvanced = $incomingVersion > (int) ($order->ej_pharmacy_status_version ?? 0);
|
||||
$nextFulfillmentStatus = $previousFulfillmentStatus;
|
||||
if ($versionAdvanced) {
|
||||
$nextFulfillmentStatus = EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||||
$previousFulfillmentStatus,
|
||||
$payload,
|
||||
$rollbackFulfillmentStatus > 0 ? $rollbackFulfillmentStatus : null
|
||||
);
|
||||
$orderValues = [
|
||||
'ej_pharmacy_status' => (string) ($payload['status'] ?? $order->ej_pharmacy_status),
|
||||
'ej_pharmacy_review_status' => (string) ($payload['review_status'] ?? $order->ej_pharmacy_review_status),
|
||||
'ej_pharmacy_status_version' => $incomingVersion,
|
||||
'ej_pharmacy_current_step' => mb_substr((string) ($payload['step_name'] ?? ''), 0, 100),
|
||||
'ej_pharmacy_remark' => mb_substr((string) ($payload['remark'] ?? ''), 0, 500),
|
||||
'express_company' => $logistics['express_company'] !== ''
|
||||
? $logistics['express_company']
|
||||
: (string) $order->express_company,
|
||||
'tracking_number' => $logistics['tracking_number'] !== ''
|
||||
? $logistics['tracking_number']
|
||||
: (string) $order->tracking_number,
|
||||
];
|
||||
if ($nextFulfillmentStatus !== (int) ($order->fulfillment_status ?? 0)) {
|
||||
$orderValues['fulfillment_status'] = $nextFulfillmentStatus;
|
||||
}
|
||||
$order->save($orderValues);
|
||||
$tracking = self::syncLogistics($order, $payload, $logistics);
|
||||
if (EjPharmacyShipmentPolicy::isShippedEvent($payload)
|
||||
&& (int) ($order->fulfillment_status ?? 0) === 5) {
|
||||
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
||||
'tracking_id' => $tracking ? (int) $tracking->id : 0,
|
||||
'tracking_number' => (string) ($order->tracking_number ?? ''),
|
||||
'source' => 'ej_pharmacy_callback',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (!$versionAdvanced) {
|
||||
$nextFulfillmentStatus = EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||||
$previousFulfillmentStatus,
|
||||
$payload,
|
||||
$rollbackFulfillmentStatus > 0 ? $rollbackFulfillmentStatus : null
|
||||
);
|
||||
}
|
||||
|
||||
// Each unique callback is an auditable order operation, including
|
||||
// callbacks that arrive out of order and are ignored by the
|
||||
// status-version gate. This keeps EJ review/production nodes
|
||||
// visible in the same timeline as the upload operation.
|
||||
$eventType = strtoupper(trim((string) ($payload['event_type'] ?? '')));
|
||||
$stepName = trim((string) ($payload['step_name'] ?? ''));
|
||||
$operatorName = trim((string) ($payload['operator_name'] ?? ''));
|
||||
$remoteStatus = trim((string) ($payload['status'] ?? ''));
|
||||
$displayOperatorName = self::callbackOperatorLabel($operatorName);
|
||||
$isWorkflowStep = $eventType === 'WORKFLOW_STEP_COMPLETED' && $stepName !== '';
|
||||
if ($isWorkflowStep) {
|
||||
// Keep the same readable production-flow format as Gancao:
|
||||
// flow name and pharmacy are the primary information.
|
||||
$summaryParts = [
|
||||
'洛阳药房回传:订单药房流转制作中',
|
||||
'流程:' . $stepName,
|
||||
'药房:洛阳药房',
|
||||
];
|
||||
if ($displayOperatorName !== '') {
|
||||
$summaryParts[] = '操作人:' . $displayOperatorName;
|
||||
}
|
||||
} else {
|
||||
$summaryParts = ['洛阳药房回传:' . self::callbackEventLabel($eventType)];
|
||||
if ($operatorName !== '') {
|
||||
$summaryParts[] = '操作人:' . $displayOperatorName;
|
||||
}
|
||||
if ($remoteStatus !== '') {
|
||||
$summaryParts[] = '远端状态:' . self::callbackStatusLabel($remoteStatus);
|
||||
}
|
||||
$summaryParts[] = '履约状态:' . self::fulfillmentStatusLabel($previousFulfillmentStatus)
|
||||
. ' → ' . self::fulfillmentStatusLabel($nextFulfillmentStatus);
|
||||
if (!$versionAdvanced) {
|
||||
$summaryParts[] = '(版本已处理,保留回传日志)';
|
||||
}
|
||||
}
|
||||
$log = new PrescriptionOrderLog();
|
||||
$log->prescription_order_id = (int) $order->id;
|
||||
$log->admin_id = 0;
|
||||
$log->admin_name = mb_substr(
|
||||
$isWorkflowStep
|
||||
? '洛阳药房系统'
|
||||
: ($operatorName !== '' ? '洛阳药房:' . $displayOperatorName : '洛阳药房ERP'),
|
||||
0,
|
||||
64
|
||||
);
|
||||
$log->action = 'ej_pharmacy_callback';
|
||||
// Match the Gancao production-flow presentation so both pharmacy
|
||||
// timelines use the same field separator.
|
||||
$log->summary = mb_substr(implode($isWorkflowStep ? ' | ' : ';', $summaryParts), 0, 500);
|
||||
$log->create_time = time();
|
||||
$log->save();
|
||||
$inboxModel->save(['process_status' => 'PROCESSED', 'processed_time' => time(), 'error_message' => '']);
|
||||
|
||||
return $inboxModel->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
private static function fulfillmentStatusLabel(int $status): string
|
||||
{
|
||||
return [
|
||||
1 => '待双审通过',
|
||||
2 => '待发货/发货与履约',
|
||||
3 => '已完成',
|
||||
4 => '已取消',
|
||||
5 => '已发货',
|
||||
6 => '已签收',
|
||||
7 => '进行中',
|
||||
8 => '暂不制药',
|
||||
9 => '拒收',
|
||||
10 => '退款',
|
||||
11 => '保留药方',
|
||||
12 => '制药缓发',
|
||||
][$status] ?? ('状态' . $status);
|
||||
}
|
||||
|
||||
private static function callbackEventLabel(string $eventType): string
|
||||
{
|
||||
return [
|
||||
'ORDER_CREATED' => '订单已创建',
|
||||
'REVIEW_APPROVED' => '审核通过',
|
||||
'REVIEW_REJECTED' => '审核驳回',
|
||||
'INVENTORY_SHORTAGE' => '库存不足',
|
||||
'WORKFLOW_STEP_COMPLETED' => '流程节点完成',
|
||||
'ORDER_SHIPPED' => '订单已发货',
|
||||
'ORDER_UPDATED' => '订单状态更新',
|
||||
][$eventType] ?? ($eventType !== '' ? $eventType : '状态更新');
|
||||
}
|
||||
|
||||
private static function callbackStatusLabel(string $status): string
|
||||
{
|
||||
return [
|
||||
'PENDING_REVIEW' => '待审核',
|
||||
'READY_FOR_PRODUCTION' => '待生产',
|
||||
'IN_PRODUCTION' => '生产中',
|
||||
'PACKAGED' => '已包装',
|
||||
'SHIPPED' => '已发货',
|
||||
'COMPLETED' => '已完成',
|
||||
'REJECTED' => '已驳回',
|
||||
'STOCK_SHORTAGE' => '库存不足',
|
||||
'CANCELLED' => '已取消',
|
||||
][$status] ?? $status;
|
||||
}
|
||||
|
||||
private static function callbackOperatorLabel(string $operatorName): string
|
||||
{
|
||||
return [
|
||||
'admin' => '管理员',
|
||||
'admin_user' => '管理员',
|
||||
'system' => '系统',
|
||||
][$operatorName] ?? $operatorName;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{express_company:string,tracking_number:string} */
|
||||
private function normalizeLogistics(array $payload): array
|
||||
{
|
||||
return [
|
||||
'express_company' => PharmacyLogisticsValue::normalize(
|
||||
$payload['express_company'] ?? '',
|
||||
32,
|
||||
'快递公司'
|
||||
),
|
||||
'tracking_number' => PharmacyLogisticsValue::normalize(
|
||||
$payload['tracking_number'] ?? '',
|
||||
100,
|
||||
'运单号'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
private static function syncLogistics(PrescriptionOrder $order, array $payload, array $logistics): ?ExpressTracking
|
||||
{
|
||||
$trackingNumber = $logistics['tracking_number'];
|
||||
if ($trackingNumber === '') {
|
||||
return null;
|
||||
}
|
||||
$tracking = ExpressTracking::where('order_id', (int) $order->id)
|
||||
->where('order_type', 'prescription')
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->lock(true)
|
||||
->find();
|
||||
$matching = null;
|
||||
if (!$tracking || trim((string) $tracking->tracking_number) !== $trackingNumber) {
|
||||
$matching = ExpressTracking::where('tracking_number', $trackingNumber)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
}
|
||||
$decision = EjPharmacyTrackingPolicy::select(
|
||||
$tracking ? $tracking->toArray() : null,
|
||||
$matching ? $matching->toArray() : null,
|
||||
(int) $order->id,
|
||||
$trackingNumber
|
||||
);
|
||||
$now = time();
|
||||
if ($decision['archive_current']) {
|
||||
ExpressTracking::where('order_id', (int) $order->id)
|
||||
->where('order_type', 'prescription')
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'order_type' => 'prescription_history',
|
||||
'auto_update' => 0,
|
||||
'next_update_time' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
if ($decision['action'] === 'REUSE_MATCHING') {
|
||||
$tracking = $matching;
|
||||
} elseif ($decision['action'] === 'CREATE') {
|
||||
$tracking = new ExpressTracking();
|
||||
$tracking->create_time = $now;
|
||||
}
|
||||
$trackingState = trim((string) ($payload['logistics_state'] ?? ''));
|
||||
$tracking->save([
|
||||
'order_id' => (int) $order->id,
|
||||
'order_type' => 'prescription',
|
||||
'tracking_number' => $trackingNumber,
|
||||
'express_company' => $logistics['express_company'] !== '' ? $logistics['express_company'] : 'auto',
|
||||
'express_company_name' => mb_substr((string) ($payload['express_company_name'] ?? ''), 0, 100),
|
||||
'recipient_phone' => mb_substr((string) $order->recipient_phone, 0, 20),
|
||||
'recipient_name' => mb_substr((string) $order->recipient_name, 0, 100),
|
||||
'recipient_address' => mb_substr((string) $order->shipping_address, 0, 500),
|
||||
'current_state' => $trackingState !== '' ? $trackingState : (string) ($tracking->current_state ?: '0'),
|
||||
'current_state_text' => mb_substr(
|
||||
(string) ($payload['logistics_state_text'] ?? $tracking->current_state_text ?? '在途'),
|
||||
0,
|
||||
50
|
||||
),
|
||||
'data_source' => 'ej_pharmacy',
|
||||
'auto_update' => 1,
|
||||
'next_update_time' => $now + 1800,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$traces = is_array($payload['logistics_traces'] ?? null) ? $payload['logistics_traces'] : [];
|
||||
foreach ($traces as $trace) {
|
||||
if (!is_array($trace)) {
|
||||
continue;
|
||||
}
|
||||
$context = mb_substr((string) ($trace['context'] ?? ''), 0, 1000);
|
||||
$timestamp = (int) ($trace['timestamp'] ?? 0);
|
||||
if ($context === '' || ExpressTrace::where('tracking_id', (int) $tracking->id)
|
||||
->where('trace_time_stamp', $timestamp)
|
||||
->where('trace_context', $context)
|
||||
->count() > 0) {
|
||||
continue;
|
||||
}
|
||||
ExpressTrace::create([
|
||||
'tracking_id' => (int) $tracking->id,
|
||||
'tracking_number' => $trackingNumber,
|
||||
'trace_time' => (string) ($trace['time'] ?? ''),
|
||||
'trace_time_stamp' => $timestamp,
|
||||
'trace_context' => $context,
|
||||
'status' => (string) ($trace['status'] ?? ''),
|
||||
'status_code' => (string) ($trace['status_code'] ?? ''),
|
||||
'location' => (string) ($trace['location'] ?? ''),
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use app\api\logic\tcm\DailyFamilyLikeLogic;
|
||||
use app\api\logic\tcm\DailyShareLogic;
|
||||
use app\api\logic\tcm\DailyPhoneLogic;
|
||||
use app\api\logic\tcm\DailyDietAiLogic;
|
||||
use app\api\logic\tcm\GamePlatformLogic;
|
||||
use app\adminapi\logic\ConfigLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
@@ -40,6 +41,54 @@ class TcmController extends BaseApiController
|
||||
*/
|
||||
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
|
||||
|
||||
/** 获取当前登录用户的控糖消消乐每周7人同行榜。 */
|
||||
public function gameWeeklyLeaderboard()
|
||||
{
|
||||
$result = GamePlatformLogic::leaderboard((int) $this->userId);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** 幂等上报一局的认糖进度和分数。 */
|
||||
public function gameSubmitProgress()
|
||||
{
|
||||
$result = GamePlatformLogic::submitProgress((int) $this->userId, [
|
||||
'session_key' => (string) $this->request->post('session_key', ''),
|
||||
'learned_count' => (int) $this->request->post('learned_count', 0),
|
||||
'score' => (int) $this->request->post('score', 0),
|
||||
'ended' => (int) $this->request->post('ended', 0),
|
||||
]);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** 记录发起分享,返回本周不包含用户ID的分享码。 */
|
||||
public function gameRecordShare()
|
||||
{
|
||||
$result = GamePlatformLogic::recordShare((int) $this->userId);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** 登录用户从微信分享卡片进入时,记录一次轻量同行关系。 */
|
||||
public function gameAcceptShare()
|
||||
{
|
||||
$result = GamePlatformLogic::acceptShare(
|
||||
(int) $this->userId,
|
||||
(string) $this->request->post('invite_code', '')
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取患者签名(供小程序调用)
|
||||
* @return \think\response\Json
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\service\FileService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
|
||||
*/
|
||||
class GamePlatformLogic
|
||||
{
|
||||
private const GROUP_SIZE = 7;
|
||||
private const MAX_SESSION_LEARNED = 20000;
|
||||
private const MAX_SCORE = 100000000;
|
||||
|
||||
protected static string $error = '';
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function fail(string $message): bool
|
||||
{
|
||||
self::$error = $message;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
|
||||
*/
|
||||
public static function leaderboard(int $userId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
|
||||
try {
|
||||
Db::startTrans();
|
||||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||
Db::commit();
|
||||
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('leaderboard', $e);
|
||||
return self::fail('同行榜暂时不可用,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
|
||||
*
|
||||
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
|
||||
*/
|
||||
public static function submitProgress(int $userId, array $params): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
|
||||
$sessionKey = trim((string) ($params['session_key'] ?? ''));
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
|
||||
return self::fail('游戏局标识无效');
|
||||
}
|
||||
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
|
||||
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
|
||||
$ended = !empty($params['ended']) ? 1 : 0;
|
||||
$weekStart = self::weekStart();
|
||||
|
||||
try {
|
||||
Db::startTrans();
|
||||
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
|
||||
$session = Db::name('tcm_game_session')
|
||||
->where('session_key', $sessionKey)
|
||||
->lock(true)
|
||||
->find();
|
||||
|
||||
$now = time();
|
||||
if ($session && (int) $session['user_id'] !== $userId) {
|
||||
Db::rollback();
|
||||
return self::fail('游戏局标识已被使用');
|
||||
}
|
||||
|
||||
if (!$session) {
|
||||
$sessionId = Db::name('tcm_game_session')->insertGetId([
|
||||
'session_key' => $sessionKey,
|
||||
'user_id' => $userId,
|
||||
'week_start' => $weekStart,
|
||||
'learned_count' => 0,
|
||||
'last_score' => 0,
|
||||
'ended' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$session = [
|
||||
'id' => $sessionId,
|
||||
'week_start' => $weekStart,
|
||||
'learned_count' => 0,
|
||||
'last_score' => 0,
|
||||
'ended' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$sessionWeek = (string) $session['week_start'];
|
||||
if ($sessionWeek !== $weekStart) {
|
||||
$weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek);
|
||||
}
|
||||
|
||||
$confirmedLearned = (int) $session['learned_count'];
|
||||
$nextLearned = max($confirmedLearned, $learned);
|
||||
$delta = $nextLearned - $confirmedLearned;
|
||||
$wasEnded = (int) $session['ended'] === 1;
|
||||
$markEnded = $wasEnded || $ended === 1;
|
||||
|
||||
Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([
|
||||
'learned_count' => $nextLearned,
|
||||
'last_score' => max((int) $session['last_score'], $scoreValue),
|
||||
'ended' => $markEnded ? 1 : 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$weeklyLearned = (int) $weeklyScore['learned_count'] + $delta;
|
||||
$weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue);
|
||||
$gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0);
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([
|
||||
'learned_count' => $weeklyLearned,
|
||||
'best_score' => $weeklyBest,
|
||||
'games_played' => $gamesPlayed,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$inviteCode = self::ensureShareInvite($userId, $sessionWeek);
|
||||
Db::commit();
|
||||
|
||||
$result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek);
|
||||
$result['confirmed_session_learned'] = $nextLearned;
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('submitProgress', $e);
|
||||
return self::fail('成绩保存失败,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录用户发起一次微信分享,并返回当前分享码。 */
|
||||
public static function recordShare(int $userId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
try {
|
||||
Db::startTrans();
|
||||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||
Db::commit();
|
||||
return ['invite_code' => $inviteCode];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('recordShare', $e);
|
||||
return self::fail('分享记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。
|
||||
*/
|
||||
public static function acceptShare(int $userId, string $inviteCode): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) {
|
||||
return self::fail('分享码无效');
|
||||
}
|
||||
|
||||
try {
|
||||
$invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find();
|
||||
if (!$invite) {
|
||||
return self::fail('分享已失效');
|
||||
}
|
||||
$inviterUserId = (int) $invite['user_id'];
|
||||
if ($inviterUserId === $userId) {
|
||||
return ['accepted' => false, 'message' => '这是您自己的分享'];
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
$inserted = Db::name('tcm_game_share_visit')->duplicate([
|
||||
'invite_code',
|
||||
])->insert([
|
||||
'invite_code' => $inviteCode,
|
||||
'inviter_user_id' => $inviterUserId,
|
||||
'visitor_user_id' => $userId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
$accepted = $inserted === 1;
|
||||
if ($accepted) {
|
||||
Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
|
||||
$inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find();
|
||||
return [
|
||||
'accepted' => $accepted,
|
||||
'message' => '已加入控糖消消乐',
|
||||
'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('acceptShare', $e);
|
||||
return self::fail('分享关系记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureWeeklyScore(int $userId, string $weekStart): array
|
||||
{
|
||||
$profile = self::userProfile($userId);
|
||||
$now = time();
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
if ($existing) {
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('id', (int) $existing['id'])
|
||||
->lock(true)
|
||||
->find();
|
||||
$profileChanged = (string) $existing['nickname'] !== $profile['nickname']
|
||||
|| (string) $existing['avatar'] !== $profile['avatar'];
|
||||
if ($profileChanged) {
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
|
||||
'nickname' => $profile['nickname'],
|
||||
'avatar' => $profile['avatar'],
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$existing['nickname'] = $profile['nickname'];
|
||||
$existing['avatar'] = $profile['avatar'];
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// 每周、每个性别使用一行分配锁串行化首次入组。直接 upsert 锁行,
|
||||
// 避免空分组上的间隙锁导致首批并发请求互相等待或偶发死锁。
|
||||
Db::name('tcm_game_weekly_allocator')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'week_start' => $weekStart,
|
||||
'sex' => $profile['sex'],
|
||||
'create_time'=> $now,
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$allocator = Db::name('tcm_game_weekly_allocator')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$allocator) {
|
||||
throw new \RuntimeException('同行分配锁创建失败');
|
||||
}
|
||||
|
||||
// 等待分配锁期间,同一用户的另一个请求可能已经完成分配。
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$group = Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->where('member_count', '<', self::GROUP_SIZE)
|
||||
->order('group_no', 'asc')
|
||||
->lock(true)
|
||||
->find();
|
||||
|
||||
if (!$group) {
|
||||
$maxGroupNo = (int) Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->max('group_no');
|
||||
$groupNo = $maxGroupNo + 1;
|
||||
// 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert,
|
||||
// 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。
|
||||
Db::name('tcm_game_weekly_group')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'week_start' => $weekStart,
|
||||
'sex' => $profile['sex'],
|
||||
'group_no' => $groupNo,
|
||||
'member_count'=> 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$group = Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', $profile['sex'])
|
||||
->where('group_no', $groupNo)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$group) {
|
||||
throw new \RuntimeException('同行分组创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
$scoreRow = [
|
||||
'group_id' => (int) $group['id'],
|
||||
'week_start' => $weekStart,
|
||||
'user_id' => $userId,
|
||||
'learned_count' => 0,
|
||||
'best_score' => 0,
|
||||
'games_played' => 0,
|
||||
'share_count' => 0,
|
||||
'nickname' => $profile['nickname'],
|
||||
'avatar' => $profile['avatar'],
|
||||
'sex' => $profile['sex'],
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$inserted = Db::name('tcm_game_weekly_score')->duplicate([
|
||||
'nickname',
|
||||
'avatar',
|
||||
'sex',
|
||||
'update_time',
|
||||
])->insert($scoreRow);
|
||||
$score = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$score) {
|
||||
throw new \RuntimeException('同行成绩创建失败');
|
||||
}
|
||||
|
||||
// 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。
|
||||
if ($inserted === 1) {
|
||||
$memberCount = (int) Db::name('tcm_game_weekly_score')
|
||||
->where('group_id', (int) $group['id'])
|
||||
->count();
|
||||
Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([
|
||||
'member_count' => min(self::GROUP_SIZE, $memberCount),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
private static function buildLeaderboard(
|
||||
int $groupId,
|
||||
int $userId,
|
||||
string $inviteCode,
|
||||
?string $weekStart = null
|
||||
): array {
|
||||
$weekStart = $weekStart ?: self::weekStart();
|
||||
$rows = Db::name('tcm_game_weekly_score')
|
||||
->where('group_id', $groupId)
|
||||
->where('week_start', $weekStart)
|
||||
->order('learned_count', 'desc')
|
||||
->order('best_score', 'desc')
|
||||
->order('create_time', 'asc')
|
||||
->limit(self::GROUP_SIZE)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$players = [];
|
||||
$myIndex = 0;
|
||||
foreach ($rows as $index => $row) {
|
||||
$isMe = (int) $row['user_id'] === $userId;
|
||||
if ($isMe) {
|
||||
$myIndex = $index;
|
||||
}
|
||||
$players[] = [
|
||||
// 前端只需要稳定列表键,不暴露平台内部 user_id。
|
||||
'id' => (int) $row['id'],
|
||||
'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']),
|
||||
'avatar' => self::avatarUrl((string) $row['avatar']),
|
||||
'count' => (int) $row['learned_count'],
|
||||
'best_score' => (int) $row['best_score'],
|
||||
'rank' => $index + 1,
|
||||
'is_me' => $isMe,
|
||||
];
|
||||
}
|
||||
|
||||
$me = $players[$myIndex] ?? [
|
||||
'count' => 0,
|
||||
'rank' => 1,
|
||||
'best_score' => 0,
|
||||
];
|
||||
$distance = $myIndex > 0
|
||||
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
|
||||
: 0;
|
||||
$group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find();
|
||||
$sex = (int) ($group['sex'] ?? 0);
|
||||
|
||||
return [
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
|
||||
'sex' => $sex,
|
||||
'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'),
|
||||
'group_size' => self::GROUP_SIZE,
|
||||
'member_count'=> count($players),
|
||||
'players' => $players,
|
||||
'me' => [
|
||||
'count' => (int) ($me['count'] ?? 0),
|
||||
'rank' => (int) ($me['rank'] ?? 1),
|
||||
'best_score' => (int) ($me['best_score'] ?? 0),
|
||||
'distance' => $distance,
|
||||
'is_first' => $myIndex === 0,
|
||||
],
|
||||
'invite_code' => $inviteCode,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{nickname:string,avatar:string,sex:int} */
|
||||
private static function userProfile(int $userId): array
|
||||
{
|
||||
$user = Db::name('user')
|
||||
->where('id', $userId)
|
||||
->whereNull('delete_time')
|
||||
->field('id,sn,nickname,avatar,sex')
|
||||
->find();
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('用户不存在');
|
||||
}
|
||||
|
||||
$sex = (int) ($user['sex'] ?? 0);
|
||||
if ($sex !== 1 && $sex !== 2) {
|
||||
$gender = Db::name('diagnosis_view_records')->alias('v')
|
||||
->join('tcm_diagnosis d', 'd.id = v.diagnosis_id')
|
||||
->where('v.user_id', $userId)
|
||||
->whereNull('v.delete_time')
|
||||
->whereNull('d.delete_time')
|
||||
->order('v.id', 'desc')
|
||||
->value('d.gender');
|
||||
if ($gender !== null && $gender !== '') {
|
||||
$sex = (int) $gender === 1 ? 1 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId),
|
||||
'avatar' => (string) ($user['avatar'] ?? ''),
|
||||
'sex' => in_array($sex, [1, 2], true) ? $sex : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private static function displayName(string $nickname, int $userId): string
|
||||
{
|
||||
$nickname = trim(strip_tags($nickname));
|
||||
if ($nickname === '') {
|
||||
return '控糖好友' . substr((string) $userId, -2);
|
||||
}
|
||||
return mb_substr($nickname, 0, 12);
|
||||
}
|
||||
|
||||
private static function avatarUrl(string $avatar): string
|
||||
{
|
||||
return $avatar === '' ? '' : FileService::getFileUrl($avatar);
|
||||
}
|
||||
|
||||
private static function ensureShareInvite(int $userId, string $weekStart): string
|
||||
{
|
||||
$existing = Db::name('tcm_game_share_invite')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if ($existing) {
|
||||
return (string) $existing['invite_code'];
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$code = strtoupper(bin2hex(random_bytes(6)));
|
||||
$now = time();
|
||||
// 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合;
|
||||
// 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。
|
||||
Db::name('tcm_game_share_invite')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'invite_code' => $code,
|
||||
'user_id' => $userId,
|
||||
'week_start' => $weekStart,
|
||||
'open_count' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$invite = Db::name('tcm_game_share_invite')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if ($invite) {
|
||||
return (string) $invite['invite_code'];
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('分享码生成失败');
|
||||
}
|
||||
|
||||
private static function weekStart(?int $timestamp = null): string
|
||||
{
|
||||
$timestamp = $timestamp ?: time();
|
||||
$day = (int) date('N', $timestamp);
|
||||
return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp));
|
||||
}
|
||||
|
||||
private static function logException(string $action, \Throwable $e): void
|
||||
{
|
||||
Log::error(sprintf(
|
||||
'tcm endless game %s failed: %s at %s:%d',
|
||||
$action,
|
||||
$e->getMessage(),
|
||||
$e->getFile(),
|
||||
$e->getLine()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ use think\facade\Route;
|
||||
// @see https://doc.thinkphp.cn/v8_0/multi_app_model.html
|
||||
|
||||
// 企业微信「客户联系」事件回调:GET 验签(echostr)、POST 收事件
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallbackController@notify', 'GET|POST');
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
|
||||
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
|
||||
|
||||
@@ -39,6 +39,7 @@ use think\facade\Log;
|
||||
*
|
||||
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
|
||||
* 成功分配同时写 tcm_diagnosis_assign_log(is_inherit=0,计入次月接诊率分母),与手动指派同口径。
|
||||
* 写入时 from_assistant_id 尽量带上「一中心」原医助(指派日志中最近的一中心身份,否则回退业务单创建人若属一中心),避免原医助恒为空。
|
||||
*/
|
||||
class AutoAssignPendingDiagnosis extends Command
|
||||
{
|
||||
@@ -304,6 +305,7 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
->where('run_date', $runDate)
|
||||
->where('action', 1)
|
||||
->where('assistant_id', '>', 0)
|
||||
->where('rollback_time', 0)
|
||||
->group('assistant_id')
|
||||
->column('COUNT(*)', 'assistant_id');
|
||||
|
||||
@@ -499,9 +501,12 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
$relatedPoCreatorId = 0;
|
||||
}
|
||||
|
||||
// 自动分给二中心时,原医助带上一中心身份(列表「原医助」不再恒为空)
|
||||
$fromAssistantId = $this->resolveYiCenterPreviousAssistantId($diagnosisId, $relatedPoCreatorId);
|
||||
|
||||
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'from_assistant_id' => 0,
|
||||
'from_assistant_id' => $fromAssistantId,
|
||||
'to_assistant_id' => $toAssistantId,
|
||||
'operator_admin_id' => 0,
|
||||
'operator_name' => '系统自动分配',
|
||||
@@ -524,6 +529,88 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析诊单对应的「一中心」原医助 id,供自动指派写入 from_assistant_id。
|
||||
* 优先:指派日志由新到旧,取最近出现的一中心身份(先 to 后 from);
|
||||
* 回退:最新业务单创建人若属一中心则用之。跳过二中心身份,避免原医助写成上一任二中心。
|
||||
*/
|
||||
private function resolveYiCenterPreviousAssistantId(int $diagnosisId, int $relatedPoCreatorId): int
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$yiDeptSet = DeptLogic::getYiCenterSubtreeDeptIdSet();
|
||||
if ($yiDeptSet === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$logs = Db::name('tcm_diagnosis_assign_log')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->order('id', 'desc')
|
||||
->field(['from_assistant_id', 'to_assistant_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$candidateIds = [];
|
||||
foreach ($logs as $log) {
|
||||
$to = (int) ($log['to_assistant_id'] ?? 0);
|
||||
$from = (int) ($log['from_assistant_id'] ?? 0);
|
||||
if ($to > 0) {
|
||||
$candidateIds[] = $to;
|
||||
}
|
||||
if ($from > 0) {
|
||||
$candidateIds[] = $from;
|
||||
}
|
||||
}
|
||||
if ($relatedPoCreatorId > 0) {
|
||||
$candidateIds[] = $relatedPoCreatorId;
|
||||
}
|
||||
$candidateIds = array_values(array_unique(array_filter($candidateIds)));
|
||||
if ($candidateIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$yiAdminSet = $this->filterAdminIdsInDeptSet($candidateIds, $yiDeptSet);
|
||||
if ($yiAdminSet === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$to = (int) ($log['to_assistant_id'] ?? 0);
|
||||
if ($to > 0 && isset($yiAdminSet[$to])) {
|
||||
return $to;
|
||||
}
|
||||
$from = (int) ($log['from_assistant_id'] ?? 0);
|
||||
if ($from > 0 && isset($yiAdminSet[$from])) {
|
||||
return $from;
|
||||
}
|
||||
}
|
||||
if ($relatedPoCreatorId > 0 && isset($yiAdminSet[$relatedPoCreatorId])) {
|
||||
return $relatedPoCreatorId;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $adminIds
|
||||
* @param array<int, true> $deptIdSet
|
||||
*
|
||||
* @return array<int, true> admin_id => true
|
||||
*/
|
||||
private function filterAdminIdsInDeptSet(array $adminIds, array $deptIdSet): array
|
||||
{
|
||||
if ($adminIds === [] || $deptIdSet === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('admin_dept')
|
||||
->whereIn('admin_id', $adminIds)
|
||||
->whereIn('dept_id', array_keys($deptIdSet))
|
||||
->column('admin_id');
|
||||
|
||||
return array_fill_keys(array_map('intval', $rows), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineBootstrapService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class EjPharmacyBootstrapMedicines extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('ej-pharmacy:bootstrap-medicines')
|
||||
->setDescription('一次性导入并原子替换恩济药房药材目录投影')
|
||||
->addOption('replace', null, Option::VALUE_NONE, '确认替换本地 EJ 药材投影')
|
||||
->addOption('confirm', null, Option::VALUE_OPTIONAL, '破坏性操作确认令牌:RESET_TEST_CATALOG', '')
|
||||
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '远端导入批次大小(1-500)', 100);
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
try {
|
||||
$batchSize = (int) $input->getOption('batch-size');
|
||||
EjMedicineBootstrapService::assertCommandGate(
|
||||
(bool) $input->getOption('replace'),
|
||||
(string) $input->getOption('confirm'),
|
||||
$batchSize
|
||||
);
|
||||
$result = EjMedicineBootstrapService::execute($batchSize);
|
||||
$output->writeln(sprintf(
|
||||
'bootstrap 完成 source=%d batches=%d catalog=%d active_mappings=%d unmapped=%d',
|
||||
$result['source_count'],
|
||||
$result['batch_count'],
|
||||
$result['catalog'],
|
||||
$result['active_mappings'],
|
||||
$result['unmapped']
|
||||
));
|
||||
return 0;
|
||||
} catch (\Throwable $exception) {
|
||||
$output->error($exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class EjPharmacySyncCatalog extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('ej-pharmacy:sync-catalog')
|
||||
->setDescription('增量同步洛阳药房 ERP 药材目录')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '每页数量', 200);
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
try {
|
||||
$stats = EjMedicineCatalogSyncService::sync(max((int) $input->getOption('limit'), 1));
|
||||
$output->writeln(sprintf(
|
||||
'同步完成 pages=%d pulled=%d created=%d updated=%d deactivated=%d cursor=%d',
|
||||
$stats['pages'],
|
||||
$stats['pulled'],
|
||||
$stats['created'],
|
||||
$stats['updated'],
|
||||
$stats['deactivated'],
|
||||
$stats['cursor']
|
||||
));
|
||||
return 0;
|
||||
} catch (\Throwable $exception) {
|
||||
$output->error($exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use think\console\Output;
|
||||
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货」核对释放诊单医助(跳过已完成/已签收业务单,与是否甘草单无关)
|
||||
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
||||
*
|
||||
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 已对「待释放医助非二中心」执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||
*/
|
||||
class ExpressAutoUpdate extends Command
|
||||
|
||||
@@ -34,7 +34,7 @@ use think\console\Output;
|
||||
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics >> runtime/log/gancao_sync.log 2>&1
|
||||
*
|
||||
* 跳过履约状态为已完成(3)、已签收(6) 的业务订单,不再拉取甘草路由。
|
||||
* 已对「业务订单创建人非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 已对「待释放医助非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||
*/
|
||||
class GancaoSyncLogisticsRoute extends Command
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class EjMedicineCatalog extends BaseModel
|
||||
{
|
||||
protected $name = 'ej_medicine_catalog';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
class EjMedicineMapping extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
protected $name = 'ej_medicine_mapping';
|
||||
protected $deleteTime = 'delete_time';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class EjPharmacyCallbackInbox extends BaseModel
|
||||
{
|
||||
protected $name = 'ej_pharmacy_callback_inbox';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class EjPharmacySubmission extends BaseModel
|
||||
{
|
||||
protected $name = 'ej_pharmacy_submission';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class PharmacySubmissionClaim extends BaseModel
|
||||
{
|
||||
protected $name = 'pharmacy_submission_claim';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -588,7 +588,7 @@ class ExpressTrackingService
|
||||
/**
|
||||
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)。
|
||||
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。
|
||||
* 不自动清空的情形:(1)按业务订单 creator_id(代建场景)或回退身份判断是否在「二中心」部门子树;(2)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(3)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`(系统任务为 operator_admin_id=0);或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。
|
||||
* 不自动清空的情形:(1)待释放医助(诊单 assistant_id,或代建回退身份)归属「二中心」部门子树;(2)诊单当前已指派、或指派日志曾 to 给「二中心」医助(含系统自动分配);(3)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(4)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`;或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。
|
||||
*
|
||||
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
|
||||
* @return array{
|
||||
@@ -663,19 +663,18 @@ class ExpressTrackingService
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 是否因「二中心」跳过释放:优先看业务订单创建人 creator_id(代建场景);创建人即开方医生时用 fromAssistantId */
|
||||
$erZhongxinCheckAdminId = ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator))
|
||||
? $poCreatorId
|
||||
: $fromAssistantId;
|
||||
|
||||
if ($erZhongxinCheckAdminId > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($erZhongxinCheckAdminId)) {
|
||||
Log::info('Skipped auto assistant release (shipped/signed): order creator (or fallback from-id) under 二中心 dept tree', [
|
||||
// 二中心不释放:(a)待释放医助属二中心;(b)诊单当前医助属二中心;(c)指派日志曾指派给二中心医助
|
||||
$skipErzhongxin = self::currentAssistantBelongsToErzhongxinDeptTree($fromAssistantId)
|
||||
|| ($diagAssistantIdNow > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($diagAssistantIdNow))
|
||||
|| self::diagnosisHasBeenAssignedToErzhongxinAssistant($diagnosisId);
|
||||
if ($skipErzhongxin) {
|
||||
Log::info('Skipped auto assistant release (shipped/signed): 二中心 assistant assigned or targeted', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'prescription_order_id' => $orderId,
|
||||
'source' => $source,
|
||||
'er_zhongxin_check_admin_id' => $erZhongxinCheckAdminId,
|
||||
'prescription_order_creator_id' => $poCreatorId,
|
||||
'from_assistant_id_for_log' => $fromAssistantId,
|
||||
'from_assistant_id' => $fromAssistantId,
|
||||
'diagnosis_assistant_id' => $diagAssistantIdNow,
|
||||
'prescription_order_creator_id' => $poCreatorId,
|
||||
]);
|
||||
|
||||
return null;
|
||||
@@ -685,6 +684,7 @@ class ExpressTrackingService
|
||||
'express_auto_update' => '系统·物流自动同步',
|
||||
'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步',
|
||||
'gancao_callback' => '系统·甘草回调',
|
||||
'ej_pharmacy_callback' => '系统·洛阳药房回调',
|
||||
'shipped_fulfillment_reconcile' => '系统·已发货履约核对',
|
||||
default => '系统·订单已发货/签收',
|
||||
};
|
||||
@@ -798,6 +798,36 @@ class ExpressTrackingService
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单指派日志是否曾指派给「二中心」医助(to_assistant_id>0,含人工指派与系统自动分配)。
|
||||
*/
|
||||
private static function diagnosisHasBeenAssignedToErzhongxinAssistant(int $diagnosisId): bool
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$toIds = Db::name('tcm_diagnosis_assign_log')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('to_assistant_id', '>', 0)
|
||||
->column('to_assistant_id');
|
||||
if ($toIds === [] || $toIds === null) {
|
||||
return false;
|
||||
}
|
||||
$seen = [];
|
||||
foreach ($toIds as $aid) {
|
||||
$aid = (int) $aid;
|
||||
if ($aid <= 0 || isset($seen[$aid])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$aid] = true;
|
||||
if (self::currentAssistantBelongsToErzhongxinDeptTree($aid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 若诊单曾被后台人工指派到具体医助,则不再执行发运/签收自动清空(避免「系统·已发货履约核对」覆盖人工链)。
|
||||
* 判定:operator_admin_id>0 且 to_assistant_id>0(人工);系统写入的释放/同步为 operator_admin_id=0。
|
||||
@@ -1031,7 +1061,7 @@ class ExpressTrackingService
|
||||
|
||||
/**
|
||||
* 按处方订单履约核对:仅已发货(5) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder(已完成/已签收不再扫描)。
|
||||
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
|
||||
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看待释放医助**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
|
||||
*
|
||||
* @return array{scanned: int, cleared: int, lines: list<string>}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjMedicineBootstrapItem
|
||||
{
|
||||
/** @param array<string,mixed> $row @return array<string,mixed> */
|
||||
public static function fromRow(array $row): array
|
||||
{
|
||||
$sourceId = self::sourceId($row['id'] ?? null);
|
||||
$name = self::text($row['name'] ?? null, '药材名称', 120);
|
||||
$unit = self::text($row['unit'] ?? null, '药材单位', 24);
|
||||
$status = $row['status'] ?? null;
|
||||
if (!in_array($status, [0, 1, '0', '1'], true)) {
|
||||
throw new InvalidArgumentException("本地药材 {$sourceId} 状态必须为 0 或 1");
|
||||
}
|
||||
|
||||
return [
|
||||
'source_medicine_id' => $sourceId,
|
||||
'name' => $name,
|
||||
'brand' => '',
|
||||
'unit' => $unit,
|
||||
'settlement_price' => self::roundPrice($row['settlement_price'] ?? null),
|
||||
'retail_price' => self::roundPrice($row['retail_price'] ?? null),
|
||||
'status' => (int) $status,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||||
public static function fromRows(array $rows): array
|
||||
{
|
||||
$items = array_map([self::class, 'fromRow'], $rows);
|
||||
usort($items, static fn (array $left, array $right): int => self::compareIds(
|
||||
(string) $left['source_medicine_id'],
|
||||
(string) $right['source_medicine_id']
|
||||
));
|
||||
|
||||
$previousId = null;
|
||||
foreach ($items as $item) {
|
||||
$sourceId = (string) $item['source_medicine_id'];
|
||||
if ($previousId !== null && hash_equals($previousId, $sourceId)) {
|
||||
throw new InvalidArgumentException("本地药材 source_medicine_id 重复:{$sourceId}");
|
||||
}
|
||||
$previousId = $sourceId;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function roundPrice(mixed $value): string
|
||||
{
|
||||
if (!is_string($value) || preg_match('/^(0|[1-9]\d*)\.(\d{1,6})$/D', $value, $matches) !== 1) {
|
||||
throw new InvalidArgumentException('药材价格必须是最多六位小数的非负十进制字符串');
|
||||
}
|
||||
$whole = ltrim($matches[1], '0');
|
||||
$whole = $whole === '' ? '0' : $whole;
|
||||
$fraction = str_pad($matches[2], 6, '0');
|
||||
$fourDecimals = substr($fraction, 0, 4);
|
||||
if ((int) $fraction[4] < 5) {
|
||||
return $whole . '.' . $fourDecimals;
|
||||
}
|
||||
|
||||
$digits = self::addOne($whole . $fourDecimals);
|
||||
if (strlen($digits) < 5) {
|
||||
$digits = str_pad($digits, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
return substr($digits, 0, -4) . '.' . substr($digits, -4);
|
||||
}
|
||||
|
||||
public static function compareIds(string $left, string $right): int
|
||||
{
|
||||
return strlen($left) <=> strlen($right) ?: strcmp($left, $right);
|
||||
}
|
||||
|
||||
private static function sourceId(mixed $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
throw new InvalidArgumentException('本地药材 id 必须是正整数');
|
||||
}
|
||||
$value = ltrim($value, '0');
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('本地药材 id 必须是正整数');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function text(mixed $value, string $field, int $maxLength): string
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException("{$field}必须是字符串");
|
||||
}
|
||||
$trimmed = preg_replace('/\A[\s\p{Z}\p{Cf}]+|[\s\p{Z}\p{Cf}]+\z/u', '', $value);
|
||||
if (!is_string($trimmed) || $trimmed === '' || mb_strlen($trimmed) > $maxLength) {
|
||||
throw new InvalidArgumentException("{$field}不能为空且不能超过 {$maxLength} 个字符");
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
private static function addOne(string $digits): string
|
||||
{
|
||||
$characters = str_split($digits);
|
||||
for ($index = count($characters) - 1; $index >= 0; --$index) {
|
||||
if ($characters[$index] !== '9') {
|
||||
$characters[$index] = (string) ((int) $characters[$index] + 1);
|
||||
return implode('', $characters);
|
||||
}
|
||||
$characters[$index] = '0';
|
||||
}
|
||||
return '1' . implode('', $characters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class EjMedicineBootstrapService
|
||||
{
|
||||
private const SOURCE_SYSTEM = 'zyt';
|
||||
private const BOOTSTRAP_RUN_ID = 'zyt-medicine-bootstrap-v1';
|
||||
private const EXPECTED_MEDICINE_COUNT = 654;
|
||||
|
||||
public static function assertCommandGate(bool $replace, string $confirm, int $batchSize): void
|
||||
{
|
||||
if (!$replace) {
|
||||
throw new InvalidArgumentException('必须显式提供 --replace 才能替换恩济药材投影');
|
||||
}
|
||||
if (!hash_equals('RESET_TEST_CATALOG', $confirm)) {
|
||||
throw new InvalidArgumentException('必须提供 --confirm=RESET_TEST_CATALOG');
|
||||
}
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items @return list<array<string,mixed>> */
|
||||
public static function buildBatches(array $items, int $batchSize): array
|
||||
{
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||||
}
|
||||
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
(string) ($left['source_medicine_id'] ?? ''),
|
||||
(string) ($right['source_medicine_id'] ?? '')
|
||||
));
|
||||
|
||||
$batches = [];
|
||||
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
|
||||
$ordinal = $index + 1;
|
||||
$contentJson = json_encode(
|
||||
$batchItems,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
$contentIdentity = substr(hash('sha256', $contentJson), 0, 32);
|
||||
$batches[] = [
|
||||
'source_system' => self::SOURCE_SYSTEM,
|
||||
'import_id' => sprintf('%s-%04d-%s', self::BOOTSTRAP_RUN_ID, $ordinal, $contentIdentity),
|
||||
'items' => $batchItems,
|
||||
];
|
||||
}
|
||||
return $batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,bool> $seenCodes
|
||||
* @param array<int,bool> $seenVersions
|
||||
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
|
||||
*/
|
||||
public static function validateImportResponse(
|
||||
array $response,
|
||||
array $payload,
|
||||
array &$seenCodes,
|
||||
array &$seenVersions
|
||||
): array {
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
$body = $response['body'] ?? null;
|
||||
if (!in_array($httpStatus, [200, 201], true) || !is_array($body) || (int) ($body['code'] ?? -1) !== 0) {
|
||||
$message = is_array($body) ? trim((string) ($body['message'] ?? '')) : '';
|
||||
throw new RuntimeException(sprintf(
|
||||
'恩济药材导入失败 HTTP %d%s',
|
||||
$httpStatus,
|
||||
$message === '' ? '' : ':' . $message
|
||||
));
|
||||
}
|
||||
$data = $body['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('恩济药材导入响应缺少 data');
|
||||
}
|
||||
$expectedImportId = (string) ($payload['import_id'] ?? '');
|
||||
if (!hash_equals($expectedImportId, (string) ($data['import_id'] ?? ''))) {
|
||||
throw new RuntimeException('恩济药材导入响应 import_id 不匹配');
|
||||
}
|
||||
|
||||
$expectedItems = $payload['items'] ?? null;
|
||||
$responseItems = $data['items'] ?? null;
|
||||
if (!is_array($expectedItems) || !is_array($responseItems)) {
|
||||
throw new RuntimeException('恩济药材导入响应 items 无效');
|
||||
}
|
||||
$itemCount = count($expectedItems);
|
||||
$createdCount = (int) ($data['created_count'] ?? -1);
|
||||
$existingCount = (int) ($data['existing_count'] ?? -1);
|
||||
if (
|
||||
(int) ($data['item_count'] ?? -1) !== $itemCount
|
||||
|| count($responseItems) !== $itemCount
|
||||
|| $createdCount < 0
|
||||
|| $existingCount < 0
|
||||
|| $createdCount + $existingCount !== $itemCount
|
||||
|| !is_bool($data['idempotent'] ?? null)
|
||||
) {
|
||||
throw new RuntimeException('恩济药材导入响应计数或幂等标记不完整');
|
||||
}
|
||||
|
||||
$expectedSourceIds = array_map(
|
||||
static fn (array $item): string => (string) ($item['source_medicine_id'] ?? ''),
|
||||
$expectedItems
|
||||
);
|
||||
$nextSeenCodes = $seenCodes;
|
||||
$nextSeenVersions = $seenVersions;
|
||||
$normalized = [];
|
||||
$responseSourceIds = [];
|
||||
$actions = ['created' => 0, 'existing' => 0];
|
||||
foreach ($responseItems as $item) {
|
||||
if (!is_array($item)) {
|
||||
throw new RuntimeException('恩济药材导入响应 item 必须是对象');
|
||||
}
|
||||
$sourceId = self::canonicalSourceId($item['source_medicine_id'] ?? null);
|
||||
if (isset($responseSourceIds[$sourceId])) {
|
||||
throw new RuntimeException("恩济药材导入响应 source_medicine_id 重复:{$sourceId}");
|
||||
}
|
||||
$responseSourceIds[$sourceId] = true;
|
||||
$code = trim((string) ($item['medicine_code'] ?? ''));
|
||||
if ($code === '' || mb_strlen($code) > 32 || isset($nextSeenCodes[$code])) {
|
||||
throw new RuntimeException("恩济药材导入响应 medicine_code 为空、过长或重复:{$code}");
|
||||
}
|
||||
$version = filter_var($item['catalog_version'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($version === false || $version < 1 || isset($nextSeenVersions[$version])) {
|
||||
throw new RuntimeException('恩济药材导入响应 catalog_version 缺失或重复');
|
||||
}
|
||||
$action = (string) ($item['action'] ?? '');
|
||||
if (!array_key_exists($action, $actions)) {
|
||||
throw new RuntimeException('恩济药材导入响应 action 无效');
|
||||
}
|
||||
++$actions[$action];
|
||||
$nextSeenCodes[$code] = true;
|
||||
$nextSeenVersions[$version] = true;
|
||||
$normalized[] = [
|
||||
'source_medicine_id' => $sourceId,
|
||||
'medicine_code' => $code,
|
||||
'catalog_version' => $version,
|
||||
'action' => $action,
|
||||
];
|
||||
}
|
||||
usort($normalized, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
$left['source_medicine_id'],
|
||||
$right['source_medicine_id']
|
||||
));
|
||||
sort($expectedSourceIds, SORT_NATURAL);
|
||||
$actualSourceIds = array_column($normalized, 'source_medicine_id');
|
||||
sort($actualSourceIds, SORT_NATURAL);
|
||||
if ($expectedSourceIds !== $actualSourceIds) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 不完整或不匹配');
|
||||
}
|
||||
if ($actions['created'] !== $createdCount || $actions['existing'] !== $existingCount) {
|
||||
throw new RuntimeException('恩济药材导入响应 action 与计数不一致');
|
||||
}
|
||||
if ($data['idempotent'] === true && ($createdCount !== 0 || $existingCount !== $itemCount)) {
|
||||
throw new RuntimeException('恩济药材导入响应幂等标记与 action 不一致');
|
||||
}
|
||||
|
||||
$seenCodes = $nextSeenCodes;
|
||||
$seenVersions = $nextSeenVersions;
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionReplacer
|
||||
* @return array{source_count:int,batch_count:int,catalog:int,active_mappings:int,unmapped:int}
|
||||
*/
|
||||
public static function execute(
|
||||
int $batchSize = 100,
|
||||
?callable $sourceLoader = null,
|
||||
?callable $importer = null,
|
||||
?callable $projectionReplacer = null
|
||||
): array {
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||||
}
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
if (count($items) !== self::EXPECTED_MEDICINE_COUNT) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'药材 bootstrap 要求恰好 %d 条启用且未删除的本地药材,当前为 %d 条',
|
||||
self::EXPECTED_MEDICINE_COUNT,
|
||||
count($items)
|
||||
));
|
||||
}
|
||||
$batches = self::buildBatches($items, $batchSize);
|
||||
if ($importer === null) {
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('恩济药房接口未启用或配置不完整');
|
||||
}
|
||||
$client = new EjPharmacyClient();
|
||||
$importer = static fn (array $payload): array => $client->importMedicines($payload);
|
||||
}
|
||||
|
||||
$sourceById = [];
|
||||
foreach ($items as $item) {
|
||||
$sourceById[(string) $item['source_medicine_id']] = $item;
|
||||
}
|
||||
$seenCodes = [];
|
||||
$seenVersions = [];
|
||||
$projectionRows = [];
|
||||
foreach ($batches as $payload) {
|
||||
$responseItems = self::validateImportResponse(
|
||||
$importer($payload),
|
||||
$payload,
|
||||
$seenCodes,
|
||||
$seenVersions
|
||||
);
|
||||
foreach ($responseItems as $responseItem) {
|
||||
$sourceId = $responseItem['source_medicine_id'];
|
||||
$source = $sourceById[$sourceId];
|
||||
$projectionRows[] = [
|
||||
'local_medicine_id' => (int) $sourceId,
|
||||
'medicine_code' => $responseItem['medicine_code'],
|
||||
'name' => $source['name'],
|
||||
'brand' => '',
|
||||
'unit' => $source['unit'],
|
||||
'settlement_price' => $source['settlement_price'],
|
||||
'retail_price' => $source['retail_price'],
|
||||
'status' => $source['status'],
|
||||
'catalog_version' => $responseItem['catalog_version'],
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($projectionRows, static fn (array $left, array $right): int => $left['local_medicine_id'] <=> $right['local_medicine_id']);
|
||||
$verification = $projectionReplacer === null
|
||||
? self::replaceProjection($projectionRows)
|
||||
: $projectionReplacer($projectionRows);
|
||||
self::assertProjectionVerification($verification, self::EXPECTED_MEDICINE_COUNT);
|
||||
|
||||
return [
|
||||
'source_count' => count($items),
|
||||
'batch_count' => count($batches),
|
||||
'catalog' => (int) $verification['catalog'],
|
||||
'active_mappings' => (int) $verification['active_mappings'],
|
||||
'unmapped' => (int) $verification['unmapped'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $projectionRows
|
||||
* @param callable(callable():array<string,int>):array<string,int> $transaction
|
||||
* @param callable():void $referenceLocker
|
||||
* @param callable():array<string,int> $referenceCounter
|
||||
* @param callable():void $projectionLocker
|
||||
* @param callable(array<int,array<string,mixed>>):void $replacer
|
||||
* @param callable():array<string,int> $verifier
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public static function replaceProjectionWith(
|
||||
array $projectionRows,
|
||||
callable $transaction,
|
||||
callable $referenceLocker,
|
||||
callable $referenceCounter,
|
||||
callable $projectionLocker,
|
||||
callable $replacer,
|
||||
callable $verifier
|
||||
): array {
|
||||
return $transaction(static function () use (
|
||||
$projectionRows,
|
||||
$referenceLocker,
|
||||
$referenceCounter,
|
||||
$projectionLocker,
|
||||
$replacer,
|
||||
$verifier
|
||||
): array {
|
||||
$referenceLocker();
|
||||
$references = $referenceCounter();
|
||||
foreach (['submissions', 'callbacks', 'business_links'] as $key) {
|
||||
if ((int) ($references[$key] ?? -1) !== 0) {
|
||||
throw new RuntimeException('恩济药材投影已有提交、回调或业务引用,禁止 bootstrap 替换');
|
||||
}
|
||||
}
|
||||
$projectionLocker();
|
||||
$replacer($projectionRows);
|
||||
$verification = $verifier();
|
||||
self::assertProjectionVerification($verification, count($projectionRows));
|
||||
return $verification;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function loadSourceRows(): array
|
||||
{
|
||||
return Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $projectionRows @return array<string,int> */
|
||||
private static function replaceProjection(array $projectionRows): array
|
||||
{
|
||||
return self::replaceProjectionWith(
|
||||
$projectionRows,
|
||||
static fn (callable $operation): array => Db::transaction($operation),
|
||||
static function (): void {
|
||||
Db::name('ej_pharmacy_submission')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('ej_pharmacy_callback_inbox')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('pharmacy_submission_claim')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('tcm_prescription_order')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
},
|
||||
static function (): array {
|
||||
$directClaims = (int) Db::name('pharmacy_submission_claim')
|
||||
->where('target', 'direct')
|
||||
->count();
|
||||
$linkedOrders = (int) Db::name('tcm_prescription_order')
|
||||
->where(function ($query): void {
|
||||
$query->whereNotNull('ej_pharmacy_order_no')
|
||||
->whereOr('ej_pharmacy_submit_time', '>', 0)
|
||||
->whereOr('ej_pharmacy_status', '<>', '')
|
||||
->whereOr('ej_pharmacy_status_version', '>', 0);
|
||||
})
|
||||
->count();
|
||||
return [
|
||||
'submissions' => (int) Db::name('ej_pharmacy_submission')->count(),
|
||||
'callbacks' => (int) Db::name('ej_pharmacy_callback_inbox')->count(),
|
||||
'business_links' => $directClaims + $linkedOrders,
|
||||
];
|
||||
},
|
||||
static function () use ($projectionRows): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', 1)->lock(true)->find();
|
||||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('doctor_medicine')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
|
||||
$lockedRows = Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$lockedItems = EjMedicineBootstrapItem::fromRows($lockedRows);
|
||||
$expectedItems = array_map(static fn (array $row): array => [
|
||||
'source_medicine_id' => (string) $row['local_medicine_id'],
|
||||
'name' => (string) $row['name'],
|
||||
'brand' => '',
|
||||
'unit' => (string) $row['unit'],
|
||||
'settlement_price' => (string) $row['settlement_price'],
|
||||
'retail_price' => (string) $row['retail_price'],
|
||||
'status' => (int) $row['status'],
|
||||
], $projectionRows);
|
||||
if ($lockedItems !== $expectedItems) {
|
||||
throw new RuntimeException('本地药材源快照在远端导入期间发生变化,已拒绝替换投影');
|
||||
}
|
||||
},
|
||||
static function (array $rows): void {
|
||||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->delete();
|
||||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->delete();
|
||||
$now = time();
|
||||
$catalogRows = [];
|
||||
$mappingRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$catalogRows[] = [
|
||||
'medicine_code' => $row['medicine_code'],
|
||||
'name' => $row['name'],
|
||||
'brand' => '',
|
||||
'unit' => $row['unit'],
|
||||
'settlement_price' => $row['settlement_price'],
|
||||
'retail_price' => $row['retail_price'],
|
||||
'status' => $row['status'],
|
||||
'catalog_version' => $row['catalog_version'],
|
||||
'remote_deleted' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$mappingRows[] = [
|
||||
'local_medicine_id' => $row['local_medicine_id'],
|
||||
'medicine_code' => $row['medicine_code'],
|
||||
'status' => 1,
|
||||
'operator_id' => 0,
|
||||
'operator_name' => 'system-bootstrap',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
}
|
||||
foreach (array_chunk($catalogRows, 500) as $chunk) {
|
||||
Db::name('ej_medicine_catalog')->insertAll($chunk);
|
||||
}
|
||||
foreach (array_chunk($mappingRows, 500) as $chunk) {
|
||||
Db::name('ej_medicine_mapping')->insertAll($chunk);
|
||||
}
|
||||
|
||||
$stateValues = [
|
||||
'cursor' => 0,
|
||||
'last_success_time' => $now,
|
||||
'last_failure_time' => 0,
|
||||
'last_error_summary' => '',
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$updated = Db::name('ej_pharmacy_sync_state')->where('id', 1)->update($stateValues);
|
||||
if ($updated === 0 && !Db::name('ej_pharmacy_sync_state')->where('id', 1)->find()) {
|
||||
Db::name('ej_pharmacy_sync_state')->insert($stateValues + ['id' => 1, 'create_time' => $now]);
|
||||
}
|
||||
},
|
||||
static function () use ($projectionRows): array {
|
||||
$expectedLocalIds = array_map(
|
||||
static fn (array $row): int => (int) $row['local_medicine_id'],
|
||||
$projectionRows
|
||||
);
|
||||
$actualLocalIds = array_map(
|
||||
'intval',
|
||||
Db::name('ej_medicine_mapping')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('local_medicine_id', 'asc')
|
||||
->column('local_medicine_id')
|
||||
);
|
||||
if ($expectedLocalIds !== $actualLocalIds) {
|
||||
throw new RuntimeException('恩济药材 bootstrap 映射未精确覆盖全部本地药材 id');
|
||||
}
|
||||
return [
|
||||
'catalog' => (int) Db::name('ej_medicine_catalog')->count(),
|
||||
'active_mappings' => count($actualLocalIds),
|
||||
'unmapped' => (int) Db::name('doctor_medicine')->alias('l')
|
||||
->leftJoin(
|
||||
'ej_medicine_mapping m',
|
||||
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
|
||||
)
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereNull('m.id')
|
||||
->count('l.id'),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $verification */
|
||||
private static function assertProjectionVerification(array $verification, int $expected): void
|
||||
{
|
||||
if (
|
||||
(int) ($verification['catalog'] ?? -1) !== $expected
|
||||
|| (int) ($verification['active_mappings'] ?? -1) !== $expected
|
||||
|| (int) ($verification['unmapped'] ?? -1) !== 0
|
||||
) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'恩济药材 bootstrap 最终验证失败:catalog=%d active_mappings=%d unmapped=%d expected=%d',
|
||||
(int) ($verification['catalog'] ?? -1),
|
||||
(int) ($verification['active_mappings'] ?? -1),
|
||||
(int) ($verification['unmapped'] ?? -1),
|
||||
$expected
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static function canonicalSourceId(mixed $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||||
}
|
||||
$value = ltrim($value, '0');
|
||||
if ($value === '') {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class EjMedicineCatalogSyncPolicy
|
||||
{
|
||||
/** @return array{items:array<int,array<string,mixed>>,next_cursor:int,has_more:bool} */
|
||||
public static function parsePage(array $response, int $cursor): array
|
||||
{
|
||||
$body = is_array($response['body'] ?? null) ? $response['body'] : [];
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
if ($httpStatus < 200 || $httpStatus >= 300 || (int) ($body['code'] ?? -1) !== 0) {
|
||||
$message = trim((string) ($body['message'] ?? ''));
|
||||
throw new RuntimeException($message !== '' ? $message : '洛阳药房药材目录同步失败');
|
||||
}
|
||||
|
||||
$data = is_array($body['data'] ?? null) ? $body['data'] : [];
|
||||
$items = is_array($data['items'] ?? null) ? array_values(array_filter(
|
||||
$data['items'],
|
||||
static fn ($item): bool => is_array($item)
|
||||
)) : [];
|
||||
$nextCursor = max(0, (int) ($data['next_cursor'] ?? $cursor));
|
||||
$hasMore = !empty($data['has_more']);
|
||||
if ($hasMore && $nextCursor <= $cursor) {
|
||||
throw new RuntimeException('洛阳药房药材目录游标未推进,已停止同步');
|
||||
}
|
||||
|
||||
return ['items' => $items, 'next_cursor' => $nextCursor, 'has_more' => $hasMore];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $existing
|
||||
* @param array<string,mixed> $remote
|
||||
* @return array{action:string,values:array<string,mixed>,deactivated:int}
|
||||
*/
|
||||
public static function merge(?array $existing, array $remote): array
|
||||
{
|
||||
$code = trim((string) ($remote['medicine_code'] ?? ''));
|
||||
if ($code === '') {
|
||||
throw new RuntimeException('洛阳药房药材目录包含空 medicine_code');
|
||||
}
|
||||
|
||||
$deleted = !empty($remote['deleted']) || !empty($remote['remote_deleted']);
|
||||
$values = [
|
||||
'medicine_code' => $code,
|
||||
'name' => trim((string) ($remote['name'] ?? '')),
|
||||
'brand' => trim((string) ($remote['brand'] ?? '')),
|
||||
'unit' => trim((string) ($remote['unit'] ?? '')),
|
||||
'settlement_price' => self::decimal($remote['settlement_price'] ?? 0),
|
||||
'retail_price' => self::decimal($remote['retail_price'] ?? 0),
|
||||
'status' => $deleted ? 0 : (int) ($remote['status'] ?? 0),
|
||||
'catalog_version' => max(0, (int) ($remote['catalog_version'] ?? 0)),
|
||||
'remote_deleted' => $deleted ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($existing === null) {
|
||||
return [
|
||||
'action' => 'created',
|
||||
'values' => $values,
|
||||
'deactivated' => $values['status'] === 0 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$existingComparable = [
|
||||
'medicine_code' => trim((string) ($existing['medicine_code'] ?? '')),
|
||||
'name' => trim((string) ($existing['name'] ?? '')),
|
||||
'brand' => trim((string) ($existing['brand'] ?? '')),
|
||||
'unit' => trim((string) ($existing['unit'] ?? '')),
|
||||
'settlement_price' => self::decimal($existing['settlement_price'] ?? 0),
|
||||
'retail_price' => self::decimal($existing['retail_price'] ?? 0),
|
||||
'status' => (int) ($existing['status'] ?? 0),
|
||||
'catalog_version' => max(0, (int) ($existing['catalog_version'] ?? 0)),
|
||||
'remote_deleted' => (int) ($existing['remote_deleted'] ?? 0),
|
||||
];
|
||||
$deactivated = $existingComparable['status'] === 1 && $values['status'] === 0 ? 1 : 0;
|
||||
|
||||
return [
|
||||
'action' => $existingComparable === $values ? 'unchanged' : 'updated',
|
||||
'values' => $values,
|
||||
'deactivated' => $deactivated,
|
||||
];
|
||||
}
|
||||
|
||||
private static function decimal(mixed $value): string
|
||||
{
|
||||
return number_format(max(0.0, (float) $value), 4, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use app\common\model\pharmacy\EjMedicineCatalog;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use Throwable;
|
||||
|
||||
final class EjMedicineCatalogSyncService
|
||||
{
|
||||
private const STATE_ID = 1;
|
||||
private const LOCK_TTL = 600;
|
||||
|
||||
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
|
||||
public static function sync(int $limit = 200): array
|
||||
{
|
||||
if (!(bool) Config::get('ej_pharmacy.catalog_sync_enabled', false)) {
|
||||
throw new RuntimeException('恩济药房增量目录同步已关闭,请使用一次性 bootstrap 命令初始化药材目录');
|
||||
}
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('洛阳药房接口未启用或配置不完整');
|
||||
}
|
||||
|
||||
self::ensureStateRow();
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$client = new EjPharmacyClient();
|
||||
$workflow = new EjMedicineCatalogSyncWorkflow(
|
||||
static fn (): bool => self::acquireLock($token),
|
||||
static fn () => self::releaseLock($token),
|
||||
static fn (): int => (int) (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->value('cursor') ?? 0),
|
||||
static fn (int $cursor, int $pageLimit): array => $client->medicines($cursor, $pageLimit),
|
||||
static fn (array $items, int $nextCursor): array => self::mergePage($items, $nextCursor, $token),
|
||||
static function (int $cursor) use ($token): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)->update([
|
||||
'cursor' => $cursor,
|
||||
'last_success_time' => time(),
|
||||
'last_error_summary' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
},
|
||||
static function (int $cursor, string $error) use ($token): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)->update([
|
||||
'cursor' => $cursor,
|
||||
'last_failure_time' => time(),
|
||||
'last_error_summary' => $error,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
return $workflow->sync($limit);
|
||||
}
|
||||
|
||||
private static function ensureStateRow(): void
|
||||
{
|
||||
if (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->find()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Db::name('ej_pharmacy_sync_state')->insert([
|
||||
'id' => self::STATE_ID,
|
||||
'cursor' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
if (!self::isDuplicateKey($exception)) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function acquireLock(string $token): bool
|
||||
{
|
||||
$now = time();
|
||||
$updated = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now);
|
||||
})
|
||||
->update([
|
||||
'lock_token' => $token,
|
||||
'lock_expires_at' => $now + self::LOCK_TTL,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
return $updated === 1;
|
||||
}
|
||||
|
||||
private static function releaseLock(string $token): void
|
||||
{
|
||||
Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->update(['lock_token' => '', 'lock_expires_at' => 0, 'update_time' => time()]);
|
||||
}
|
||||
|
||||
/** @return array{created:int,updated:int,unchanged:int,deactivated:int} */
|
||||
private static function mergePage(array $items, int $nextCursor, string $token): array
|
||||
{
|
||||
return Db::transaction(function () use ($items, $nextCursor, $token): array {
|
||||
$stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'deactivated' => 0];
|
||||
foreach ($items as $item) {
|
||||
$code = trim((string) ($item['medicine_code'] ?? ''));
|
||||
$model = $code === '' ? null : EjMedicineCatalog::where('medicine_code', $code)->lock(true)->find();
|
||||
$result = EjMedicineCatalogSyncPolicy::merge($model ? $model->toArray() : null, $item);
|
||||
$values = $result['values'];
|
||||
if ($result['action'] === 'created') {
|
||||
EjMedicineCatalog::create($values);
|
||||
} elseif ($result['action'] === 'updated' && $model) {
|
||||
unset($values['medicine_code']);
|
||||
$model->save($values);
|
||||
}
|
||||
++$stats[$result['action']];
|
||||
$stats['deactivated'] += $result['deactivated'];
|
||||
|
||||
if ($result['deactivated'] === 1) {
|
||||
$now = time();
|
||||
Db::name('ej_medicine_mapping')
|
||||
->where('medicine_code', $code)
|
||||
->where('status', 1)
|
||||
->update(['status' => 0, 'delete_time' => $now, 'update_time' => $now]);
|
||||
}
|
||||
}
|
||||
|
||||
$state = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$state || !hash_equals((string) $state['lock_token'], $token)) {
|
||||
throw new RuntimeException('洛阳药房目录同步锁已失效,请重试');
|
||||
}
|
||||
Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->update([
|
||||
'cursor' => $nextCursor,
|
||||
'lock_expires_at' => time() + self::LOCK_TTL,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
return $stats;
|
||||
});
|
||||
}
|
||||
|
||||
private static function isDuplicateKey(Throwable $exception): bool
|
||||
{
|
||||
return (string) $exception->getCode() === '23000'
|
||||
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EjMedicineCatalogSyncWorkflow
|
||||
{
|
||||
private $acquireLock;
|
||||
private $releaseLock;
|
||||
private $loadCursor;
|
||||
private $fetchPage;
|
||||
private $mergePage;
|
||||
private $markSuccess;
|
||||
private $markFailure;
|
||||
|
||||
public function __construct(
|
||||
callable $acquireLock,
|
||||
callable $releaseLock,
|
||||
callable $loadCursor,
|
||||
callable $fetchPage,
|
||||
callable $mergePage,
|
||||
callable $markSuccess,
|
||||
callable $markFailure
|
||||
) {
|
||||
$this->acquireLock = $acquireLock;
|
||||
$this->releaseLock = $releaseLock;
|
||||
$this->loadCursor = $loadCursor;
|
||||
$this->fetchPage = $fetchPage;
|
||||
$this->mergePage = $mergePage;
|
||||
$this->markSuccess = $markSuccess;
|
||||
$this->markFailure = $markFailure;
|
||||
}
|
||||
|
||||
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
|
||||
public function sync(int $limit = 200): array
|
||||
{
|
||||
if (!(bool) ($this->acquireLock)()) {
|
||||
throw new DomainException('洛阳药房目录正在同步,请稍后重试');
|
||||
}
|
||||
|
||||
$cursor = 0;
|
||||
$stats = [
|
||||
'pages' => 0,
|
||||
'pulled' => 0,
|
||||
'received' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'deactivated' => 0,
|
||||
'cursor' => $cursor,
|
||||
];
|
||||
|
||||
try {
|
||||
$cursor = max(0, (int) ($this->loadCursor)());
|
||||
$stats['cursor'] = $cursor;
|
||||
for ($page = 0; $page < 1000; ++$page) {
|
||||
$parsed = EjMedicineCatalogSyncPolicy::parsePage(
|
||||
($this->fetchPage)($cursor, min(max($limit, 1), 500)),
|
||||
$cursor
|
||||
);
|
||||
$merged = ($this->mergePage)($parsed['items'], $parsed['next_cursor']);
|
||||
++$stats['pages'];
|
||||
$pulled = count($parsed['items']);
|
||||
$stats['pulled'] += $pulled;
|
||||
$stats['received'] += $pulled;
|
||||
foreach (['created', 'updated', 'unchanged', 'deactivated'] as $key) {
|
||||
$stats[$key] += (int) ($merged[$key] ?? 0);
|
||||
}
|
||||
$cursor = $parsed['next_cursor'];
|
||||
$stats['cursor'] = $cursor;
|
||||
if (!$parsed['has_more']) {
|
||||
($this->markSuccess)($cursor, $stats);
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('洛阳药房药材目录分页超过安全上限');
|
||||
} catch (Throwable $exception) {
|
||||
($this->markFailure)($cursor, self::summarizeError($exception->getMessage()));
|
||||
throw $exception;
|
||||
} finally {
|
||||
($this->releaseLock)();
|
||||
}
|
||||
}
|
||||
|
||||
public static function summarizeError(string $message): string
|
||||
{
|
||||
$message = preg_replace('/(app[_-]?secret|signature|token|authorization)\s*[:=]\s*[^\s,;]+/i', '$1=[redacted]', $message) ?? $message;
|
||||
return mb_substr(trim($message), 0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class EjMedicineMappingPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $local @param array<string,mixed> $remote */
|
||||
public static function assertValid(array $local, array $remote): void
|
||||
{
|
||||
if ((int) ($local['id'] ?? 0) <= 0) {
|
||||
throw new DomainException('本地药材不存在');
|
||||
}
|
||||
if (!empty($local['delete_time'])) {
|
||||
throw new DomainException('本地药材已删除,不能建立映射');
|
||||
}
|
||||
if ((int) ($local['status'] ?? 0) !== 1) {
|
||||
throw new DomainException('本地药材已停用,不能建立映射');
|
||||
}
|
||||
|
||||
if (trim((string) ($remote['medicine_code'] ?? '')) === '') {
|
||||
throw new DomainException('洛阳药房药材不存在');
|
||||
}
|
||||
if (!empty($remote['remote_deleted'])) {
|
||||
throw new DomainException('洛阳药房药材已删除,不能建立映射');
|
||||
}
|
||||
if ((int) ($remote['status'] ?? 0) !== 1) {
|
||||
throw new DomainException('洛阳药房药材已停用,不能建立映射');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $local
|
||||
* @param array<string,mixed>|null $mapping
|
||||
* @return array{mapping_id:int,already_unlinked:bool}
|
||||
*/
|
||||
public static function unlinkDecision(array $local, ?array $mapping): array
|
||||
{
|
||||
$localId = (int) ($local['id'] ?? 0);
|
||||
if ($localId <= 0) {
|
||||
throw new DomainException('本地药材不存在');
|
||||
}
|
||||
if ($mapping === null) {
|
||||
return ['mapping_id' => 0, 'already_unlinked' => true];
|
||||
}
|
||||
if ((int) ($mapping['local_medicine_id'] ?? 0) !== $localId) {
|
||||
throw new DomainException('药材映射归属不匹配');
|
||||
}
|
||||
|
||||
$mappingId = (int) ($mapping['id'] ?? 0);
|
||||
if ($mappingId <= 0) {
|
||||
throw new DomainException('药材映射记录无效');
|
||||
}
|
||||
return [
|
||||
'mapping_id' => $mappingId,
|
||||
'already_unlinked' => (int) ($mapping['status'] ?? 0) !== 1 || !empty($mapping['delete_time']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjPharmacyCallbackFailureTransition
|
||||
{
|
||||
/** @param callable(int,array<string,mixed>,string):bool $conditionalUpdate */
|
||||
public static function apply(int $inboxId, string $error, callable $conditionalUpdate): bool
|
||||
{
|
||||
if ($inboxId <= 0) {
|
||||
throw new InvalidArgumentException('callback inbox id is missing');
|
||||
}
|
||||
|
||||
return (bool) Closure::fromCallable($conditionalUpdate)(
|
||||
$inboxId,
|
||||
[
|
||||
'process_status' => 'FAILED',
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'update_time' => time(),
|
||||
],
|
||||
'PROCESSED'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class EjPharmacyCallbackRetryException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EjPharmacyCallbackWorkflow
|
||||
{
|
||||
private Closure $loadInbox;
|
||||
private Closure $createInbox;
|
||||
private Closure $reloadInbox;
|
||||
private Closure $process;
|
||||
private Closure $markFailed;
|
||||
private Closure $isDuplicateKey;
|
||||
|
||||
public function __construct(
|
||||
callable $loadInbox,
|
||||
callable $createInbox,
|
||||
callable $reloadInbox,
|
||||
callable $process,
|
||||
callable $markFailed,
|
||||
callable $isDuplicateKey
|
||||
) {
|
||||
$this->loadInbox = Closure::fromCallable($loadInbox);
|
||||
$this->createInbox = Closure::fromCallable($createInbox);
|
||||
$this->reloadInbox = Closure::fromCallable($reloadInbox);
|
||||
$this->process = Closure::fromCallable($process);
|
||||
$this->markFailed = Closure::fromCallable($markFailed);
|
||||
$this->isDuplicateKey = Closure::fromCallable($isDuplicateKey);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array<string,mixed> */
|
||||
public function handle(array $payload): array
|
||||
{
|
||||
$eventId = trim((string) ($payload['event_id'] ?? ''));
|
||||
if ($eventId === '') {
|
||||
return ['http_status' => 422, 'message' => 'event_id is required', 'duplicate' => false];
|
||||
}
|
||||
|
||||
$inbox = ($this->loadInbox)($eventId);
|
||||
if (is_array($inbox) && strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') {
|
||||
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true];
|
||||
}
|
||||
|
||||
if (!is_array($inbox)) {
|
||||
try {
|
||||
$inbox = ($this->createInbox)($payload);
|
||||
} catch (Throwable $exception) {
|
||||
if (!(bool) ($this->isDuplicateKey)($exception)) {
|
||||
return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
}
|
||||
$inbox = ($this->reloadInbox)($eventId);
|
||||
if (!is_array($inbox)) {
|
||||
return ['http_status' => 500, 'message' => 'callback inbox race could not be reloaded', 'duplicate' => false];
|
||||
}
|
||||
if (strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') {
|
||||
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
($this->process)($inbox, $payload);
|
||||
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => false];
|
||||
} catch (EjPharmacyCallbackRetryException $exception) {
|
||||
($this->markFailed)($inbox, $exception->getMessage());
|
||||
return ['http_status' => 503, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
} catch (InvalidArgumentException|DomainException $exception) {
|
||||
($this->markFailed)($inbox, $exception->getMessage());
|
||||
return ['http_status' => 422, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
} catch (Throwable $exception) {
|
||||
($this->markFailed)($inbox, $exception->getMessage());
|
||||
return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
|
||||
final class EjPharmacyClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $appKey;
|
||||
private string $appSecret;
|
||||
/** @var null|Closure(string,string,string,array<int,string>):array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
private ?Closure $transport;
|
||||
|
||||
public function __construct(
|
||||
?string $baseUrl = null,
|
||||
?string $appKey = null,
|
||||
?string $appSecret = null,
|
||||
?callable $transport = null
|
||||
)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl ?? (string) Config::get('ej_pharmacy.base_url', ''), '/');
|
||||
$this->appKey = $appKey ?? (string) Config::get('ej_pharmacy.app_key', '');
|
||||
$this->appSecret = $appSecret ?? (string) Config::get('ej_pharmacy.app_secret', '');
|
||||
if ($this->baseUrl === '' || $this->appKey === '' || $this->appSecret === '') {
|
||||
throw new RuntimeException('恩济药房接口未配置完整');
|
||||
}
|
||||
$this->transport = $transport === null ? null : Closure::fromCallable($transport);
|
||||
}
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return (bool) Config::get('ej_pharmacy.enabled', false)
|
||||
&& trim((string) Config::get('ej_pharmacy.base_url', '')) !== ''
|
||||
&& trim((string) Config::get('ej_pharmacy.app_key', '')) !== ''
|
||||
&& trim((string) Config::get('ej_pharmacy.app_secret', '')) !== '';
|
||||
}
|
||||
|
||||
/** @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function medicines(int $after = 0, int $limit = 100): array
|
||||
{
|
||||
return $this->request('GET', '/api/openapi/v1/medicines', null, [
|
||||
'after' => max($after, 0),
|
||||
'limit' => min(max($limit, 1), 500),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function importMedicines(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/api/openapi/v1/medicine-imports', $payload);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function createPrescriptionOrder(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/api/openapi/v1/prescription-orders', $payload);
|
||||
}
|
||||
|
||||
/** @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function prescriptionOrder(string $sourceOrderNo, int $sourceRevision = 0): array
|
||||
{
|
||||
$query = ['source_system' => 'zyt'];
|
||||
if ($sourceRevision > 0) {
|
||||
$query['source_revision'] = $sourceRevision;
|
||||
}
|
||||
return $this->request(
|
||||
'GET',
|
||||
'/api/openapi/v1/prescription-orders/' . rawurlencode($sourceOrderNo),
|
||||
null,
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $payload @param array<string,int|string> $query @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
private function request(string $method, string $path, ?array $payload = null, array $query = []): array
|
||||
{
|
||||
$queryString = $query === [] ? '' : http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
$pathWithQuery = $path . ($queryString !== '' ? '?' . $queryString : '');
|
||||
$body = $payload === null
|
||||
? ''
|
||||
: (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$timestamp = (string) time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$requestId = bin2hex(random_bytes(16));
|
||||
$canonical = EjPharmacySignature::canonical($method, $pathWithQuery, $timestamp, $nonce, $body);
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'X-App-Key: ' . $this->appKey,
|
||||
'X-Timestamp: ' . $timestamp,
|
||||
'X-Nonce: ' . $nonce,
|
||||
'X-Signature: ' . EjPharmacySignature::sign($this->appSecret, $canonical),
|
||||
'X-Request-Id: ' . $requestId,
|
||||
'Expect:',
|
||||
];
|
||||
|
||||
if ($this->transport !== null) {
|
||||
return ($this->transport)(strtoupper($method), $pathWithQuery, $body, $headers);
|
||||
}
|
||||
|
||||
$configuredTransport = strtolower((string) Config::get('ej_pharmacy.http_transport', 'auto'));
|
||||
$curlSsl = strtoupper((string) ((function_exists('curl_version') ? curl_version() : [])['ssl_version'] ?? ''));
|
||||
if ($configuredTransport === 'openssl'
|
||||
|| ($configuredTransport === 'auto' && str_starts_with($curlSsl, 'NSS/'))
|
||||
) {
|
||||
return $this->requestWithOpenSsl($method, $pathWithQuery, $body, $headers, $requestId);
|
||||
}
|
||||
|
||||
$ch = curl_init($this->baseUrl . $pathWithQuery);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => (int) Config::get('ej_pharmacy.connect_timeout', 5),
|
||||
CURLOPT_TIMEOUT => (int) Config::get('ej_pharmacy.request_timeout', 30),
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($payload !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('恩济药房通信失败:' . $error);
|
||||
}
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('恩济药房返回了无效 JSON,HTTP ' . $httpStatus);
|
||||
}
|
||||
|
||||
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
|
||||
}
|
||||
|
||||
/** @param array<int,string> $headers @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
private function requestWithOpenSsl(
|
||||
string $method,
|
||||
string $path,
|
||||
string $body,
|
||||
array $headers,
|
||||
string $requestId
|
||||
): array {
|
||||
$ssl = [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'allow_self_signed' => false,
|
||||
];
|
||||
$caFile = trim((string) Config::get('ej_pharmacy.ca_file', ''));
|
||||
if ($caFile !== '') {
|
||||
$ssl['cafile'] = $caFile;
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => strtoupper($method),
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'ignore_errors' => true,
|
||||
'timeout' => (int) Config::get('ej_pharmacy.request_timeout', 30),
|
||||
'protocol_version' => 1.1,
|
||||
],
|
||||
'ssl' => $ssl,
|
||||
]);
|
||||
$raw = @file_get_contents($this->baseUrl . $path, false, $context);
|
||||
if ($raw === false) {
|
||||
$lastError = error_get_last();
|
||||
$message = is_array($lastError) ? (string) ($lastError['message'] ?? '') : '';
|
||||
throw new RuntimeException('恩济药房通信失败:' . ($message !== '' ? $message : 'OpenSSL 请求失败'));
|
||||
}
|
||||
|
||||
$httpStatus = 0;
|
||||
$responseHeaders = $http_response_header ?? [];
|
||||
foreach (array_reverse($responseHeaders) as $responseHeader) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', $responseHeader, $matches)) {
|
||||
$httpStatus = (int) $matches[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('恩济药房返回了无效 JSON,HTTP ' . $httpStatus);
|
||||
}
|
||||
|
||||
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjPharmacyPayload
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed> $prescription
|
||||
* @param array<int,string> $medicineMappings
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function build(array $order, array $prescription, array $medicineMappings, int $revision): array
|
||||
{
|
||||
$orderNo = trim((string) ($order['order_no'] ?? ''));
|
||||
if ($orderNo === '') {
|
||||
throw new InvalidArgumentException('order_no is required');
|
||||
}
|
||||
$herbs = $prescription['herbs'] ?? [];
|
||||
if (!is_array($herbs) || $herbs === []) {
|
||||
throw new InvalidArgumentException('prescription herbs are required');
|
||||
}
|
||||
$medicines = [];
|
||||
foreach ($herbs as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
$medicineId = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
|
||||
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
|
||||
$medicineCode = trim((string) ($medicineMappings[$medicineId] ?? ''));
|
||||
if ($medicineId <= 0 || $medicineCode === '') {
|
||||
throw new DomainException('Unmapped medicine: ' . ($name !== '' ? $name : (string) $medicineId));
|
||||
}
|
||||
$quantity = (float) ($herb['dose'] ?? $herb['dosage'] ?? $herb['quantity'] ?? 0);
|
||||
if ($quantity <= 0) {
|
||||
throw new InvalidArgumentException('Medicine quantity must be positive: ' . $name);
|
||||
}
|
||||
$medicines[] = [
|
||||
'source_medicine_id' => (string) $medicineId,
|
||||
'medicine_code' => $medicineCode,
|
||||
'name' => $name,
|
||||
'quantity' => number_format($quantity, 4, '.', ''),
|
||||
'unit' => trim((string) ($herb['unit'] ?? '克')) ?: '克',
|
||||
'usage' => trim((string) ($herb['usage'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($medicines === []) {
|
||||
throw new InvalidArgumentException('prescription herbs are required');
|
||||
}
|
||||
|
||||
return [
|
||||
'source_system' => 'zyt',
|
||||
'source_order_no' => $orderNo,
|
||||
'source_revision' => max($revision, 1),
|
||||
'patient' => [
|
||||
'source_patient_id' => (string) ($prescription['patient_id'] ?? ''),
|
||||
'name' => trim((string) ($prescription['patient_name'] ?? $order['recipient_name'] ?? '')),
|
||||
'id_card' => trim((string) ($prescription['id_card'] ?? '')),
|
||||
'mobile' => trim((string) ($prescription['phone'] ?? $order['recipient_phone'] ?? '')),
|
||||
],
|
||||
'shipping' => [
|
||||
'recipient_name' => trim((string) ($order['recipient_name'] ?? $prescription['patient_name'] ?? '')),
|
||||
'recipient_mobile' => trim((string) ($order['recipient_phone'] ?? $prescription['phone'] ?? '')),
|
||||
'province' => trim((string) ($order['shipping_province'] ?? '')),
|
||||
'city' => trim((string) ($order['shipping_city'] ?? '')),
|
||||
'district' => trim((string) ($order['shipping_district'] ?? '')),
|
||||
'address' => trim((string) ($order['shipping_address'] ?? '')),
|
||||
],
|
||||
'prescription' => [
|
||||
'source_prescription_id' => (string) ($prescription['id'] ?? ''),
|
||||
'diagnosis' => trim((string) (
|
||||
$prescription['clinical_diagnosis']
|
||||
?? $prescription['diagnosis']
|
||||
?? $prescription['diagnosis_name']
|
||||
?? ''
|
||||
)),
|
||||
'processing_type' => trim((string) ($prescription['processing_type'] ?? 'decoction')) ?: 'decoction',
|
||||
'dose_count' => max((int) ($order['dose_count'] ?? $prescription['dose_count'] ?? 1), 1),
|
||||
'doctor' => [
|
||||
'source_doctor_id' => (string) ($prescription['creator_id'] ?? $prescription['doctor_id'] ?? ''),
|
||||
'name' => trim((string) ($prescription['doctor_name'] ?? '')),
|
||||
],
|
||||
'doctor_signature' => is_array($prescription['doctor_signature'] ?? null)
|
||||
? $prescription['doctor_signature']
|
||||
: [],
|
||||
'medicines' => $medicines,
|
||||
'instructions' => trim((string) (
|
||||
$prescription['usage_instruction']
|
||||
?? $prescription['instructions']
|
||||
?? $prescription['advice']
|
||||
?? ''
|
||||
)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class EjPharmacyShipmentPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isShippedEvent(array $payload): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'ORDER_SHIPPED'
|
||||
|| strtoupper(trim((string) ($payload['status'] ?? ''))) === 'SHIPPED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isWorkflowStepEvent(array $payload): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'WORKFLOW_STEP_COMPLETED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isCompletedEvent(array $payload): bool
|
||||
{
|
||||
// A workflow node (including the final “ship” node) is only a
|
||||
// pharmacy-process update. It must not close the ZYT business order.
|
||||
return !self::isWorkflowStepEvent($payload)
|
||||
&& strtoupper(trim((string) ($payload['status'] ?? ''))) === 'COMPLETED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isRejectedEvent(array $payload): bool
|
||||
{
|
||||
return in_array(strtoupper(trim((string) ($payload['event_type'] ?? ''))), [
|
||||
'REVIEW_REJECTED',
|
||||
'INVENTORY_SHORTAGE',
|
||||
], true)
|
||||
|| strtoupper(trim((string) ($payload['status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($payload['review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function nextFulfillmentStatus(
|
||||
int $currentStatus,
|
||||
array $payload,
|
||||
?int $rollbackStatus = null
|
||||
): int
|
||||
{
|
||||
if (self::isRejectedEvent($payload)) {
|
||||
// EJ rejection means the pharmacy did not accept the submission;
|
||||
// it must not turn the ZYT business order into a customer refusal.
|
||||
// Restore the status captured immediately before the submission.
|
||||
return $rollbackStatus !== null && $rollbackStatus > 0
|
||||
? $rollbackStatus
|
||||
: ($currentStatus === 9 ? 2 : $currentStatus);
|
||||
}
|
||||
if (self::isWorkflowStepEvent($payload)) {
|
||||
// EJ workflow callbacks are informational nodes. Keep the ZYT
|
||||
// fulfillment status unchanged; only explicit shipment/completion
|
||||
// events may advance it.
|
||||
return $currentStatus;
|
||||
}
|
||||
if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
|
||||
return 5;
|
||||
}
|
||||
if (self::isCompletedEvent($payload) && in_array($currentStatus, [1, 2, 5], true)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return $currentStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class EjPharmacySignature
|
||||
{
|
||||
public static function canonical(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
||||
{
|
||||
return implode("\n", [
|
||||
strtoupper(trim($method)),
|
||||
$path,
|
||||
trim($timestamp),
|
||||
trim($nonce),
|
||||
hash('sha256', $body),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function sign(string $secret, string $canonical): string
|
||||
{
|
||||
return hash_hmac('sha256', $canonical, $secret);
|
||||
}
|
||||
|
||||
public static function verify(string $secret, string $canonical, string $signature): bool
|
||||
{
|
||||
return $secret !== '' && $signature !== '' && hash_equals(self::sign($secret, $canonical), strtolower(trim($signature)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class EjPharmacyTrackingPolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed>|null $current Active tracking for this order.
|
||||
* @param array<string,mixed>|null $matching Tracking already owning the incoming number.
|
||||
* @return array{action:string,archive_current:bool}
|
||||
*/
|
||||
public static function select(?array $current, ?array $matching, int $orderId, string $trackingNumber): array
|
||||
{
|
||||
$trackingNumber = trim($trackingNumber);
|
||||
if ($current !== null && trim((string) ($current['tracking_number'] ?? '')) === $trackingNumber) {
|
||||
return ['action' => 'REUSE_CURRENT', 'archive_current' => false];
|
||||
}
|
||||
|
||||
if ($matching !== null) {
|
||||
$ownerOrderId = (int) ($matching['order_id'] ?? 0);
|
||||
if ($ownerOrderId !== 0 && $ownerOrderId !== $orderId) {
|
||||
throw new DomainException('该运单号已关联其他订单,禁止重新绑定');
|
||||
}
|
||||
|
||||
return ['action' => 'REUSE_MATCHING', 'archive_current' => $current !== null];
|
||||
}
|
||||
|
||||
return ['action' => 'CREATE', 'archive_current' => $current !== null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class LockedPharmacySnapshotMutation
|
||||
{
|
||||
/**
|
||||
* @param callable():array<string,mixed> $lockOrder
|
||||
* @param callable(array<string,mixed>):?array<string,mixed> $lockClaim
|
||||
* @param callable(array<string,mixed>,?array<string,mixed>):mixed $mutation
|
||||
*/
|
||||
public static function run(
|
||||
callable $lockOrder,
|
||||
callable $lockClaim,
|
||||
callable $mutation,
|
||||
bool $assertMutable = true
|
||||
): mixed {
|
||||
$order = Closure::fromCallable($lockOrder)();
|
||||
if ($order === []) {
|
||||
throw new DomainException('订单不存在');
|
||||
}
|
||||
$claim = Closure::fromCallable($lockClaim)($order);
|
||||
if ($assertMutable) {
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim);
|
||||
}
|
||||
|
||||
$result = Closure::fromCallable($mutation)($order, $claim);
|
||||
if ($result === false) {
|
||||
throw new DomainException('受保护变更未完成,事务已回滚');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param callable(array<string,mixed>,?array<string,mixed>):mixed $mutation */
|
||||
public static function execute(
|
||||
int $orderId,
|
||||
callable $mutation,
|
||||
bool $assertMutable = true,
|
||||
int $revision = 1
|
||||
): mixed {
|
||||
return Db::transaction(static fn (): mixed => self::run(
|
||||
static fn (): array => (array) (Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find() ?: []),
|
||||
static fn (): ?array => Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->lock(true)
|
||||
->find() ?: null,
|
||||
$mutation,
|
||||
$assertMutable
|
||||
));
|
||||
}
|
||||
|
||||
/** @param callable(array<int,array<string,mixed>>):mixed $mutation */
|
||||
public static function executeForPrescription(int $prescriptionId, callable $mutation): mixed
|
||||
{
|
||||
return Db::transaction(static function () use ($prescriptionId, $mutation): mixed {
|
||||
$orders = Db::name('tcm_prescription_order')
|
||||
->where('prescription_id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->lock(true)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($orders as $order) {
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', (int) $order['id'])
|
||||
->where('source_revision', 1)
|
||||
->lock(true)
|
||||
->find();
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim ?: null);
|
||||
}
|
||||
|
||||
$result = Closure::fromCallable($mutation)($orders);
|
||||
if ($result === false) {
|
||||
throw new DomainException('受保护变更未完成,事务已回滚');
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacyHerbIdentityResolver
|
||||
{
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $herbs
|
||||
* @param callable(array<int,int>):array<int,array<string,mixed>> $loadByIds
|
||||
* @param callable(array<int,string>):array<int,array<string,mixed>> $loadByNames
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public static function resolve(array $herbs, callable $loadByIds, callable $loadByNames): array
|
||||
{
|
||||
$ids = [];
|
||||
$names = [];
|
||||
foreach ($herbs as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$byId = [];
|
||||
foreach ($ids === [] ? [] : $loadByIds(array_values(array_unique($ids))) as $row) {
|
||||
if (self::isActive($row)) {
|
||||
$byId[(int) $row['id']] = $row;
|
||||
}
|
||||
}
|
||||
$byName = [];
|
||||
foreach ($names === [] ? [] : $loadByNames(array_values(array_unique($names))) as $row) {
|
||||
if (!self::isActive($row)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$byName[$name][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($herbs as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
|
||||
$id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$row = $byId[$id] ?? null;
|
||||
if (!is_array($row)) {
|
||||
throw new DomainException('药材“' . ($name !== '' ? $name : (string) $id) . '”对应的本地药材不存在或已停用');
|
||||
}
|
||||
} else {
|
||||
if ($name === '') {
|
||||
throw new DomainException('药材名称不能为空');
|
||||
}
|
||||
$candidates = $byName[$name] ?? [];
|
||||
if (count($candidates) === 0) {
|
||||
throw new DomainException('药材“' . $name . '”未在本地药材库中找到');
|
||||
}
|
||||
if (count($candidates) !== 1) {
|
||||
throw new DomainException('药材“' . $name . '”存在多个同名记录,请重新选择具体药材');
|
||||
}
|
||||
$row = $candidates[0];
|
||||
$id = (int) $row['id'];
|
||||
}
|
||||
|
||||
$herb['medicine_id'] = $id;
|
||||
$herb['name'] = trim((string) ($row['name'] ?? $name));
|
||||
unset($herb['id'], $herb['title'], $herb['local_medicine_id']);
|
||||
$resolved[] = $herb;
|
||||
}
|
||||
|
||||
if ($resolved === []) {
|
||||
throw new DomainException('处方药材不能为空');
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private static function isActive(array $row): bool
|
||||
{
|
||||
return (int) ($row['id'] ?? 0) > 0
|
||||
&& (int) ($row['status'] ?? 0) === 1
|
||||
&& ($row['delete_time'] ?? null) === null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class PharmacyLogisticsValue
|
||||
{
|
||||
public static function normalize(mixed $value, int $maxLength, string $label): string
|
||||
{
|
||||
$normalized = preg_replace(
|
||||
'/^[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+|[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+$/u',
|
||||
'',
|
||||
(string) $value
|
||||
);
|
||||
if ($normalized === null) {
|
||||
throw new InvalidArgumentException($label . '格式无效');
|
||||
}
|
||||
if (mb_strlen($normalized) > $maxLength) {
|
||||
throw new InvalidArgumentException($label . '长度不能超过' . $maxLength . '个字符');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PharmacyReconciliationRequiredException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Throwable;
|
||||
|
||||
final class PharmacyRemoteOutcomeClassifier
|
||||
{
|
||||
public static function isConfirmedEjNoCreateHttpStatus(int $httpStatus): bool
|
||||
{
|
||||
return in_array($httpStatus, [400, 401, 403, 404, 405, 415, 422], true);
|
||||
}
|
||||
|
||||
public static function isConfirmedNoCreate(Throwable $exception): bool
|
||||
{
|
||||
return $exception instanceof PharmacyRemoteRejectedException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** The pharmacy explicitly confirmed that no remote order was created. */
|
||||
final class PharmacyRemoteRejectedException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacyRemoteSnapshotPolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed>|null $claim
|
||||
*/
|
||||
public static function isLocked(array $order, ?array $claim): bool
|
||||
{
|
||||
if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
if (trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
if ((int) ($order['gancao_submit_time'] ?? 0) > 0 || (int) ($order['ej_pharmacy_submit_time'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_array($claim) && in_array(
|
||||
strtoupper((string) ($claim['status'] ?? '')),
|
||||
['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed>|null $claim
|
||||
*/
|
||||
public static function assertMutable(array $order, ?array $claim): void
|
||||
{
|
||||
if (self::isLocked($order, $claim)) {
|
||||
throw new DomainException('订单已提交药房,患者、地址、处方与发货药房快照不可修改;请先完成远端取消确认,取消后创建新版本');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacySubmissionClaimPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $claim @return array<string,mixed> */
|
||||
public static function existingDecision(array $claim, string $requestedTarget, ?int $now = null): array
|
||||
{
|
||||
$target = (string) ($claim['target'] ?? '');
|
||||
$status = strtoupper(trim((string) ($claim['status'] ?? '')));
|
||||
if ($status === 'SUCCESS') {
|
||||
if ($target !== $requestedTarget) {
|
||||
throw new DomainException('该订单已上传其他药房');
|
||||
}
|
||||
return ['action' => 'IDEMPOTENT'] + $claim;
|
||||
}
|
||||
if ($status === 'PENDING') {
|
||||
$leaseExpiresAt = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
if ($leaseExpiresAt > 0 && $leaseExpiresAt <= ($now ?? time())) {
|
||||
return [
|
||||
'action' => $target === 'gancao' ? 'RECONCILE' : 'RETRY',
|
||||
'lease_expired' => true,
|
||||
] + $claim;
|
||||
}
|
||||
throw new DomainException('该订单正在上传药房,请勿重复提交');
|
||||
}
|
||||
if (in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) {
|
||||
if ($target !== $requestedTarget) {
|
||||
throw new DomainException('该订单远端结果待对账,禁止切换药房');
|
||||
}
|
||||
return ['action' => 'RECONCILE'] + $claim;
|
||||
}
|
||||
if ($status === 'FAILED') {
|
||||
return ['action' => 'RETRY'] + $claim;
|
||||
}
|
||||
|
||||
throw new DomainException('药房提交状态异常,请先对账处理');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
|
||||
final class PharmacySubmissionClaimService
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public static function acquire(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
int $operatorId,
|
||||
string $operatorName
|
||||
): array {
|
||||
self::assertTarget($target);
|
||||
$revision = max($revision, 0);
|
||||
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
throw new DomainException('订单不存在');
|
||||
}
|
||||
|
||||
if ($revision <= 0) {
|
||||
$revision = self::nextRevisionForOrder($order, $target);
|
||||
}
|
||||
|
||||
// Rows handled by the old integration may already be marked as
|
||||
// ZYT "拒收". Reopen them to the uploadable fulfillment state
|
||||
// before creating the next EJ revision.
|
||||
if ($target === 'direct'
|
||||
&& self::isRejectedEjOrder($order, $target)
|
||||
&& (int) ($order['fulfillment_status'] ?? 0) === 9) {
|
||||
Db::name('tcm_prescription_order')->where('id', $orderId)->update([
|
||||
'fulfillment_status' => 2,
|
||||
]);
|
||||
$order['fulfillment_status'] = 2;
|
||||
}
|
||||
|
||||
$expectedTarget = self::targetForShipMode((string) ($order['ship_mode'] ?? 'gancao'));
|
||||
if ($expectedTarget !== $target) {
|
||||
throw new DomainException('发货药房已变更,请刷新后重试');
|
||||
}
|
||||
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', $revision)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($claim) {
|
||||
$decision = PharmacySubmissionClaimPolicy::existingDecision($claim, $target);
|
||||
if ($decision['action'] === 'IDEMPOTENT') {
|
||||
return [
|
||||
'target' => $target,
|
||||
'token' => (string) $claim['claim_token'],
|
||||
'status' => 'SUCCESS',
|
||||
'idempotent' => true,
|
||||
'result' => self::idempotentResult($target, (string) ($claim['remote_order_no'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($decision['action'] === 'RECONCILE') {
|
||||
if (!empty($decision['lease_expired'])) {
|
||||
$claim = self::expirePendingGancaoClaim($claim, $operatorId, $operatorName);
|
||||
}
|
||||
return [
|
||||
'target' => $target,
|
||||
'token' => (string) $claim['claim_token'],
|
||||
'status' => (string) $claim['status'],
|
||||
'idempotency_key' => (string) $claim['idempotency_key'],
|
||||
'idempotent' => false,
|
||||
'reconcile' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (self::hasAnyRemoteOrder($order)) {
|
||||
if (!self::isRejectedEjOrder($order, $target)) {
|
||||
throw new DomainException('该订单已存在远程药房单号,不可重复提交');
|
||||
}
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$now = time();
|
||||
$leaseSeconds = max(30, (int) Config::get('ej_pharmacy.submission_lease_seconds', 300));
|
||||
$values = [
|
||||
'target' => $target,
|
||||
'status' => 'PENDING',
|
||||
'claim_token' => $token,
|
||||
'idempotency_key' => hash('sha256', $orderId . ':' . $revision . ':' . $target),
|
||||
'remote_order_no' => '',
|
||||
'request_id' => '',
|
||||
'error_message' => '',
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'claimed_at' => $now,
|
||||
'lease_expires_at' => $now + $leaseSeconds,
|
||||
'completed_at' => 0,
|
||||
'failed_at' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($claim) {
|
||||
Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])->update($values);
|
||||
} else {
|
||||
Db::name('pharmacy_submission_claim')->insert($values + [
|
||||
'prescription_order_id' => $orderId,
|
||||
'source_revision' => $revision,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $values + [
|
||||
'source_revision' => $revision,
|
||||
'idempotent' => false,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
public static function markSuccess(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
string $token,
|
||||
array $result
|
||||
): bool {
|
||||
self::assertTarget($target);
|
||||
$remoteOrderNo = trim((string) (
|
||||
$result['remote_order_no']
|
||||
?? $result['pharmacy_order_no']
|
||||
?? $result['recipel_order_no']
|
||||
?? ''
|
||||
));
|
||||
if ($remoteOrderNo === '') {
|
||||
throw new DomainException('药房返回缺少远程订单号');
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $token, $result, $remoteOrderNo): bool {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order || self::hasConflictingRemoteOrder($order, $target)) {
|
||||
return false;
|
||||
}
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$claimUpdated = Db::name('pharmacy_submission_claim')
|
||||
->where('id', (int) $claim['id'])
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->update([
|
||||
'status' => 'SUCCESS',
|
||||
'remote_order_no' => mb_substr($remoteOrderNo, 0, 64),
|
||||
'request_id' => mb_substr((string) ($result['request_id'] ?? ''), 0, 64),
|
||||
'error_message' => '',
|
||||
'completed_at' => $now,
|
||||
'failed_at' => 0,
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($claimUpdated !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderValues = $target === 'direct'
|
||||
? [
|
||||
'ej_pharmacy_order_no' => mb_substr($remoteOrderNo, 0, 40),
|
||||
'ej_pharmacy_submit_time' => $now,
|
||||
'ej_pharmacy_status' => (string) ($result['status'] ?? 'PENDING_REVIEW'),
|
||||
'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'),
|
||||
'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1),
|
||||
// Preserve the business status from before the EJ upload.
|
||||
'ej_pharmacy_previous_fulfillment_status' => (int) ($order['ej_pharmacy_previous_fulfillment_status'] ?? 0) > 0
|
||||
? (int) $order['ej_pharmacy_previous_fulfillment_status']
|
||||
: (int) ($order['fulfillment_status'] ?? 0),
|
||||
]
|
||||
: [
|
||||
'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32),
|
||||
'gancao_submit_time' => $now,
|
||||
];
|
||||
Db::name('tcm_prescription_order')->where('id', $orderId)->update($orderValues);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static function markFailure(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
string $token,
|
||||
string $error
|
||||
): bool {
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->where('status', 'PENDING')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])
|
||||
->where('status', 'PENDING')
|
||||
->update([
|
||||
'status' => 'FAILED',
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'failed_at' => $now,
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
]) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
public static function markReconcile(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $target,
|
||||
string $token,
|
||||
string $error
|
||||
): bool {
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool {
|
||||
Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->lock(true)
|
||||
->find();
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->where('target', $target)
|
||||
->where('claim_token', $token)
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])
|
||||
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
|
||||
->update([
|
||||
'status' => 'PENDING_RECONCILE',
|
||||
'error_message' => mb_substr($error, 0, 1000),
|
||||
'failed_at' => 0,
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => time(),
|
||||
]) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function claimForOrder(int $orderId, int $revision = 0): ?array
|
||||
{
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->when($revision > 0, static fn ($query) => $query->where('source_revision', $revision))
|
||||
->order('source_revision', 'desc')
|
||||
->find();
|
||||
|
||||
return is_array($claim) ? $claim : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function resolveGancao(
|
||||
int $orderId,
|
||||
int $revision,
|
||||
string $resolution,
|
||||
string $remoteOrderNo,
|
||||
string $note,
|
||||
int $operatorId,
|
||||
string $operatorName
|
||||
): array {
|
||||
return Db::transaction(function () use (
|
||||
$orderId,
|
||||
$revision,
|
||||
$resolution,
|
||||
$remoteOrderNo,
|
||||
$note,
|
||||
$operatorId,
|
||||
$operatorName
|
||||
): array {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
->where('id', $orderId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$order) {
|
||||
throw new DomainException('订单不存在');
|
||||
}
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$claim) {
|
||||
throw new DomainException('未找到待核对的甘草提交');
|
||||
}
|
||||
$resolved = PharmacySubmissionReconciliationPolicy::resolve(
|
||||
$claim,
|
||||
$resolution,
|
||||
$remoteOrderNo,
|
||||
$note
|
||||
);
|
||||
if ($resolved['status'] === 'SUCCESS' && self::hasConflictingRemoteOrder($order, 'gancao')) {
|
||||
throw new DomainException('订单已存在洛阳药房单号,不能确认甘草成功');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$updated = Db::name('pharmacy_submission_claim')
|
||||
->where('id', (int) $claim['id'])
|
||||
->where('claim_token', (string) $claim['claim_token'])
|
||||
->whereIn('status', ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE'])
|
||||
->update([
|
||||
'status' => $resolved['status'],
|
||||
'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64),
|
||||
'error_message' => mb_substr($resolved['note'], 0, 1000),
|
||||
'lease_expires_at' => 0,
|
||||
'completed_at' => $resolved['status'] === 'SUCCESS' ? $now : 0,
|
||||
'failed_at' => $resolved['status'] === 'FAILED' ? $now : 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($updated !== 1) {
|
||||
throw new DomainException('提交状态已变化,请刷新后重新核对');
|
||||
}
|
||||
if ($resolved['status'] === 'SUCCESS') {
|
||||
Db::name('tcm_prescription_order')->where('id', $orderId)->update([
|
||||
'gancao_reciperl_order_no' => mb_substr($resolved['remote_order_no'], 0, 32),
|
||||
'gancao_submit_time' => $now,
|
||||
]);
|
||||
}
|
||||
Db::name('pharmacy_submission_claim_audit')->insert([
|
||||
'claim_id' => (int) $claim['id'],
|
||||
'prescription_order_id' => $orderId,
|
||||
'source_revision' => max($revision, 1),
|
||||
'target' => 'gancao',
|
||||
'action' => strtoupper(trim($resolution)),
|
||||
'from_status' => strtoupper((string) $claim['status']),
|
||||
'to_status' => $resolved['status'],
|
||||
'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64),
|
||||
'note' => mb_substr($resolved['note'], 0, 1000),
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
return $resolved + ['claim_id' => (int) $claim['id']];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
public static function hasAnyRemoteOrder(array $order): bool
|
||||
{
|
||||
return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== ''
|
||||
|| trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private static function isRejectedEjOrder(array $order, string $target): bool
|
||||
{
|
||||
if ($target !== 'direct') {
|
||||
return false;
|
||||
}
|
||||
return strtoupper(trim((string) ($order['ej_pharmacy_status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($order['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private static function nextRevisionForOrder(array $order, string $target): int
|
||||
{
|
||||
$latest = (int) Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', (int) $order['id'])
|
||||
->max('source_revision');
|
||||
if (self::isRejectedEjOrder($order, $target)) {
|
||||
return max($latest + 1, 1);
|
||||
}
|
||||
return max($latest, 1);
|
||||
}
|
||||
|
||||
private static function targetForShipMode(string $shipMode): string
|
||||
{
|
||||
return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $claim @return array<string,mixed> */
|
||||
private static function expirePendingGancaoClaim(array $claim, int $operatorId, string $operatorName): array
|
||||
{
|
||||
$now = time();
|
||||
$newToken = bin2hex(random_bytes(16));
|
||||
$updated = Db::name('pharmacy_submission_claim')
|
||||
->where('id', (int) $claim['id'])
|
||||
->where('status', 'PENDING')
|
||||
->where('claim_token', (string) $claim['claim_token'])
|
||||
->update([
|
||||
'status' => 'PENDING_RECONCILE',
|
||||
'claim_token' => $newToken,
|
||||
'error_message' => '提交租约已超时,甘草远端结果不确定,须人工核对',
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'lease_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($updated !== 1) {
|
||||
throw new DomainException('提交租约状态已变化,请刷新后重试');
|
||||
}
|
||||
Db::name('pharmacy_submission_claim_audit')->insert([
|
||||
'claim_id' => (int) $claim['id'],
|
||||
'prescription_order_id' => (int) $claim['prescription_order_id'],
|
||||
'source_revision' => (int) $claim['source_revision'],
|
||||
'target' => 'gancao',
|
||||
'action' => 'LEASE_EXPIRED',
|
||||
'from_status' => 'PENDING',
|
||||
'to_status' => 'PENDING_RECONCILE',
|
||||
'remote_order_no' => '',
|
||||
'note' => '租约超时后轮换 claim token,禁止自动重提',
|
||||
'operator_id' => $operatorId,
|
||||
'operator_name' => mb_substr(trim($operatorName), 0, 80),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
return array_replace($claim, [
|
||||
'status' => 'PENDING_RECONCILE',
|
||||
'claim_token' => $newToken,
|
||||
'lease_expires_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function assertTarget(string $target): void
|
||||
{
|
||||
if (!in_array($target, ['gancao', 'direct'], true)) {
|
||||
throw new DomainException('不支持的药房目标');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private static function hasConflictingRemoteOrder(array $order, string $target): bool
|
||||
{
|
||||
if ($target === 'direct') {
|
||||
return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
return trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function idempotentResult(string $target, string $remoteOrderNo): array
|
||||
{
|
||||
if ($target === 'direct') {
|
||||
return ['pharmacy' => 'ej', 'pharmacy_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo];
|
||||
}
|
||||
|
||||
return ['pharmacy' => 'gancao', 'recipel_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user