Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d6a38e26 | ||
|
|
64d760af1a | ||
|
|
9ba53b6145 | ||
|
|
30cf33c546 | ||
|
|
e1fe818dce | ||
|
|
31312ae975 |
@@ -22,5 +22,3 @@ unpackage/dist
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
TUICallKit
|
TUICallKit
|
||||||
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
|
|
||||||
!TUICallKit
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../wx/TUICallKit
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"requiredBackgroundModes" : [ "audio" ],
|
"requiredBackgroundModes" : [ "audio" ],
|
||||||
"plugins" : {
|
"plugins" : {
|
||||||
"WechatSI" : {
|
"WechatSI" : {
|
||||||
"version" : "0.3.9",
|
"version" : "0.3.5",
|
||||||
"provider" : "wx069ba97219f66d99"
|
"provider" : "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@
|
|||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"usingPlugins": {
|
"usingPlugins": {
|
||||||
"WechatSI": {
|
"WechatSI": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.5",
|
||||||
"provider": "wx069ba97219f66d99"
|
"provider": "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"usingPlugins": {
|
"usingPlugins": {
|
||||||
"WechatSI": {
|
"WechatSI": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.5",
|
||||||
"provider": "wx069ba97219f66d99"
|
"provider": "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"usingPlugins": {
|
"usingPlugins": {
|
||||||
"WechatSI": {
|
"WechatSI": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.5",
|
||||||
"provider": "wx069ba97219f66d99"
|
"provider": "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
## 已接入能力
|
## 已接入能力
|
||||||
|
|
||||||
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
||||||
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女。
|
- 按周一日期、性别自动分配最多 7 人的同行组。
|
||||||
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
|
|
||||||
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
||||||
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
||||||
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
|
|
||||||
## 上线前检查
|
## 上线前检查
|
||||||
|
|
||||||
- 用不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
|
- 用男女各两个测试账号进入,确认被分入对应性别组。
|
||||||
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
||||||
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
||||||
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 55 KiB |
@@ -1,577 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
|
|
||||||
// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
|
|
||||||
|
|
||||||
const ROWS = 5
|
|
||||||
const COLS = 4
|
|
||||||
const HIGH_CAP = 3
|
|
||||||
const MAX_CASCADE_WAVES = 3
|
|
||||||
const DANGER_DROP_CHANCE = 0.1
|
|
||||||
const COLUMN_GROUP_BIAS = 0.72
|
|
||||||
|
|
||||||
const FOOD_GROUPS = [
|
|
||||||
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
|
|
||||||
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
|
|
||||||
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
|
|
||||||
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
|
|
||||||
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
|
|
||||||
{ title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' },
|
|
||||||
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
|
|
||||||
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
|
|
||||||
{ title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
|
|
||||||
const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
|
|
||||||
|
|
||||||
function readNumberArg(name, fallback) {
|
|
||||||
const index = process.argv.indexOf(name)
|
|
||||||
if (index < 0) return fallback
|
|
||||||
const value = Number(process.argv[index + 1])
|
|
||||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUNS = readNumberArg('--runs', 10000)
|
|
||||||
const TURNS = readNumberArg('--turns', 80)
|
|
||||||
const BASE_SEED = readNumberArg('--seed', 20260722)
|
|
||||||
|
|
||||||
function createRng(seed) {
|
|
||||||
let state = seed >>> 0 || 1
|
|
||||||
return () => {
|
|
||||||
state ^= state << 13
|
|
||||||
state ^= state >>> 17
|
|
||||||
state ^= state << 5
|
|
||||||
return (state >>> 0) / 4294967296
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneBoard(source) {
|
|
||||||
return source.map(tile => tile ? { ...tile } : null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function tile(key) {
|
|
||||||
return { key }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdjacent(a, b) {
|
|
||||||
const ar = Math.floor(a / COLS)
|
|
||||||
const ac = a % COLS
|
|
||||||
const br = Math.floor(b / COLS)
|
|
||||||
const bc = b % COLS
|
|
||||||
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function swap(source, a, b) {
|
|
||||||
const next = cloneBoard(source)
|
|
||||||
;[next[a], next[b]] = [next[b], next[a]]
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffle(source, rng) {
|
|
||||||
const result = cloneBoard(source)
|
|
||||||
for (let i = result.length - 1; i > 0; i -= 1) {
|
|
||||||
const j = Math.floor(rng() * (i + 1))
|
|
||||||
;[result[i], result[j]] = [result[j], result[i]]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMatches(source) {
|
|
||||||
const groups = []
|
|
||||||
for (let row = 0; row < ROWS; row += 1) {
|
|
||||||
let start = 0
|
|
||||||
while (start < COLS) {
|
|
||||||
let end = start + 1
|
|
||||||
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
|
|
||||||
if (source[row * COLS + start] && end - start >= 3) {
|
|
||||||
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
|
|
||||||
}
|
|
||||||
start = end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let col = 0; col < COLS; col += 1) {
|
|
||||||
let start = 0
|
|
||||||
while (start < ROWS) {
|
|
||||||
let end = start + 1
|
|
||||||
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
|
|
||||||
if (source[start * COLS + col] && end - start >= 3) {
|
|
||||||
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
|
|
||||||
}
|
|
||||||
start = end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
function enumerateMatchingMoves(source) {
|
|
||||||
const moves = []
|
|
||||||
for (let i = 0; i < source.length; i += 1) {
|
|
||||||
const candidates = []
|
|
||||||
if (i % COLS < COLS - 1) candidates.push(i + 1)
|
|
||||||
if (i + COLS < source.length) candidates.push(i + COLS)
|
|
||||||
for (const j of candidates) {
|
|
||||||
const copy = swap(source, i, j)
|
|
||||||
const groups = findMatches(copy)
|
|
||||||
if (!groups.length) continue
|
|
||||||
const indices = new Set(groups.flatMap(group => group.indices))
|
|
||||||
const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
|
|
||||||
moves.push({ from: i, to: j, hitsHigh, groups })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return moves
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeMoves(source) {
|
|
||||||
return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
|
|
||||||
}
|
|
||||||
|
|
||||||
function wouldCreateMatchAt(source, index, key) {
|
|
||||||
const row = Math.floor(index / COLS)
|
|
||||||
const col = index % COLS
|
|
||||||
const keyAt = target => target === index ? key : source[target]?.key
|
|
||||||
let horizontal = 1
|
|
||||||
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
|
|
||||||
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
|
|
||||||
if (horizontal >= 3) return true
|
|
||||||
let vertical = 1
|
|
||||||
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
|
|
||||||
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
|
|
||||||
return vertical >= 3
|
|
||||||
}
|
|
||||||
|
|
||||||
function openingKeys(state) {
|
|
||||||
const [a, b, c, d] = state.activeSafeKeys
|
|
||||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
|
||||||
return [
|
|
||||||
a, b, c, d,
|
|
||||||
b, c, d, a,
|
|
||||||
c, d, a, danger,
|
|
||||||
d, a, danger, b,
|
|
||||||
a, b, c, d
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function forcedPlayableKeys(state) {
|
|
||||||
const [a, b, c, d] = state.activeSafeKeys
|
|
||||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
|
||||||
return [
|
|
||||||
d, a, danger, c,
|
|
||||||
d, c, b, danger,
|
|
||||||
c, a, b, a,
|
|
||||||
d, d, a, a,
|
|
||||||
b, d, c, b
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlayableBoard(state, keys = openingKeys(state)) {
|
|
||||||
let candidate = keys.map(tile)
|
|
||||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
|
||||||
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
|
||||||
candidate = shuffle(candidate, state.rng)
|
|
||||||
}
|
|
||||||
state.metrics.guaranteedFallbacks += 1
|
|
||||||
return forcedPlayableKeys(state).map(tile)
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeStablePlayableBoard(state, source) {
|
|
||||||
const tiles = source.filter(Boolean).map(item => ({ ...item }))
|
|
||||||
if (tiles.length === ROWS * COLS) {
|
|
||||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
|
||||||
const candidate = shuffle(tiles, state.rng)
|
|
||||||
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.metrics.stableFallbacks += 1
|
|
||||||
return buildPlayableBoard(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueSafeGroupTransition(state, keys) {
|
|
||||||
const target = [...keys]
|
|
||||||
const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
|
|
||||||
if (!last || !last.every((key, index) => key === target[index])) {
|
|
||||||
state.queuedSafeGroups.push(target)
|
|
||||||
}
|
|
||||||
startNextTransition(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNextTransition(state) {
|
|
||||||
if (state.transition) return false
|
|
||||||
while (state.queuedSafeGroups.length) {
|
|
||||||
const target = state.queuedSafeGroups[0]
|
|
||||||
const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
|
|
||||||
if (slot < 0) {
|
|
||||||
state.queuedSafeGroups.shift()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const from = state.activeSafeKeys[slot]
|
|
||||||
const to = target[slot]
|
|
||||||
state.activeSafeKeys[slot] = to
|
|
||||||
state.transition = { from, to, slot, age: 0 }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishTransitionIfReady(state) {
|
|
||||||
let changed = false
|
|
||||||
while (state.transition) {
|
|
||||||
const { from, to } = state.transition
|
|
||||||
const remaining = state.board.filter(item => item?.key === from).length
|
|
||||||
if (remaining > 2) break
|
|
||||||
if (remaining) {
|
|
||||||
state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
|
|
||||||
state.metrics.tailConversions += remaining
|
|
||||||
}
|
|
||||||
state.transition = null
|
|
||||||
changed = true
|
|
||||||
startNextTransition(state)
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareTransitionForReshuffle(state) {
|
|
||||||
const activeSet = new Set(state.activeSafeKeys)
|
|
||||||
const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
|
|
||||||
state.board.forEach(item => {
|
|
||||||
if (item && activeSet.has(item.key)) counts[item.key] += 1
|
|
||||||
})
|
|
||||||
let imported = 0
|
|
||||||
const next = state.board.map(item => {
|
|
||||||
if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
|
|
||||||
if (state.transition && item.key === state.transition.from) {
|
|
||||||
imported += 1
|
|
||||||
counts[state.transition.to] += 1
|
|
||||||
return { ...item, key: state.transition.to }
|
|
||||||
}
|
|
||||||
if (activeSet.has(item.key)) return { ...item }
|
|
||||||
const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
|
|
||||||
counts[key] += 1
|
|
||||||
imported += 1
|
|
||||||
return { ...item, key }
|
|
||||||
})
|
|
||||||
state.board = next
|
|
||||||
state.metrics.reshuffleImports += imported
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomFoodKey(state, snapshot, col) {
|
|
||||||
const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
|
|
||||||
if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
|
|
||||||
return FOOD_GROUPS[state.themeIndex].danger
|
|
||||||
}
|
|
||||||
if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
|
|
||||||
return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickDropKey(state, snapshot, index, col) {
|
|
||||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
||||||
const key = randomFoodKey(state, snapshot, col)
|
|
||||||
if (!wouldCreateMatchAt(snapshot, index, key)) return key
|
|
||||||
}
|
|
||||||
const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
|
|
||||||
.find(key => !wouldCreateMatchAt(snapshot, index, key))
|
|
||||||
return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseAndFill(state) {
|
|
||||||
const next = Array(ROWS * COLS).fill(null)
|
|
||||||
for (let col = 0; col < COLS; col += 1) {
|
|
||||||
const existing = []
|
|
||||||
for (let row = ROWS - 1; row >= 0; row -= 1) {
|
|
||||||
const item = state.board[row * COLS + col]
|
|
||||||
if (item) existing.push(item)
|
|
||||||
}
|
|
||||||
existing.forEach((item, offset) => {
|
|
||||||
next[(ROWS - 1 - offset) * COLS + col] = item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (let row = 0; row < ROWS; row += 1) {
|
|
||||||
for (let col = 0; col < COLS; col += 1) {
|
|
||||||
const index = row * COLS + col
|
|
||||||
if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.board = next
|
|
||||||
}
|
|
||||||
|
|
||||||
function reshuffle(state) {
|
|
||||||
prepareTransitionForReshuffle(state)
|
|
||||||
let candidate = null
|
|
||||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
|
||||||
const shuffled = shuffle(state.board, state.rng)
|
|
||||||
if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
|
|
||||||
candidate = shuffled
|
|
||||||
break
|
|
||||||
}
|
|
||||||
state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
|
|
||||||
finishTransitionIfReady(state)
|
|
||||||
state.metrics.reshuffles += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceTheme(state) {
|
|
||||||
const previous = FOOD_GROUPS[state.themeIndex]
|
|
||||||
state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
|
|
||||||
const next = FOOD_GROUPS[state.themeIndex]
|
|
||||||
state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
|
|
||||||
queueSafeGroupTransition(state, next.safe)
|
|
||||||
state.metrics.themeChanges += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMatches(state, initialGroups) {
|
|
||||||
let groups = initialGroups
|
|
||||||
let wave = 0
|
|
||||||
let camelMatch = 0
|
|
||||||
while (groups.length && wave < MAX_CASCADE_WAVES) {
|
|
||||||
wave += 1
|
|
||||||
const remove = new Set(groups.flatMap(group => group.indices))
|
|
||||||
for (const group of groups) {
|
|
||||||
if (group.indices.every(index => state.board[index]?.key === 'camel')) {
|
|
||||||
camelMatch = Math.max(camelMatch, group.indices.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remove.forEach(index => { state.board[index] = null })
|
|
||||||
collapseAndFill(state)
|
|
||||||
finishTransitionIfReady(state)
|
|
||||||
groups = findMatches(state.board)
|
|
||||||
const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
|
|
||||||
if (autoHigh) {
|
|
||||||
state.metrics.automaticHighAvoided += 1
|
|
||||||
state.board = makeStablePlayableBoard(state, state.board)
|
|
||||||
groups = []
|
|
||||||
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
|
|
||||||
state.metrics.cascadeCaps += 1
|
|
||||||
state.board = makeStablePlayableBoard(state, state.board)
|
|
||||||
groups = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
|
|
||||||
if (camelMatch >= 3) advanceTheme(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCamelTool(state) {
|
|
||||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
|
||||||
const targets = state.board
|
|
||||||
.map((item, index) => item?.key === danger ? index : -1)
|
|
||||||
.filter(index => index >= 0)
|
|
||||||
if (!targets.length) return false
|
|
||||||
const index = targets[Math.floor(state.rng() * targets.length)]
|
|
||||||
state.board[index] = tile('camel')
|
|
||||||
state.metrics.toolUses += 1
|
|
||||||
const groups = findMatches(state.board)
|
|
||||||
if (groups.length) resolveMatches(state, groups)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordFailure(state, type, detail = '') {
|
|
||||||
state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
|
|
||||||
if (state.metrics.samples.length < 20) {
|
|
||||||
state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateState(state, expectStable = true) {
|
|
||||||
if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
|
|
||||||
recordFailure(state, 'invalid_board_size')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const currentDanger = FOOD_GROUPS[state.themeIndex].danger
|
|
||||||
const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
|
|
||||||
if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
|
|
||||||
if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
|
|
||||||
|
|
||||||
const allowed = new Set([...state.activeSafeKeys, 'camel'])
|
|
||||||
if (state.transition) allowed.add(state.transition.from)
|
|
||||||
const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
|
|
||||||
if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
|
|
||||||
|
|
||||||
const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
|
|
||||||
if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
|
|
||||||
if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
|
|
||||||
if (state.transition) {
|
|
||||||
const remaining = state.board.filter(item => item.key === state.transition.from).length
|
|
||||||
if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
|
|
||||||
}
|
|
||||||
if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMetrics() {
|
|
||||||
return {
|
|
||||||
runs: RUNS,
|
|
||||||
turnsPerRun: TURNS,
|
|
||||||
totalTurns: 0,
|
|
||||||
validMoves: 0,
|
|
||||||
reshuffles: 0,
|
|
||||||
reshuffleImports: 0,
|
|
||||||
guaranteedFallbacks: 0,
|
|
||||||
stableFallbacks: 0,
|
|
||||||
themeChanges: 0,
|
|
||||||
toolUses: 0,
|
|
||||||
tailConversions: 0,
|
|
||||||
automaticHighAvoided: 0,
|
|
||||||
cascadeCaps: 0,
|
|
||||||
maxCascade: 0,
|
|
||||||
unsafeHighMoveProbes: 0,
|
|
||||||
maxTransitionAge: 0,
|
|
||||||
maxQueuedThemes: 0,
|
|
||||||
transitionDrainTurns: 0,
|
|
||||||
unfinishedTransitions: 0,
|
|
||||||
failures: {},
|
|
||||||
samples: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runTargetedTailTests(metrics) {
|
|
||||||
for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
|
|
||||||
const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
|
|
||||||
const previousGroup = FOOD_GROUPS[groupIndex]
|
|
||||||
const nextGroup = FOOD_GROUPS[nextIndex]
|
|
||||||
// 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物,
|
|
||||||
// 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。
|
|
||||||
const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key))
|
|
||||||
if (slot < 0) continue
|
|
||||||
for (const remaining of [0, 1, 2]) {
|
|
||||||
const state = {
|
|
||||||
run: -1,
|
|
||||||
turn: remaining,
|
|
||||||
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
|
|
||||||
metrics,
|
|
||||||
themeIndex: nextIndex,
|
|
||||||
activeSafeKeys: [...nextGroup.safe],
|
|
||||||
transition: {
|
|
||||||
from: previousGroup.safe[slot],
|
|
||||||
to: nextGroup.safe[slot],
|
|
||||||
slot,
|
|
||||||
age: 0
|
|
||||||
},
|
|
||||||
queuedSafeGroups: [[...nextGroup.safe]],
|
|
||||||
board: []
|
|
||||||
}
|
|
||||||
const fill = nextGroup.safe
|
|
||||||
state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
|
|
||||||
for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
|
|
||||||
finishTransitionIfReady(state)
|
|
||||||
if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) {
|
|
||||||
recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = createMetrics()
|
|
||||||
runTargetedTailTests(metrics)
|
|
||||||
|
|
||||||
for (let run = 0; run < RUNS; run += 1) {
|
|
||||||
const state = {
|
|
||||||
run,
|
|
||||||
turn: 0,
|
|
||||||
rng: createRng(BASE_SEED + run * 2654435761),
|
|
||||||
metrics,
|
|
||||||
themeIndex: run % FOOD_GROUPS.length,
|
|
||||||
activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
|
|
||||||
transition: null,
|
|
||||||
queuedSafeGroups: [],
|
|
||||||
board: []
|
|
||||||
}
|
|
||||||
state.board = buildPlayableBoard(state)
|
|
||||||
validateState(state)
|
|
||||||
|
|
||||||
let toolsUsed = 0
|
|
||||||
for (let turn = 0; turn < TURNS; turn += 1) {
|
|
||||||
state.turn = turn
|
|
||||||
metrics.totalTurns += 1
|
|
||||||
if (state.transition) {
|
|
||||||
state.transition.age += 1
|
|
||||||
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
|
||||||
}
|
|
||||||
metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
|
|
||||||
|
|
||||||
// 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
|
|
||||||
if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
|
|
||||||
toolsUsed += 1
|
|
||||||
finishTransitionIfReady(state)
|
|
||||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
|
||||||
}
|
|
||||||
|
|
||||||
let moves = safeMoves(state.board)
|
|
||||||
if (!moves.length) {
|
|
||||||
reshuffle(state)
|
|
||||||
moves = safeMoves(state.board)
|
|
||||||
}
|
|
||||||
if (!moves.length) {
|
|
||||||
recordFailure(state, 'dead_board_after_reshuffle')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
|
|
||||||
const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
|
|
||||||
if (unsafe) {
|
|
||||||
const probe = swap(state.board, unsafe.from, unsafe.to)
|
|
||||||
const direct = findMatches(probe)
|
|
||||||
if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
|
|
||||||
recordFailure(state, 'unsafe_high_probe_missed')
|
|
||||||
}
|
|
||||||
metrics.unsafeHighMoveProbes += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const move = moves[Math.floor(state.rng() * moves.length)]
|
|
||||||
state.board = swap(state.board, move.from, move.to)
|
|
||||||
const groups = findMatches(state.board)
|
|
||||||
if (!groups.length) {
|
|
||||||
recordFailure(state, 'safe_move_without_match')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
|
|
||||||
recordFailure(state, 'safe_move_hit_high')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
resolveMatches(state, groups)
|
|
||||||
metrics.validMoves += 1
|
|
||||||
|
|
||||||
// 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
|
|
||||||
if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
|
|
||||||
finishTransitionIfReady(state)
|
|
||||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
|
||||||
if (!safeMoves(state.board).length) reshuffle(state)
|
|
||||||
validateState(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
|
|
||||||
let drainTurn = 0
|
|
||||||
while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
|
|
||||||
state.turn = TURNS + drainTurn
|
|
||||||
if (state.transition) {
|
|
||||||
state.transition.age += 1
|
|
||||||
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
|
||||||
}
|
|
||||||
let moves = safeMoves(state.board)
|
|
||||||
if (!moves.length) {
|
|
||||||
reshuffle(state)
|
|
||||||
moves = safeMoves(state.board)
|
|
||||||
}
|
|
||||||
if (!moves.length) {
|
|
||||||
recordFailure(state, 'dead_board_while_draining')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const move = moves[Math.floor(state.rng() * moves.length)]
|
|
||||||
state.board = swap(state.board, move.from, move.to)
|
|
||||||
resolveMatches(state, findMatches(state.board))
|
|
||||||
metrics.validMoves += 1
|
|
||||||
finishTransitionIfReady(state)
|
|
||||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
|
||||||
if (!safeMoves(state.board).length) reshuffle(state)
|
|
||||||
validateState(state)
|
|
||||||
drainTurn += 1
|
|
||||||
}
|
|
||||||
metrics.transitionDrainTurns += drainTurn
|
|
||||||
if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
|
|
||||||
const result = {
|
|
||||||
ok: failureCount === 0,
|
|
||||||
...metrics,
|
|
||||||
failureCount
|
|
||||||
}
|
|
||||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
||||||
if (!result.ok) process.exitCode = 1
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
import type { Plugin } from 'vite'
|
|
||||||
|
|
||||||
const FOOD_ICON_FILES = [
|
|
||||||
'tofu.jpg',
|
|
||||||
'spinach.jpg',
|
|
||||||
'carrot.jpg',
|
|
||||||
'fish.jpg',
|
|
||||||
'redBeanCongee.jpg',
|
|
||||||
'milkTea.jpg'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep endless-game food icons inside the tongji subpackage.
|
|
||||||
* Importing them from Vue would make Vite emit them into the main-package assets folder.
|
|
||||||
*/
|
|
||||||
export function tongjiFoodAssetsPlugin(): Plugin {
|
|
||||||
return {
|
|
||||||
name: 'tongji-food-assets',
|
|
||||||
apply: 'build',
|
|
||||||
generateBundle() {
|
|
||||||
FOOD_ICON_FILES.forEach((fileName) => {
|
|
||||||
this.emitFile({
|
|
||||||
type: 'asset',
|
|
||||||
fileName: `tongji/endless-game/assets/food/${fileName}`,
|
|
||||||
source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,6 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
|||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncStatus = ref('idle')
|
const syncStatus = ref('idle')
|
||||||
const lastError = ref('')
|
|
||||||
const leaderboard = ref({ ...EMPTY_BOARD })
|
const leaderboard = ref({ ...EMPTY_BOARD })
|
||||||
const confirmedSessionLearned = ref(0)
|
const confirmedSessionLearned = ref(0)
|
||||||
|
|
||||||
@@ -74,19 +73,17 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
|||||||
}
|
}
|
||||||
connected.value = true
|
connected.value = true
|
||||||
syncStatus.value = 'synced'
|
syncStatus.value = 'synced'
|
||||||
lastError.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLeaderboard() {
|
async function refreshLeaderboard() {
|
||||||
const res = await api({
|
const res = await api({
|
||||||
url: '/api/tcm/gameWeeklyLeaderboard',
|
url: '/api/tcm/gameWeeklyLeaderboard',
|
||||||
method: 'GET',
|
method: 'GET'
|
||||||
data: { session_key: sessionKey }
|
|
||||||
})
|
})
|
||||||
if (res?.code !== 1 || !res.data) {
|
if (res?.code !== 1 || !res.data) {
|
||||||
throw new Error(res?.msg || '同行榜加载失败')
|
throw new Error(res?.msg || '同行榜加载失败')
|
||||||
}
|
}
|
||||||
applyLeaderboard(res.data, sessionKey)
|
applyLeaderboard(res.data)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +112,9 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
|||||||
await refreshLeaderboard()
|
await refreshLeaderboard()
|
||||||
if (queuedPayloads.length) flushProgress()
|
if (queuedPayloads.length) flushProgress()
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (_) {
|
||||||
connected.value = false
|
connected.value = false
|
||||||
syncStatus.value = 'offline'
|
syncStatus.value = 'offline'
|
||||||
lastError.value = String(error?.message || '平台连接失败,请点击重试')
|
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -128,21 +124,14 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
|||||||
return connectPromise
|
return connectPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginSession(existingSessionKey = '') {
|
function beginSession() {
|
||||||
if (syncTimer) clearTimeout(syncTimer)
|
if (syncTimer) clearTimeout(syncTimer)
|
||||||
const restoredKey = String(existingSessionKey || '').trim()
|
sessionKey = createSessionKey()
|
||||||
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
|
|
||||||
? restoredKey
|
|
||||||
: createSessionKey()
|
|
||||||
confirmedSessionLearned.value = 0
|
confirmedSessionLearned.value = 0
|
||||||
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
||||||
return sessionKey
|
return sessionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionKey() {
|
|
||||||
return sessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueProgress(learnedCount, score, ended = false) {
|
function queueProgress(learnedCount, score, ended = false) {
|
||||||
const nextPayload = {
|
const nextPayload = {
|
||||||
session_key: sessionKey,
|
session_key: sessionKey,
|
||||||
@@ -202,10 +191,9 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
|||||||
|| Number(item.ended) > Number(payload.ended)
|
|| Number(item.ended) > Number(payload.ended)
|
||||||
))
|
))
|
||||||
persistPendingPayloads()
|
persistPendingPayloads()
|
||||||
} catch (error) {
|
} catch (_) {
|
||||||
connected.value = false
|
connected.value = false
|
||||||
syncStatus.value = 'offline'
|
syncStatus.value = 'offline'
|
||||||
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
|
|
||||||
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
||||||
persistPendingPayloads()
|
persistPendingPayloads()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -249,12 +237,10 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
|||||||
connected,
|
connected,
|
||||||
loading,
|
loading,
|
||||||
syncStatus,
|
syncStatus,
|
||||||
lastError,
|
|
||||||
leaderboard,
|
leaderboard,
|
||||||
confirmedSessionLearned,
|
confirmedSessionLearned,
|
||||||
connect,
|
connect,
|
||||||
beginSession,
|
beginSession,
|
||||||
getSessionKey,
|
|
||||||
queueProgress,
|
queueProgress,
|
||||||
flushProgress,
|
flushProgress,
|
||||||
syncAndRefresh,
|
syncAndRefresh,
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# 识糖小课堂 3D 食物图标候选稿 v1
|
|
||||||
|
|
||||||
- 共 42 个食物图标,文件名与 `FOODS` 中的英文键名一致。
|
|
||||||
- 单图尺寸:320 × 320,JPEG。
|
|
||||||
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
|
|
||||||
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
|
|
||||||
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
|
|
||||||
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
|
|
||||||
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
|
|
||||||
|
|
||||||
目录说明:
|
|
||||||
|
|
||||||
- `icons/`:可直接用于游戏的独立图标。
|
|
||||||
- `sheets/`:11 张分组预览母版,方便整体审美检查。
|
|
||||||
- `manifest.json`:食物中文名、代码键名与文件路径清单。
|
|
||||||
|
|
||||||
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
|
|
||||||
|
Before Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"style": "chinese-rounded-3d-elderly-friendly",
|
|
||||||
"iconSize": {
|
|
||||||
"width": 320,
|
|
||||||
"height": 320,
|
|
||||||
"format": "jpg"
|
|
||||||
},
|
|
||||||
"items": [
|
|
||||||
{ "key": "corn", "name": "玉米", "file": "icons/corn.jpg" },
|
|
||||||
{ "key": "egg", "name": "鸡蛋", "file": "icons/egg.jpg" },
|
|
||||||
{ "key": "tomato", "name": "番茄", "file": "icons/tomato.jpg" },
|
|
||||||
{ "key": "potato", "name": "红薯", "file": "icons/potato.jpg" },
|
|
||||||
{ "key": "cucumber", "name": "黄瓜", "file": "icons/cucumber.jpg" },
|
|
||||||
{ "key": "lettuce", "name": "生菜", "file": "icons/lettuce.jpg" },
|
|
||||||
{ "key": "grainMantou", "name": "杂粮馒头", "file": "icons/grainMantou.jpg" },
|
|
||||||
{ "key": "wonton", "name": "馄饨", "file": "icons/wonton.jpg" },
|
|
||||||
{ "key": "celery", "name": "芹菜", "file": "icons/celery.jpg" },
|
|
||||||
{ "key": "mushroom", "name": "香菇", "file": "icons/mushroom.jpg" },
|
|
||||||
{ "key": "chicken", "name": "鸡肉", "file": "icons/chicken.jpg" },
|
|
||||||
{ "key": "taro", "name": "芋头", "file": "icons/taro.jpg" },
|
|
||||||
{ "key": "broccoli", "name": "西兰花", "file": "icons/broccoli.jpg" },
|
|
||||||
{ "key": "milk", "name": "牛奶", "file": "icons/milk.jpg" },
|
|
||||||
{ "key": "brownRice", "name": "糙米饭", "file": "icons/brownRice.jpg" },
|
|
||||||
{ "key": "apple", "name": "苹果", "file": "icons/apple.jpg" },
|
|
||||||
{ "key": "strawberry", "name": "草莓", "file": "icons/strawberry.jpg" },
|
|
||||||
{ "key": "banana", "name": "香蕉", "file": "icons/banana.jpg" },
|
|
||||||
{ "key": "mango", "name": "芒果", "file": "icons/mango.jpg" },
|
|
||||||
{ "key": "tea", "name": "红茶", "file": "icons/tea.jpg" },
|
|
||||||
{ "key": "blackCoffee", "name": "黑咖啡", "file": "icons/blackCoffee.jpg" },
|
|
||||||
{ "key": "coffee", "name": "咖啡", "file": "icons/coffee.jpg" },
|
|
||||||
{ "key": "pineapple", "name": "菠萝", "file": "icons/pineapple.jpg" },
|
|
||||||
{ "key": "pepper", "name": "青椒", "file": "icons/pepper.jpg" },
|
|
||||||
{ "key": "greenBean", "name": "四季豆", "file": "icons/greenBean.jpg" },
|
|
||||||
{ "key": "shrimp", "name": "虾仁", "file": "icons/shrimp.jpg" },
|
|
||||||
{ "key": "udon", "name": "乌冬面", "file": "icons/udon.jpg" },
|
|
||||||
{ "key": "onion", "name": "洋葱", "file": "icons/onion.jpg" },
|
|
||||||
{ "key": "congee", "name": "白米粥", "file": "icons/congee.jpg" },
|
|
||||||
{ "key": "eightCongee", "name": "八宝粥", "file": "icons/eightCongee.jpg" },
|
|
||||||
{ "key": "centuryCongee", "name": "皮蛋瘦肉粥", "file": "icons/centuryCongee.jpg" },
|
|
||||||
{ "key": "milletCongee", "name": "小米粥", "file": "icons/milletCongee.jpg" },
|
|
||||||
{ "key": "orangeJuice", "name": "柳橙汁", "file": "icons/orangeJuice.jpg" },
|
|
||||||
{ "key": "soda", "name": "含糖汽水", "file": "icons/soda.jpg" },
|
|
||||||
{ "key": "riceNoodleSoup", "name": "汤粉", "file": "icons/riceNoodleSoup.jpg" },
|
|
||||||
{ "key": "noodleSoup", "name": "汤面", "file": "icons/noodleSoup.jpg" },
|
|
||||||
{ "key": "milkTea", "name": "含糖奶茶", "file": "icons/milkTea.jpg" },
|
|
||||||
{ "key": "tofu", "name": "豆腐", "file": "icons/tofu.jpg" },
|
|
||||||
{ "key": "spinach", "name": "菠菜", "file": "icons/spinach.jpg" },
|
|
||||||
{ "key": "carrot", "name": "胡萝卜", "file": "icons/carrot.jpg" },
|
|
||||||
{ "key": "fish", "name": "鱼", "file": "icons/fish.jpg" },
|
|
||||||
{ "key": "redBeanCongee", "name": "软烂红豆粥", "file": "icons/redBeanCongee.jpg" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 110 KiB |
@@ -46,44 +46,7 @@
|
|||||||
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
|
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
|
||||||
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
||||||
|
|
||||||
.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||||
.eg-title-wrap > .eg-title,
|
|
||||||
.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
|
|
||||||
.eg-title-wrap.is-noticing > .eg-title,
|
|
||||||
.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
|
|
||||||
.eg-nav-notice {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5rpx 8rpx;
|
|
||||||
border: 1rpx solid rgba(21,94,75,.09);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
color: rgba(38,86,72,.82);
|
|
||||||
background: rgba(255,255,255,.58);
|
|
||||||
box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
|
|
||||||
text-align: center;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
animation: egNavNotice 1.05s ease-out both;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.eg-nav-notice-title,
|
|
||||||
.eg-nav-notice-detail {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 100%;
|
|
||||||
line-height: 1.15;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
|
|
||||||
.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
|
|
||||||
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
|
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
||||||
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
||||||
@@ -154,20 +117,12 @@
|
|||||||
background: rgba(255,255,255,.92);
|
background: rgba(255,255,255,.92);
|
||||||
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
||||||
}
|
}
|
||||||
.eg-board-shell.is-flowing {
|
|
||||||
border-color: rgba(245, 158, 11, .72);
|
|
||||||
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
|
|
||||||
animation: egFlowShell 1.05s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
||||||
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
||||||
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
||||||
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||||
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
||||||
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
|
|
||||||
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
|
|
||||||
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
|
|
||||||
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
||||||
.eg-risk-dots { display: flex; gap: 5rpx; }
|
.eg-risk-dots { display: flex; gap: 5rpx; }
|
||||||
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
||||||
@@ -212,15 +167,7 @@
|
|||||||
.eg-cell.is-level-high { background: #fff0e4; }
|
.eg-cell.is-level-high { background: #fff0e4; }
|
||||||
.eg-cell.is-milk { background: #edf7ff; }
|
.eg-cell.is-milk { background: #edf7ff; }
|
||||||
.eg-cell.is-high { border-color: #ef7b45; }
|
.eg-cell.is-high { border-color: #ef7b45; }
|
||||||
.eg-cell.is-villain {
|
|
||||||
background: radial-gradient(circle at 50% 38%, #fff7e8 0%, #ffe9d8 70%, #ffddc6 100%);
|
|
||||||
box-shadow: inset 0 -5rpx 0 rgba(154,57,25,.08), 0 6rpx 15rpx rgba(191,72,31,.16);
|
|
||||||
}
|
|
||||||
.eg-cell.is-villain .eg-food-image { animation: egVillainPeek 2.2s ease-in-out infinite; }
|
|
||||||
.eg-cell.is-villain.is-clearing .eg-food-image,
|
|
||||||
.eg-cell.is-villain.is-dragging .eg-food-image { animation: none; }
|
|
||||||
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
||||||
.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
|
|
||||||
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
||||||
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
||||||
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
||||||
@@ -390,9 +337,6 @@
|
|||||||
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
||||||
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
||||||
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
||||||
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
|
|
||||||
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
|
|
||||||
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
|
|
||||||
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
.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-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 { 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; }
|
||||||
@@ -406,9 +350,6 @@
|
|||||||
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
||||||
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
||||||
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
|
|
||||||
.eg-weekly-connection > text:first-child { flex: 1; }
|
|
||||||
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
|
|
||||||
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
||||||
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
||||||
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
||||||
@@ -476,13 +417,6 @@
|
|||||||
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes egVillainPeek {
|
|
||||||
0%, 72%, 100% { transform: translateY(0) rotate(0); filter: saturate(1.08) contrast(1.05); }
|
|
||||||
80% { transform: translateY(-3rpx) rotate(-1.5deg); filter: saturate(1.15) contrast(1.08); }
|
|
||||||
88% { transform: translateY(0) rotate(1.5deg); }
|
|
||||||
95% { transform: translateY(-2rpx) rotate(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes egTaskRewardFlight {
|
@keyframes egTaskRewardFlight {
|
||||||
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
||||||
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
||||||
@@ -490,13 +424,6 @@
|
|||||||
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes egNavNotice {
|
|
||||||
0% { opacity: 0; transform: translate(-50%, -42%); }
|
|
||||||
12% { opacity: .9; transform: translate(-50%, -50%); }
|
|
||||||
42% { opacity: .82; transform: translate(-50%, -50%); }
|
|
||||||
100% { opacity: 0; transform: translate(-50%, -58%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes egRewardGlow {
|
@keyframes egRewardGlow {
|
||||||
from { opacity: .4; transform: scale(.86); }
|
from { opacity: .4; transform: scale(.86); }
|
||||||
to { opacity: .9; transform: scale(1.12); }
|
to { opacity: .9; transform: scale(1.12); }
|
||||||
@@ -626,16 +553,6 @@
|
|||||||
100% { opacity: 1; transform: scale(1); }
|
100% { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes egFlowShell {
|
|
||||||
from { filter: saturate(1); }
|
|
||||||
to { filter: saturate(1.08); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes egFlowBadge {
|
|
||||||
from { transform: scale(.98); filter: brightness(.96); }
|
|
||||||
to { transform: scale(1.035); filter: brightness(1.12); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes egStageIn {
|
@keyframes egStageIn {
|
||||||
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
||||||
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import uni from '@dcloudio/vite-plugin-uni'
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
import Optimization from '@uni-ku/bundle-optimizer'
|
import Optimization from '@uni-ku/bundle-optimizer'
|
||||||
import { tongjiFoodAssetsPlugin } from './tongji/endless-game/build/tongjiFoodAssetsPlugin'
|
|
||||||
|
|
||||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||||
@@ -17,7 +16,6 @@ export default defineConfig({
|
|||||||
dts: false,
|
dts: false,
|
||||||
logger: false,
|
logger: false,
|
||||||
}),
|
}),
|
||||||
tongjiFoodAssetsPlugin(),
|
|
||||||
],
|
],
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -528,6 +528,21 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
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,不提交订单) */
|
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '—' }}</span>
|
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '—' }}</span>
|
||||||
<el-select
|
<el-select
|
||||||
v-else
|
v-else
|
||||||
:model-value="modelValue"
|
:model-value="selectedMedicineId"
|
||||||
class="medicine-name-select w-full"
|
class="medicine-name-select w-full"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
@@ -14,7 +14,14 @@
|
|||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
@update:model-value="onUpdate"
|
@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>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -24,6 +31,7 @@ import { medicineLists } from '@/api/medicine'
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
|
medicineId?: number | null
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ disabled: false }
|
{ disabled: false }
|
||||||
@@ -31,18 +39,30 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string]
|
'update:modelValue': [value: string]
|
||||||
|
'update:medicineId': [value: number | undefined]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
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() {
|
function ensureCurrentInOptions() {
|
||||||
const v = (props.modelValue || '').trim()
|
const v = (props.modelValue || '').trim()
|
||||||
if (!v) {
|
if (!v) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!options.value.some((o) => o.name === v)) {
|
const id = Number(props.medicineId)
|
||||||
options.value = [{ id: 0, name: v }, ...options.value]
|
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,
|
page_size: 100,
|
||||||
status: 1
|
status: 1
|
||||||
})
|
})
|
||||||
options.value = res.lists || []
|
options.value = (res.lists || []) as MedicineOption[]
|
||||||
ensureCurrentInOptions()
|
ensureCurrentInOptions()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -72,13 +92,19 @@ const onVisibleChange = (open: boolean) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onUpdate = (val: string) => {
|
const onUpdate = (val: number | string) => {
|
||||||
emit('update:modelValue', val || '')
|
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => [props.modelValue, props.medicineId],
|
||||||
() => {
|
() => {
|
||||||
|
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
|
||||||
|
emit('update:medicineId', undefined)
|
||||||
|
}
|
||||||
ensureCurrentInOptions()
|
ensureCurrentInOptions()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -93,4 +119,23 @@ watch(
|
|||||||
.medicine-name-select.w-full {
|
.medicine-name-select.w-full {
|
||||||
width: 100%;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -139,7 +139,12 @@
|
|||||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||||
>
|
>
|
||||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
<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
|
<div
|
||||||
v-if="isDuplicateHerbName(row.index)"
|
v-if="isDuplicateHerbName(row.index)"
|
||||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
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) }"
|
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||||
>
|
>
|
||||||
<div class="herb-editor-card__title">辅方</div>
|
<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
|
<div
|
||||||
v-if="isDuplicateHerbName(row.index)"
|
v-if="isDuplicateHerbName(row.index)"
|
||||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
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 = '主方' | '辅方'
|
type FormulaType = '主方' | '辅方'
|
||||||
|
|
||||||
interface Herb {
|
interface Herb {
|
||||||
|
medicine_id?: number
|
||||||
name: string
|
name: string
|
||||||
dosage: number
|
dosage: number
|
||||||
formula_type?: FormulaType
|
formula_type?: FormulaType
|
||||||
@@ -1071,6 +1082,10 @@ function normalizeHerbRow(raw: any): Herb {
|
|||||||
dosage: Number(raw?.dosage) || 0,
|
dosage: Number(raw?.dosage) || 0,
|
||||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
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) {
|
if (raw?.locked === true || raw?.locked === 1) {
|
||||||
row.locked = true
|
row.locked = true
|
||||||
}
|
}
|
||||||
@@ -1578,8 +1593,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
|
|||||||
return herbs
|
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()
|
const q = rawName.trim()
|
||||||
if (!q) return null
|
if (!q) return null
|
||||||
try {
|
try {
|
||||||
@@ -1590,8 +1605,10 @@ async function resolveHerbNameFromLibrary(rawName: string): Promise<string | nul
|
|||||||
status: 1
|
status: 1
|
||||||
})
|
})
|
||||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||||
return exact ? exact.name.trim() : null
|
return exact.length === 1
|
||||||
|
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||||
|
: null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1612,12 +1629,12 @@ async function handlePasteRecipeImport() {
|
|||||||
const resolved: Herb[] = []
|
const resolved: Herb[] = []
|
||||||
const skippedNames: string[] = []
|
const skippedNames: string[] = []
|
||||||
for (const row of parsed) {
|
for (const row of parsed) {
|
||||||
const name = await resolveHerbNameFromLibrary(row.name)
|
const medicine = await resolveHerbFromLibrary(row.name)
|
||||||
if (!name) {
|
if (!medicine) {
|
||||||
skippedNames.push(row.name.trim())
|
skippedNames.push(row.name.trim())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||||
}
|
}
|
||||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||||
if (resolved.length === 0) {
|
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
|
round
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
>甘草 {{ 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" />
|
<slot v-if="detailData" name="header-extra" :detail="detailData" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,45 @@ export const TCM_ASSISTANT_ROLE_ID = 2
|
|||||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
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) {
|
export function formatTime(v: unknown) {
|
||||||
if (v === null || v === undefined || v === '') return '—'
|
if (v === null || v === undefined || v === '') return '—'
|
||||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||||
@@ -134,6 +173,8 @@ export function logActionText(act: string) {
|
|||||||
revoke_rx_audit: '撤回处方审核',
|
revoke_rx_audit: '撤回处方审核',
|
||||||
revoke_pay_audit: '撤回支付审核',
|
revoke_pay_audit: '撤回支付审核',
|
||||||
gancao_submit: '甘草下单',
|
gancao_submit: '甘草下单',
|
||||||
|
ej_pharmacy_submit: '洛阳药房下单',
|
||||||
|
ej_pharmacy_callback: '洛阳药房状态',
|
||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
patch_rx_usage: '服用参数',
|
patch_rx_usage: '服用参数',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
|
|||||||
@@ -696,7 +696,12 @@
|
|||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
@@ -732,7 +737,12 @@
|
|||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
@@ -1294,6 +1304,14 @@
|
|||||||
|
|
||||||
<div v-show="createOrderStep === 1" class="create-order-step-panel">
|
<div v-show="createOrderStep === 1" class="create-order-step-panel">
|
||||||
<el-row :gutter="20">
|
<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-col :xs="24" :sm="8">
|
||||||
<el-form-item label="复诊">
|
<el-form-item label="复诊">
|
||||||
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
|
<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 { usePaging } from '@/hooks/usePaging'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
import MedicineNameSelect from '@/components/medicine-name-select/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'))
|
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
type FormulaType = '主方' | '辅方'
|
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 = {
|
type AuxUsageForm = {
|
||||||
dosage_amount?: number
|
dosage_amount?: number
|
||||||
@@ -1814,6 +1833,10 @@ function normalizeHerbRow(raw: any): HerbRow {
|
|||||||
dosage: Number(raw?.dosage) || 0,
|
dosage: Number(raw?.dosage) || 0,
|
||||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
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) {
|
if (raw?.locked === true || raw?.locked === 1) {
|
||||||
row.locked = true
|
row.locked = true
|
||||||
}
|
}
|
||||||
@@ -2006,6 +2029,7 @@ const createOrderForm = reactive({
|
|||||||
service_package: [] as string[],
|
service_package: [] as string[],
|
||||||
express_company: 'auto',
|
express_company: 'auto',
|
||||||
tracking_number: '',
|
tracking_number: '',
|
||||||
|
ship_mode: 'gancao' as 'gancao' | 'direct',
|
||||||
fee_type: 3,
|
fee_type: 3,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
internal_cost: undefined as number | undefined,
|
internal_cost: undefined as number | undefined,
|
||||||
@@ -2014,6 +2038,10 @@ const createOrderForm = reactive({
|
|||||||
pay_order_ids: [] as number[]
|
pay_order_ids: [] as number[]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 只有具备「设置发货类型」权限的账号才可在创建业务订单时选择洛阳药房;
|
||||||
|
// 无权限账号保持历史默认逻辑,固定走甘草药房。
|
||||||
|
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
|
||||||
|
|
||||||
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
|
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
|
||||||
const regionOptions = ref([])
|
const regionOptions = ref([])
|
||||||
|
|
||||||
@@ -2252,6 +2280,7 @@ function resetCreateOrderForm() {
|
|||||||
createOrderForm.service_package = []
|
createOrderForm.service_package = []
|
||||||
createOrderForm.express_company = 'auto'
|
createOrderForm.express_company = 'auto'
|
||||||
createOrderForm.tracking_number = ''
|
createOrderForm.tracking_number = ''
|
||||||
|
createOrderForm.ship_mode = 'gancao'
|
||||||
createOrderForm.fee_type = 3
|
createOrderForm.fee_type = 3
|
||||||
createOrderForm.amount = 0
|
createOrderForm.amount = 0
|
||||||
createOrderForm.internal_cost = undefined
|
createOrderForm.internal_cost = undefined
|
||||||
@@ -2425,6 +2454,7 @@ async function submitCreateOrderFromPrescription() {
|
|||||||
: '',
|
: '',
|
||||||
express_company: createOrderForm.express_company || 'auto',
|
express_company: createOrderForm.express_company || 'auto',
|
||||||
tracking_number: createOrderForm.tracking_number || '',
|
tracking_number: createOrderForm.tracking_number || '',
|
||||||
|
ship_mode: createOrderForm.ship_mode,
|
||||||
fee_type: createOrderForm.fee_type,
|
fee_type: createOrderForm.fee_type,
|
||||||
amount: createOrderForm.amount,
|
amount: createOrderForm.amount,
|
||||||
remark_extra: createOrderForm.remark_extra || '',
|
remark_extra: createOrderForm.remark_extra || '',
|
||||||
@@ -3601,7 +3631,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
|
|||||||
return herbs
|
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()
|
const q = rawName.trim()
|
||||||
if (!q) return null
|
if (!q) return null
|
||||||
try {
|
try {
|
||||||
@@ -3612,8 +3642,10 @@ async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string
|
|||||||
status: 1
|
status: 1
|
||||||
})
|
})
|
||||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||||
return exact ? exact.name.trim() : null
|
return exact.length === 1
|
||||||
|
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||||
|
: null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -3634,12 +3666,12 @@ async function handlePasteRecipeImport() {
|
|||||||
const resolved: HerbRow[] = []
|
const resolved: HerbRow[] = []
|
||||||
const skippedNames: string[] = []
|
const skippedNames: string[] = []
|
||||||
for (const row of parsed) {
|
for (const row of parsed) {
|
||||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
const medicine = await resolvePasteHerbFromLibrary(row.name)
|
||||||
if (!name) {
|
if (!medicine) {
|
||||||
skippedNames.push(row.name.trim())
|
skippedNames.push(row.name.trim())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||||
}
|
}
|
||||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||||
if (resolved.length === 0) {
|
if (resolved.length === 0) {
|
||||||
|
|||||||
@@ -157,7 +157,11 @@
|
|||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剂量(克)" min-width="150">
|
<el-table-column label="剂量(克)" min-width="150">
|
||||||
@@ -257,7 +261,7 @@ const editForm = reactive({
|
|||||||
id: 0,
|
id: 0,
|
||||||
prescription_name: '',
|
prescription_name: '',
|
||||||
formula_type: '主方',
|
formula_type: '主方',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||||
is_public: 0,
|
is_public: 0,
|
||||||
disable_edit: 0
|
disable_edit: 0
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -373,9 +373,9 @@
|
|||||||
<el-tag
|
<el-tag
|
||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
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>
|
</el-tag>
|
||||||
<span class="text-gray-400">#{{ row.id }}</span>
|
<span class="text-gray-400">#{{ row.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,13 +594,13 @@
|
|||||||
@click="confirmWithdraw(row)"
|
@click="confirmWithdraw(row)"
|
||||||
>撤回</el-button>
|
>撤回</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canUploadGancaoRow(row)"
|
v-if="canUploadPharmacyRow(row)"
|
||||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||||
type="warning"
|
type="warning"
|
||||||
link
|
link
|
||||||
:loading="gancaoSubmitId === row.id"
|
:loading="pharmacySubmitId === row.id"
|
||||||
@click="confirmSubmitGancaoRecipel(row)"
|
@click="confirmUploadToPharmacy(row)"
|
||||||
>上传药方</el-button>
|
>上传药房</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -638,7 +638,7 @@
|
|||||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||||
<el-radio-button
|
<el-radio-button
|
||||||
label="direct"
|
label="direct"
|
||||||
:disabled="isShipModeLockedToGancao(detail)"
|
:disabled="isShipModeLocked(detail)"
|
||||||
>洛阳药房</el-radio-button>
|
>洛阳药房</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<el-tag
|
<el-tag
|
||||||
@@ -650,6 +650,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-actions="{ detail }">
|
<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
|
<el-button
|
||||||
v-if="canRxAudit(detail)"
|
v-if="canRxAudit(detail)"
|
||||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
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 { 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 ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
||||||
|
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||||
import {
|
import {
|
||||||
TCM_ASSISTANT_ROLE_ID,
|
TCM_ASSISTANT_ROLE_ID,
|
||||||
PRESCRIPTION_AUDIT_ROLE_IDS,
|
PRESCRIPTION_AUDIT_ROLE_IDS,
|
||||||
@@ -2231,7 +2245,10 @@ import {
|
|||||||
type ServicePackageOption,
|
type ServicePackageOption,
|
||||||
normalizeServicePackageOptions,
|
normalizeServicePackageOptions,
|
||||||
parseServicePackageValues,
|
parseServicePackageValues,
|
||||||
mergeServicePackageSelectOptions
|
mergeServicePackageSelectOptions,
|
||||||
|
isRemoteSnapshotLocked,
|
||||||
|
supplyModeLabel,
|
||||||
|
supplyModeTagType
|
||||||
} from './components/prescription-order-utils'
|
} from './components/prescription-order-utils'
|
||||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import {
|
import {
|
||||||
@@ -2256,7 +2273,7 @@ import {
|
|||||||
prescriptionOrderBatchAssignAssistant,
|
prescriptionOrderBatchAssignAssistant,
|
||||||
prescriptionOrderPatchPrescriptionPatient,
|
prescriptionOrderPatchPrescriptionPatient,
|
||||||
prescriptionOrderLinkPayOrder,
|
prescriptionOrderLinkPayOrder,
|
||||||
prescriptionOrderSubmitGancaoRecipel,
|
prescriptionOrderUploadToPharmacy,
|
||||||
prescriptionOrderPreviewGancaoRecipel,
|
prescriptionOrderPreviewGancaoRecipel,
|
||||||
prescriptionDetail,
|
prescriptionDetail,
|
||||||
prescriptionLibraryLists,
|
prescriptionLibraryLists,
|
||||||
@@ -2576,12 +2593,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
|||||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||||
|
|
||||||
const supplyModeTabs = [
|
const supplyModeTabs = [
|
||||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||||
{ label: '甘草', value: 'gancao' as const },
|
{ label: '甘草', value: 'gancao' as const },
|
||||||
|
{ label: '洛阳直发', value: 'direct' as const },
|
||||||
{ label: '自营', value: 'self' as const }
|
{ label: '自营', value: 'self' as const }
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||||
queryParams.supply_mode = value
|
queryParams.supply_mode = value
|
||||||
resetPage()
|
resetPage()
|
||||||
}
|
}
|
||||||
@@ -2615,8 +2633,8 @@ const queryParams = reactive({
|
|||||||
fulfillment_status: '' as number | '',
|
fulfillment_status: '' as number | '',
|
||||||
prescription_audit_status: '' as number | '',
|
prescription_audit_status: '' as number | '',
|
||||||
payment_slip_audit_status: '' as number | '',
|
payment_slip_audit_status: '' as number | '',
|
||||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||||
supply_mode: '' as '' | 'gancao' | 'self',
|
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||||
service_channel: '' as '' | '0',
|
service_channel: '' as '' | '0',
|
||||||
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
||||||
@@ -3071,14 +3089,7 @@ function canEditRow(row: {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||||
const gcTime = Number(row.gancao_submit_time || 0)
|
|
||||||
const gcLocked = gcNo !== '' || gcTime > 0
|
|
||||||
|
|
||||||
// 甘草已提交:仅允许在待双审/履约中/已发货/进行中下修改快递(已签收 6 不可改单号;后端 edit 亦拦截)
|
|
||||||
if (gcLocked) {
|
|
||||||
return [1, 2, 5, 7].includes(fs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未提交甘草:仅待双审(1)、履约中(2)可全量编辑
|
// 未提交甘草:仅待双审(1)、履约中(2)可全量编辑
|
||||||
return fs === 1 || fs === 2
|
return fs === 1 || fs === 2
|
||||||
@@ -3106,6 +3117,7 @@ function canRevokeRxAudit(row: {
|
|||||||
payment_slip_audit_status?: number
|
payment_slip_audit_status?: number
|
||||||
fulfillment_status?: number
|
fulfillment_status?: number
|
||||||
}) {
|
}) {
|
||||||
|
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||||
const fs = Number(row.fulfillment_status)
|
const fs = Number(row.fulfillment_status)
|
||||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||||
const rxStatus = Number(row.prescription_audit_status)
|
const rxStatus = Number(row.prescription_audit_status)
|
||||||
@@ -3128,6 +3140,7 @@ function canRevokePayAudit(row: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||||
|
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||||
return Number(row.fulfillment_status) === 1
|
return Number(row.fulfillment_status) === 1
|
||||||
}
|
}
|
||||||
@@ -3152,13 +3165,13 @@ function shipModeLabel(v: unknown): string {
|
|||||||
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
|
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) {
|
function isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
|
||||||
return String(row.gancao_reciperl_order_no || '').trim() !== ''
|
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)
|
const fs = Number(row.fulfillment_status)
|
||||||
return fs !== 3 && fs !== 4
|
return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shipModeSaving = ref(false)
|
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: {
|
function canAddPayOrderRow(row: {
|
||||||
fulfillment_status?: number
|
fulfillment_status?: number
|
||||||
amount?: number | string
|
amount?: number | string
|
||||||
@@ -3230,14 +3248,27 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
|||||||
return Number(row.fulfillment_status) === 5
|
return Number(row.fulfillment_status) === 5
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadGancaoRow(row: {
|
function canUploadPharmacyRow(row: {
|
||||||
prescription_audit_status?: number
|
prescription_audit_status?: number
|
||||||
|
fulfillment_status?: number
|
||||||
|
ship_mode?: string
|
||||||
gancao_reciperl_order_no?: 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 rxStatus = Number(row.prescription_audit_status)
|
||||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
const fs = Number(row.fulfillment_status)
|
||||||
return rxStatus === 1 && gancaoOrderNo === ''
|
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):状态桥接 ───
|
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
||||||
@@ -3863,7 +3894,7 @@ async function confirmWithdraw(row: { id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gancaoSubmitId = ref(0)
|
const pharmacySubmitId = ref(0)
|
||||||
const gancaoPreviewDrawerVisible = ref(false)
|
const gancaoPreviewDrawerVisible = ref(false)
|
||||||
const gancaoPreviewLoading = ref(false)
|
const gancaoPreviewLoading = ref(false)
|
||||||
const gancaoPreviewData = ref<any>(null)
|
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 {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
`将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
|
||||||
'上传甘草药方',
|
'上传药房',
|
||||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
gancaoSubmitId.value = row.id
|
pharmacySubmitId.value = row.id
|
||||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||||
const d = res?.data ?? res
|
const d = res?.data ?? res
|
||||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
const no = d?.pharmacy_order_no != null
|
||||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
? String(d.pharmacy_order_no)
|
||||||
|
: d?.recipel_order_no != null
|
||||||
|
? String(d.recipel_order_no)
|
||||||
|
: ''
|
||||||
|
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
|
||||||
getLists()
|
getLists()
|
||||||
await detailDrawerRef.value?.refreshIfCurrent(row.id)
|
await detailDrawerRef.value?.refreshIfCurrent(row.id)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -4021,7 +4058,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
|||||||
/* 拦截器已提示 */
|
/* 拦截器已提示 */
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
gancaoSubmitId.value = 0
|
pharmacySubmitId.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,8 +365,8 @@
|
|||||||
<el-tag
|
<el-tag
|
||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
:type="supplyModeTagType(row)"
|
||||||
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag>
|
>{{ supplyModeLabel(row) }}</el-tag>
|
||||||
<span class="po-card__id">#{{ row.id }}</span>
|
<span class="po-card__id">#{{ row.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,7 +500,11 @@
|
|||||||
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
|
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
|
||||||
<span class="text-red-500">退款</span>
|
<span class="text-red-500">退款</span>
|
||||||
</el-dropdown-item>
|
</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>
|
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
|
||||||
<span class="text-red-500">撤回订单</span>
|
<span class="text-red-500">撤回订单</span>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
@@ -544,6 +548,10 @@
|
|||||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
|
<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
|
<el-button
|
||||||
v-if="canRxAudit(detailData)"
|
v-if="canRxAudit(detailData)"
|
||||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
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 v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
|
||||||
<el-form-item label="发货方式">
|
<el-form-item label="发货方式">
|
||||||
<el-radio-group v-model="shipForm.ship_mode">
|
<el-tag
|
||||||
<el-radio label="gancao">甘草药方发</el-radio>
|
:type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
|
||||||
<el-radio label="direct">药房直发</el-radio>
|
effect="plain"
|
||||||
</el-radio-group>
|
size="default"
|
||||||
|
>{{ shipDialogModeDisplay }}</el-tag>
|
||||||
|
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部「发货类型」中设置,此处不可修改</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="承运商">
|
<el-form-item label="承运商">
|
||||||
<el-select v-model="shipForm.express_company" class="w-full">
|
<el-select v-model="shipForm.express_company" class="w-full">
|
||||||
@@ -2697,6 +2707,7 @@ import {
|
|||||||
User
|
User
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import {
|
import {
|
||||||
prescriptionOrderAuditPayment,
|
prescriptionOrderAuditPayment,
|
||||||
@@ -2719,7 +2730,7 @@ import {
|
|||||||
prescriptionOrderPatchPrescriptionUsage,
|
prescriptionOrderPatchPrescriptionUsage,
|
||||||
prescriptionOrderLinkPayOrder,
|
prescriptionOrderLinkPayOrder,
|
||||||
prescriptionOrderRequestCompletion,
|
prescriptionOrderRequestCompletion,
|
||||||
prescriptionOrderSubmitGancaoRecipel,
|
prescriptionOrderUploadToPharmacy,
|
||||||
prescriptionOrderPreviewGancaoRecipel,
|
prescriptionOrderPreviewGancaoRecipel,
|
||||||
prescriptionDetail,
|
prescriptionDetail,
|
||||||
getDoctors,
|
getDoctors,
|
||||||
@@ -2733,7 +2744,10 @@ import {
|
|||||||
mergeServicePackageSelectOptions,
|
mergeServicePackageSelectOptions,
|
||||||
formatServicePackageLabels,
|
formatServicePackageLabels,
|
||||||
normalizeSlipAuxUsageForm,
|
normalizeSlipAuxUsageForm,
|
||||||
prescriptionHasAuxFormula
|
prescriptionHasAuxFormula,
|
||||||
|
isRemoteSnapshotLocked,
|
||||||
|
supplyModeLabel,
|
||||||
|
supplyModeTagType
|
||||||
} from './components/prescription-order-utils'
|
} from './components/prescription-order-utils'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import { jsPDF } from 'jspdf'
|
import { jsPDF } from 'jspdf'
|
||||||
@@ -2984,12 +2998,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
|||||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||||
|
|
||||||
const supplyModeTabs = [
|
const supplyModeTabs = [
|
||||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||||
{ label: '甘草', value: 'gancao' as const },
|
{ label: '甘草', value: 'gancao' as const },
|
||||||
|
{ label: '洛阳直发', value: 'direct' as const },
|
||||||
{ label: '自营', value: 'self' as const }
|
{ label: '自营', value: 'self' as const }
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||||
queryParams.supply_mode = value
|
queryParams.supply_mode = value
|
||||||
resetPage()
|
resetPage()
|
||||||
}
|
}
|
||||||
@@ -3023,8 +3038,8 @@ const queryParams = reactive({
|
|||||||
fulfillment_status: '' as number | '',
|
fulfillment_status: '' as number | '',
|
||||||
prescription_audit_status: '' as number | '',
|
prescription_audit_status: '' as number | '',
|
||||||
payment_slip_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_rx_* / audit_pay_*) */
|
||||||
audit_admin_id: '' as number | ''
|
audit_admin_id: '' as number | ''
|
||||||
})
|
})
|
||||||
@@ -3459,13 +3474,7 @@ function canEditRow(row: {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||||
const gcTime = Number(row.gancao_submit_time || 0)
|
|
||||||
const gcLocked = gcNo !== '' || gcTime > 0
|
|
||||||
|
|
||||||
if (gcLocked) {
|
|
||||||
return [1, 2, 5, 7].includes(fs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs === 1 || fs === 2
|
return fs === 1 || fs === 2
|
||||||
}
|
}
|
||||||
@@ -3492,6 +3501,7 @@ function canRevokeRxAudit(row: {
|
|||||||
payment_slip_audit_status?: number
|
payment_slip_audit_status?: number
|
||||||
fulfillment_status?: number
|
fulfillment_status?: number
|
||||||
}) {
|
}) {
|
||||||
|
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||||
const fs = Number(row.fulfillment_status)
|
const fs = Number(row.fulfillment_status)
|
||||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||||
const rxStatus = Number(row.prescription_audit_status)
|
const rxStatus = Number(row.prescription_audit_status)
|
||||||
@@ -3514,6 +3524,7 @@ function canRevokePayAudit(row: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||||
|
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||||
return Number(row.fulfillment_status) === 1
|
return Number(row.fulfillment_status) === 1
|
||||||
}
|
}
|
||||||
@@ -3544,14 +3555,26 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
|||||||
return Number(row.fulfillment_status) === 5
|
return Number(row.fulfillment_status) === 5
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadGancaoRow(row: {
|
function canUploadPharmacyRow(row: {
|
||||||
prescription_audit_status?: number
|
prescription_audit_status?: number
|
||||||
|
fulfillment_status?: number
|
||||||
gancao_reciperl_order_no?: 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 rxStatus = Number(row.prescription_audit_status)
|
||||||
|
const fulfillmentStatus = Number(row.fulfillment_status)
|
||||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
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) {
|
function orderStatusText(s: number | undefined) {
|
||||||
@@ -3589,7 +3612,7 @@ const h5ActiveFilterCount = computed(() => {
|
|||||||
|
|
||||||
// H5: 判断列表卡片「更多」下拉是否需要展示
|
// H5: 判断列表卡片「更多」下拉是否需要展示
|
||||||
function hasMoreCardActions(row: Record<string, any>) {
|
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: 处理列表卡片「更多」下拉指令
|
// H5: 处理列表卡片「更多」下拉指令
|
||||||
@@ -3597,7 +3620,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
|||||||
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
|
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
|
||||||
else if (cmd === 'complete') confirmComplete(row as any)
|
else if (cmd === 'complete') confirmComplete(row as any)
|
||||||
else if (cmd === 'refund') openRefundOrder(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)
|
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3929,6 +3952,8 @@ function logActionText(act: string) {
|
|||||||
revoke_rx_audit: '撤回处方审核',
|
revoke_rx_audit: '撤回处方审核',
|
||||||
revoke_pay_audit: '撤回支付审核',
|
revoke_pay_audit: '撤回支付审核',
|
||||||
gancao_submit: '甘草下单',
|
gancao_submit: '甘草下单',
|
||||||
|
ej_pharmacy_submit: '洛阳药房下单',
|
||||||
|
ej_pharmacy_callback: '洛阳药房状态',
|
||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
complete: '完成订单',
|
complete: '完成订单',
|
||||||
@@ -4721,7 +4746,7 @@ async function confirmWithdraw(row: { id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gancaoSubmitId = ref(0)
|
const pharmacySubmitId = ref(0)
|
||||||
const gancaoPreviewDrawerVisible = ref(false)
|
const gancaoPreviewDrawerVisible = ref(false)
|
||||||
const gancaoPreviewLoading = ref(false)
|
const gancaoPreviewLoading = ref(false)
|
||||||
const gancaoPreviewData = ref<any>(null)
|
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 {
|
try {
|
||||||
|
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
`确认将本单关联处方上传至${target}?`,
|
||||||
'上传甘草药方',
|
'上传药房',
|
||||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
gancaoSubmitId.value = row.id
|
pharmacySubmitId.value = row.id
|
||||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||||
const d = res?.data ?? res
|
const d = res?.data ?? res
|
||||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
|
||||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
|
||||||
getLists()
|
getLists()
|
||||||
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
|
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
|
||||||
try {
|
try {
|
||||||
@@ -4840,7 +4866,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
|||||||
/* 拦截器已提示 */
|
/* 拦截器已提示 */
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
gancaoSubmitId.value = 0
|
pharmacySubmitId.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4943,6 +4969,10 @@ const shipForm = reactive({
|
|||||||
tracking_number: ''
|
tracking_number: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shipDialogModeDisplay = computed(() =>
|
||||||
|
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
||||||
|
)
|
||||||
|
|
||||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
||||||
shipRowId.value = row.id
|
shipRowId.value = row.id
|
||||||
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
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 = [
|
const completeOrderStatusOptions = [
|
||||||
{ value: 3, label: '已完成' },
|
{ value: 3, label: '已完成' },
|
||||||
{ value: 7, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<MedicineNameSelect v-model="row.name" />
|
<MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
@@ -321,7 +321,7 @@ const formData = reactive({
|
|||||||
pulse: '',
|
pulse: '',
|
||||||
pulse_condition: '',
|
pulse_condition: '',
|
||||||
clinical_diagnosis: '',
|
clinical_diagnosis: '',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||||
dose_count: 7,
|
dose_count: 7,
|
||||||
dose_unit: '剂',
|
dose_unit: '剂',
|
||||||
usage_days: 7,
|
usage_days: 7,
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
APP_DEBUG = true
|
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]
|
[APP]
|
||||||
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,27 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('已保存', $result);
|
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 {
|
try {
|
||||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
|
$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) {
|
if ($result === false) {
|
||||||
$error = PrescriptionOrderLogic::getError();
|
$error = PrescriptionOrderLogic::getError();
|
||||||
@@ -465,7 +486,7 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->fail('返回数据格式错误');
|
return $this->fail('返回数据格式错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success('甘草药方上传成功', $result);
|
return $this->success('药方上传成功', $result);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\think\facade\Log::error('submitGancaoRecipel exception', [
|
\think\facade\Log::error('submitGancaoRecipel exception', [
|
||||||
'message' => $e->getMessage(),
|
'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,不提交订单)
|
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
|
||||||
* 用于在编辑订单时测试价格和配置
|
* 用于在编辑订单时测试价格和配置
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ declare (strict_types=1);
|
|||||||
namespace app\adminapi\http\middleware;
|
namespace app\adminapi\http\middleware;
|
||||||
|
|
||||||
use app\adminapi\logic\LoginLogic;
|
use app\adminapi\logic\LoginLogic;
|
||||||
|
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
|
||||||
use app\common\{
|
use app\common\{
|
||||||
cache\AdminAuthCache,
|
cache\AdminAuthCache,
|
||||||
service\JsonService
|
service\JsonService
|
||||||
@@ -74,7 +75,8 @@ class AuthMiddleware
|
|||||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||||
|
|
||||||
// 判断该当前访问的uri是否存在,不存在无需验证
|
// 判断该当前访问的uri是否存在,不存在无需验证
|
||||||
if (!in_array($accessUri, $allUri)) {
|
if (!in_array($accessUri, $allUri, true)
|
||||||
|
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +111,10 @@ class AuthMiddleware
|
|||||||
*/
|
*/
|
||||||
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
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)
|
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
|
||||||
&& in_array($accessUri, [
|
&& in_array($accessUri, [
|
||||||
'tcm.diagnosistodo/lists',
|
'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",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
|||||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||||
use app\common\model\ExpressTracking;
|
use app\common\model\ExpressTracking;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
use app\common\service\gancao\GancaoScmRecipelService;
|
||||||
|
use app\common\service\pharmacy\EjPharmacyClient;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\db\Query;
|
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
|
private function applySupplyModeFilter($query): void
|
||||||
{
|
{
|
||||||
@@ -343,13 +344,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
if ($mode === '') {
|
if ($mode === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($mode === 'direct') {
|
||||||
|
$query->whereRaw("LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($mode === 'gancao') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if ($mode === 'self') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -706,6 +718,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$assistantNames = $assistantIds !== []
|
$assistantNames = $assistantIds !== []
|
||||||
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
|
? \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) {
|
foreach ($lists as &$item) {
|
||||||
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
||||||
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($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_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
|
||||||
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
|
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
|
||||||
$item['deposit_min_amount'] = $depMin;
|
$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['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
|
||||||
$item,
|
$item,
|
||||||
$this->adminId,
|
$this->adminId,
|
||||||
$this->adminInfo,
|
$this->adminInfo,
|
||||||
$assistantByDiag
|
$assistantByDiag
|
||||||
);
|
);
|
||||||
|
$item['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
|
||||||
|
$item,
|
||||||
|
$this->adminId,
|
||||||
|
$this->adminInfo,
|
||||||
|
$assistantByDiag
|
||||||
|
);
|
||||||
|
|
||||||
// 添加创建人姓名
|
// 添加创建人姓名
|
||||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||||
@@ -766,6 +804,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$base = [
|
$base = [
|
||||||
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
||||||
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
||||||
|
'ej_pharmacy_enabled' => EjPharmacyClient::isConfigured(),
|
||||||
'stats_order_amount' => $s['order_amount'],
|
'stats_order_amount' => $s['order_amount'],
|
||||||
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
|
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
|
||||||
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
|
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
|
||||||
@@ -838,7 +877,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'export_agency_collect' => '代收金额',
|
'export_agency_collect' => '代收金额',
|
||||||
'export_tracking_number' => '快递单号',
|
'export_tracking_number' => '快递单号',
|
||||||
'export_sign_time' => '签收日期',
|
'export_sign_time' => '签收日期',
|
||||||
'export_supply_mode' => '甘草还是自营',
|
'export_supply_mode' => '供货方式',
|
||||||
'export_gancao_prescription_cost' => '处方成本',
|
'export_gancao_prescription_cost' => '处方成本',
|
||||||
'export_first_visit_assistant' => '初诊医助',
|
'export_first_visit_assistant' => '初诊医助',
|
||||||
'export_rx_audit_time' => '审核时间',
|
'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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ namespace app\adminapi\logic\tcm;
|
|||||||
use app\common\cache\AdminAuthCache;
|
use app\common\cache\AdminAuthCache;
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
use app\common\model\auth\AdminRole;
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||||
use app\common\model\tcm\PrescriptionLibrary;
|
use app\common\model\tcm\PrescriptionLibrary;
|
||||||
|
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +17,18 @@ use think\facade\Config;
|
|||||||
*/
|
*/
|
||||||
class PrescriptionLibraryLogic extends BaseLogic
|
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 是否可管理全部处方(超级管理员 或 配置中的管理员角色)
|
* @notes 是否可管理全部处方(超级管理员 或 配置中的管理员角色)
|
||||||
*/
|
*/
|
||||||
@@ -94,7 +108,10 @@ class PrescriptionLibraryLogic extends BaseLogic
|
|||||||
|
|
||||||
// 处理药材数据
|
// 处理药材数据
|
||||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
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);
|
$model = PrescriptionLibrary::create($params);
|
||||||
@@ -130,7 +147,10 @@ class PrescriptionLibraryLogic extends BaseLogic
|
|||||||
|
|
||||||
// 处理药材数据
|
// 处理药材数据
|
||||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
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);
|
$model->save($params);
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ namespace app\adminapi\logic\tcm;
|
|||||||
|
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
use app\common\model\doctor\Appointment;
|
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\Prescription;
|
||||||
use app\common\model\tcm\PrescriptionOrder;
|
use app\common\model\tcm\PrescriptionOrder;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
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 app\adminapi\logic\auth\AuthLogic;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
@@ -159,6 +162,18 @@ class PrescriptionLogic
|
|||||||
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
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 字段结构一致)
|
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
|
||||||
*/
|
*/
|
||||||
@@ -255,13 +270,12 @@ class PrescriptionLogic
|
|||||||
self::setError('请添加中药');
|
self::setError('请添加中药');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
foreach ($herbs as $h) {
|
$herbs = self::normalizeHerbIdentities($herbs);
|
||||||
if (empty($h['name'])) {
|
} catch (\DomainException $exception) {
|
||||||
self::setError('中药名称不能为空');
|
self::setError($exception->getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$sn = self::generateSn();
|
$sn = self::generateSn();
|
||||||
while (Prescription::where('sn', $sn)->find()) {
|
while (Prescription::where('sn', $sn)->find()) {
|
||||||
@@ -335,6 +349,23 @@ class PrescriptionLogic
|
|||||||
* 编辑处方
|
* 编辑处方
|
||||||
*/
|
*/
|
||||||
public static function edit(array $params, int $adminId): bool
|
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 {
|
try {
|
||||||
$prescription = Prescription::find($params['id']);
|
$prescription = Prescription::find($params['id']);
|
||||||
@@ -342,6 +373,11 @@ class PrescriptionLogic
|
|||||||
self::setError('处方不存在');
|
self::setError('处方不存在');
|
||||||
return false;
|
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) {
|
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
|
||||||
@@ -381,12 +417,7 @@ class PrescriptionLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($herbs as $h) {
|
$herbs = self::normalizeHerbIdentities($herbs);
|
||||||
if (empty($h['name'])) {
|
|
||||||
self::setError('中药名称不能为空');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
|
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
|
||||||
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
|
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
|
||||||
@@ -474,6 +505,29 @@ class PrescriptionLogic
|
|||||||
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
|
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
|
||||||
*/
|
*/
|
||||||
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
|
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 = '';
|
self::$error = '';
|
||||||
$prescription = Prescription::find($rxId);
|
$prescription = Prescription::find($rxId);
|
||||||
@@ -482,6 +536,11 @@ class PrescriptionLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
|
||||||
|
if ($snapshotLockError !== null) {
|
||||||
|
self::setError($snapshotLockError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
|
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
|
||||||
self::setError('无权限修改此处方');
|
self::setError('无权限修改此处方');
|
||||||
|
|
||||||
@@ -578,6 +637,22 @@ class PrescriptionLogic
|
|||||||
* 删除处方
|
* 删除处方
|
||||||
*/
|
*/
|
||||||
public static function delete(int $id): bool
|
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 {
|
try {
|
||||||
$prescription = Prescription::find($id);
|
$prescription = Prescription::find($id);
|
||||||
@@ -1027,6 +1102,22 @@ class PrescriptionLogic
|
|||||||
* 作废处方
|
* 作废处方
|
||||||
*/
|
*/
|
||||||
public static function void(int $id, int $adminId, string $adminName): bool
|
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);
|
$row = Prescription::find($id);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
|
|||||||
@@ -17,11 +17,25 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
|||||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||||
use app\common\model\dict\DictData;
|
use app\common\model\dict\DictData;
|
||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
|
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||||
use app\common\model\auth\Admin;
|
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\DataScope\DataScopeService;
|
||||||
use app\common\service\ExpressTrackService;
|
use app\common\service\ExpressTrackService;
|
||||||
use app\common\service\ExpressTrackingService;
|
use app\common\service\ExpressTrackingService;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
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\db\Query;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
@@ -41,6 +55,42 @@ class PrescriptionOrderLogic
|
|||||||
return self::$error;
|
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 仍拼不出路径
|
* 使用 Db 直查 zyt_dept:避免 Dept 软删除全局作用域导致有 dept_id 仍拼不出路径
|
||||||
@@ -293,6 +343,21 @@ class PrescriptionOrderLogic
|
|||||||
return false;
|
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
|
* @param PrescriptionOrder $row
|
||||||
*/
|
*/
|
||||||
@@ -972,6 +1037,9 @@ class PrescriptionOrderLogic
|
|||||||
$order->service_package = (string) ($params['service_package'] ?? '');
|
$order->service_package = (string) ($params['service_package'] ?? '');
|
||||||
$order->tracking_number = (string) ($params['tracking_number'] ?? '');
|
$order->tracking_number = (string) ($params['tracking_number'] ?? '');
|
||||||
$order->express_company = self::normalizeExpressCompany($params['express_company'] ?? 'auto');
|
$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->fee_type = (int) $params['fee_type'];
|
||||||
$order->amount = round((float) $params['amount'], 2);
|
$order->amount = round((float) $params['amount'], 2);
|
||||||
$order->internal_cost = $internalCost;
|
$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;
|
return $arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,6 +1244,33 @@ class PrescriptionOrderLogic
|
|||||||
string $phone,
|
string $phone,
|
||||||
int $adminId,
|
int $adminId,
|
||||||
array $adminInfo
|
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 {
|
): bool {
|
||||||
self::$error = '';
|
self::$error = '';
|
||||||
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
||||||
@@ -1178,6 +1284,9 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$rxId = (int) ($order->prescription_id ?? 0);
|
$rxId = (int) ($order->prescription_id ?? 0);
|
||||||
if ($rxId <= 0) {
|
if ($rxId <= 0) {
|
||||||
self::setError('该订单未关联处方');
|
self::setError('该订单未关联处方');
|
||||||
@@ -1605,6 +1714,23 @@ class PrescriptionOrderLogic
|
|||||||
* @return array<string,mixed>|false
|
* @return array<string,mixed>|false
|
||||||
*/
|
*/
|
||||||
public static function edit(array $params, int $adminId, array $adminInfo)
|
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 = '';
|
self::$error = '';
|
||||||
$id = (int) $params['id'];
|
$id = (int) $params['id'];
|
||||||
@@ -1640,11 +1766,8 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 已成功提交甘草 SCM:仅允许更新快递单号与承运商(与甘草侧地址/药方等仍以取消流程为准)
|
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||||
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no);
|
return false;
|
||||||
$gcSubmitTime = (int) $order->gancao_submit_time;
|
|
||||||
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
|
|
||||||
return self::editGancaoLogisticsOnly($order, $params, $adminId, $adminInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$medDays = $params['medication_days'] ?? null;
|
$medDays = $params['medication_days'] ?? null;
|
||||||
@@ -1827,6 +1950,23 @@ class PrescriptionOrderLogic
|
|||||||
* @return array<string,mixed>|false
|
* @return array<string,mixed>|false
|
||||||
*/
|
*/
|
||||||
public static function ship(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo)
|
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 = '';
|
self::$error = '';
|
||||||
|
|
||||||
@@ -1854,12 +1994,11 @@ class PrescriptionOrderLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$shipMode = self::normalizeShipMode($shipMode);
|
$shipMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||||
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
|
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
|
||||||
|
|
||||||
$order->express_company = self::normalizeExpressCompany($expressCompany);
|
$order->express_company = self::normalizeExpressCompany($expressCompany);
|
||||||
$order->tracking_number = $trackingNumber;
|
$order->tracking_number = $trackingNumber;
|
||||||
$order->ship_mode = $shipMode;
|
|
||||||
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
|
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
|
||||||
$isFirstShip = false;
|
$isFirstShip = false;
|
||||||
if ((int) $order->fulfillment_status === 2) {
|
if ((int) $order->fulfillment_status === 2) {
|
||||||
@@ -1915,6 +2054,22 @@ class PrescriptionOrderLogic
|
|||||||
* @return array<string,mixed>|false
|
* @return array<string,mixed>|false
|
||||||
*/
|
*/
|
||||||
public static function withdraw(int $id, int $adminId, array $adminInfo)
|
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 = '';
|
self::$error = '';
|
||||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||||
@@ -2194,6 +2349,22 @@ class PrescriptionOrderLogic
|
|||||||
* @return array<string,mixed>|false
|
* @return array<string,mixed>|false
|
||||||
*/
|
*/
|
||||||
public static function revokeRxAudit(int $id, int $adminId, array $adminInfo)
|
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 = '';
|
self::$error = '';
|
||||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||||
@@ -4025,8 +4196,7 @@ class PrescriptionOrderLogic
|
|||||||
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
|
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
|
||||||
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
|
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
|
||||||
$item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : '';
|
$item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : '';
|
||||||
$isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '';
|
$item['export_supply_mode'] = PharmacySupplyMode::label(PharmacySupplyMode::resolve($item));
|
||||||
$item['export_supply_mode'] = $isGc ? '甘草' : '自营';
|
|
||||||
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
|
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
|
||||||
if (self::canViewInternalCost($adminInfo)) {
|
if (self::canViewInternalCost($adminInfo)) {
|
||||||
$rawCost = $item['internal_cost'] ?? null;
|
$rawCost = $item['internal_cost'] ?? null;
|
||||||
@@ -4303,6 +4473,12 @@ class PrescriptionOrderLogic
|
|||||||
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (trim((string) ($item['ej_pharmacy_order_no'] ?? '')) !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (self::pharmacyClaimBlocksUpload($item, 'gancao')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -4321,12 +4497,339 @@ class PrescriptionOrderLogic
|
|||||||
return $aid === $adminId && $adminId > 0;
|
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)。
|
* 将当前业务订单关联处方提交至甘草药管家(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)。
|
||||||
*
|
*
|
||||||
* @return array<string,mixed>|false 成功返回甘草 result 主要字段
|
* @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 = '';
|
self::$error = '';
|
||||||
@@ -4455,7 +4958,7 @@ class PrescriptionOrderLogic
|
|||||||
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
|
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
throw new PharmacyReconciliationRequiredException(self::$error);
|
||||||
}
|
}
|
||||||
$subBody = $subRet['body'] ?? [];
|
$subBody = $subRet['body'] ?? [];
|
||||||
|
|
||||||
@@ -4470,27 +4973,16 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
$gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? '');
|
$gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? '');
|
||||||
if ($gcNo === '') {
|
if ($gcNo === '') {
|
||||||
self::$error = '甘草返回缺少 recipel_order_no';
|
self::$error = '甘草下单可能已成功,但返回缺少 recipel_order_no,必须对账后再操作';
|
||||||
|
throw new PharmacyReconciliationRequiredException(self::$error);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
$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'] ?? [];
|
$fee = $subBody['result']['fee'] ?? [];
|
||||||
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'pharmacy' => 'gancao',
|
||||||
'recipel_order_no' => $gcNo,
|
'recipel_order_no' => $gcNo,
|
||||||
|
'remote_order_no' => $gcNo,
|
||||||
'app_order_no' => $appNo,
|
'app_order_no' => $appNo,
|
||||||
'fee' => is_array($fee) ? $fee : [],
|
'fee' => is_array($fee) ? $fee : [],
|
||||||
];
|
];
|
||||||
@@ -4627,6 +5119,22 @@ class PrescriptionOrderLogic
|
|||||||
* @return array<string,mixed>|false
|
* @return array<string,mixed>|false
|
||||||
*/
|
*/
|
||||||
public static function setShipMode(int $id, string $shipMode, int $adminId, array $adminInfo)
|
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 = '';
|
self::$error = '';
|
||||||
|
|
||||||
@@ -4641,6 +5149,14 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!self::canSelectShipMode($adminInfo)) {
|
||||||
|
self::$error = '无权限设置发货药房';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$fs = (int) $order->fulfillment_status;
|
$fs = (int) $order->fulfillment_status;
|
||||||
if ($fs === 3 || $fs === 4) {
|
if ($fs === 3 || $fs === 4) {
|
||||||
self::$error = '已完成或已取消的订单不可修改发货类型';
|
self::$error = '已完成或已取消的订单不可修改发货类型';
|
||||||
@@ -4654,6 +5170,10 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (trim((string) ($order->ej_pharmacy_order_no ?? '')) !== '') {
|
||||||
|
self::$error = '已上传洛阳药房,不可修改发货类型';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$oldMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
$oldMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||||
if ($oldMode === $shipMode) {
|
if ($oldMode === $shipMode) {
|
||||||
@@ -4888,6 +5408,23 @@ class PrescriptionOrderLogic
|
|||||||
* @param array<string, mixed> $params
|
* @param array<string, mixed> $params
|
||||||
*/
|
*/
|
||||||
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool
|
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 = '';
|
self::$error = '';
|
||||||
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||||
@@ -4902,6 +5439,9 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if ((int) $order->fulfillment_status === 4) {
|
if ((int) $order->fulfillment_status === 4) {
|
||||||
self::setError('已取消的订单不可修改');
|
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',
|
'tracking_number' => 'max:80',
|
||||||
'express_company' => 'max:20',
|
'express_company' => 'max:20',
|
||||||
'ship_mode' => 'in:gancao,direct',
|
'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',
|
'fee_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||||
'amount' => 'require|float',
|
'amount' => 'require|float',
|
||||||
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
|
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||||
@@ -64,7 +67,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'create' => [
|
'create' => [
|
||||||
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
|
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
|
||||||
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
|
'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'],
|
'detail' => ['id'],
|
||||||
'edit' => [
|
'edit' => [
|
||||||
@@ -87,11 +90,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'complete' => ['id', 'fulfillment_status'],
|
'complete' => ['id', 'fulfillment_status'],
|
||||||
'refund' => ['id', 'reason', 'refund_amount'],
|
'refund' => ['id', 'reason', 'refund_amount'],
|
||||||
'submitGancaoRecipel' => ['id'],
|
'submitGancaoRecipel' => ['id'],
|
||||||
|
'uploadToPharmacy' => ['id'],
|
||||||
'previewGancaoRecipel' => ['id'],
|
'previewGancaoRecipel' => ['id'],
|
||||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||||
'updateAmount' => ['id', 'amount'],
|
'updateAmount' => ['id', 'amount'],
|
||||||
'setShipMode' => ['id', 'ship_mode'],
|
'setShipMode' => ['id', 'ship_mode'],
|
||||||
|
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function updateAmount(): PrescriptionOrderValidate
|
public function updateAmount(): PrescriptionOrderValidate
|
||||||
|
|||||||