Compare commits

..
85 Commits
Author SHA1 Message Date
Your Name daaf6c4a14 Merge branch 'master' into chufang-5-8 2026-05-08 17:13:44 +08:00
long 68e2e0b7d7 Revert "出丸 计算公式修改" 2026-05-08 17:11:39 +08:00
long c78ba50331 Revert "出丸 计算公式修改"
This reverts commit ee1b140902.
2026-05-08 17:02:31 +08:00
Your Name 0026a92697 更新 2026-05-08 16:51:36 +08:00
long ee1b140902 出丸 计算公式修改 2026-05-08 16:51:25 +08:00
long f81a3b5d94 Merge branch 'long-0507' 2026-05-08 16:27:41 +08:00
long 8719cdd6d6 update 2026-05-08 16:27:31 +08:00
long 33fb055bfe Update NoteTimeline.vue
修复图片预览层级
2026-05-08 16:07:57 +08:00
long 14735af0b3 订单修改金额 2026-05-08 16:07:39 +08:00
Your Name 623bda2ecb 更新 2026-05-08 15:20:02 +08:00
Your Name dfbb6d82b3 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-08 14:13:46 +08:00
Your Name 7473a3e03d 更新 2026-05-08 14:13:19 +08:00
long eb9b31f8f5 Merge branch 'long-0507' 2026-05-08 13:57:17 +08:00
long f20a45a1ad Update index.vue 2026-05-08 13:57:09 +08:00
long 0f66d1438f Merge branch 'long-0507' 2026-05-08 10:55:21 +08:00
Your Name 43110e015d 修复bug 2026-05-08 10:49:32 +08:00
long 7d7a4aa4d2 oss 直传
分片上传
2026-05-08 10:49:16 +08:00
Your Name 8c640abb26 更新 2026-05-07 18:35:35 +08:00
Your Name ffabe78942 更新 2026-05-07 18:34:06 +08:00
long 81fcef2156 Merge branch 'long-0507' 2026-05-07 18:33:40 +08:00
long 4dc9ec1878 debug 2026-05-07 18:32:47 +08:00
Your Name 3f05a610b3 新增 2026-05-07 18:31:52 +08:00
Your Name 6c997559a2 更新 2026-05-07 18:21:00 +08:00
Your Name d39b1ed7ee 更新 2026-05-07 17:42:00 +08:00
long 0e81fb5037 Merge branch 'long-0507' 2026-05-07 17:36:11 +08:00
long 36847ea70c 定时拉取聊天记录 2026-05-07 17:36:00 +08:00
Your Name 9b40ebb8b7 更新 2026-05-07 16:53:32 +08:00
Your Name 9948bc28df Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-07 16:42:49 +08:00
Your Name a9474356ff 更新 2026-05-07 16:42:29 +08:00
long 4e23c4bf79 Merge branch 'long-0507' 2026-05-07 16:29:41 +08:00
long b6e6394e51 1 2026-05-07 16:29:31 +08:00
Your Name 576ef8e033 更新 2026-05-07 15:49:55 +08:00
Your Name a52380aa63 Merge branch 'master' into tongji-99 2026-05-07 15:43:49 +08:00
Your Name 49e7169630 更新 2026-05-07 15:27:57 +08:00
long d53f492d2a Merge branch 'long-0507' 2026-05-07 15:04:23 +08:00
long 3d3abff842 1 2026-05-07 15:04:16 +08:00
long 90ac63c905 Merge branch 'long-0507' 2026-05-07 14:46:29 +08:00
long 320d3783ac 日常记录
归总 血糖、饮食、运动、备注、代办
2026-05-07 14:46:12 +08:00
long 5966049020 Merge branch 'long-0507' 2026-05-07 12:44:54 +08:00
long 755491c263 Update ConversionLogic.php 2026-05-07 12:44:43 +08:00
long 79203d948e Update ConversionLogic.php 2026-05-07 12:16:50 +08:00
long 01d56f3b4f Merge branch 'long-0507' 2026-05-07 11:59:45 +08:00
long 477fcf6180 增加开方-用量 袋数 2026-05-07 11:59:15 +08:00
Your Name 3014a99db5 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-07 11:50:58 +08:00
Your Name c4dd196e72 更新 2026-05-07 11:50:48 +08:00
long 581c74f11a Merge branch 'history-order' 2026-05-07 10:50:42 +08:00
long 7a2f285442 1 2026-05-07 10:50:37 +08:00
long 375f54263a Merge branch 'history-order' 2026-05-07 10:48:31 +08:00
long ed4a4c4960 业务订单 2026-05-07 10:48:21 +08:00
Your Name 9d405ebed9 更新 2026-05-06 18:22:24 +08:00
Your Name a7fd55aa1c 更新 2026-05-06 18:17:37 +08:00
Your Name c389fea73c 更新 2026-05-06 14:43:17 +08:00
Your Name 05e8f4ddd7 更新
Please enter the commit message for your changes. Lines starting
2026-05-06 14:21:52 +08:00
Your Name da9a493e8d 更新 2026-05-06 13:53:09 +08:00
Your Name 64ab3197ec 更新 2026-05-06 12:19:46 +08:00
Your Name dddc4d202a 更新 2026-05-06 11:17:31 +08:00
Your Name 71180d4fd8 更新 2026-05-06 10:25:41 +08:00
Your Name 1942387050 更新 2026-05-06 10:16:52 +08:00
Your Name 77c1fc908d 更新 2026-05-06 09:05:29 +08:00
Your Name ef4800e82a 更新 2026-05-06 09:03:08 +08:00
Your Name 815ea9e025 更新 2026-05-05 18:27:58 +08:00
Your Name f7ce3d18d8 Merge branch 'master' into cs 2026-05-05 18:26:03 +08:00
Your Name de78f3b218 更新 2026-05-05 18:25:14 +08:00
long 671c7f16bd Merge branch 'fix-diagnosis-20250505' 2026-05-05 18:07:04 +08:00
long 30b5a13f3a update 2026-05-05 18:06:42 +08:00
long c830008dfc 跟踪备注 2026-05-05 17:52:35 +08:00
long 919d0f856e 1 2026-05-05 16:55:58 +08:00
long 49d0fed14e 1 2026-05-05 16:16:55 +08:00
long 19bfeef98f 1 2026-05-05 15:02:04 +08:00
Your Name cd51f55701 Merge branch 'master' into cs 2026-05-05 14:52:27 +08:00
Your Name 17cb0e8319 更新 2026-05-05 14:51:49 +08:00
Your Name 3ef10846b4 更新 2026-05-05 14:51:35 +08:00
Your Name 40eec808ef 更新 2026-05-05 14:34:39 +08:00
Your Name 03db6d2677 更新 2026-05-05 13:41:37 +08:00
Your Name c03e7051d7 Merge branch 'master' into cs 2026-05-05 13:30:23 +08:00
Your Name d6e105f912 更新 2026-05-05 13:28:45 +08:00
long 0da27ed745 代办任务 2026-05-05 12:42:09 +08:00
long 1d6a2b9cbf Update ConversionLogic.php 2026-05-04 17:37:14 +08:00
long 28fbbd28b7 增加数据权限
增加调试登录
2026-05-04 15:07:44 +08:00
long 22b8b38b5d 1 2026-05-04 13:18:43 +08:00
Your Name 81b2808eaf 更新 2026-04-30 21:53:05 +08:00
Your Name 25eb3340de 更新 2026-04-30 21:43:48 +08:00
Your Name c9150ca4b4 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-04-30 21:43:20 +08:00
long b9165f6b1b 更新权限按钮 2026-04-30 18:06:40 +08:00
long e0ab9ebc4b Update .gitignore 2026-04-30 17:40:30 +08:00
502 changed files with 30118 additions and 1907 deletions
+6 -1
View File
@@ -11,7 +11,12 @@
"Bash(php think *)",
"Bash(python3 -c ' *)",
"Bash(python3 *)",
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)"
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)",
"Bash(npx eslint *)",
"Bash(git *)",
"Bash(tail -c 100000 /Users/long/.claude/projects/-Users-long-Work-zyt/7be9f770-afc3-497d-87a3-2ffa1fbe3fd1.jsonl)",
"Read(//tmp/**)",
"Bash(npx --no-install vue-tsc --noEmit)"
]
}
}
+4
View File
@@ -25,3 +25,7 @@ bin-release/
/.shared
/.cursor
/.codex
/.agents
/.claude
/.claude
/.claude
+47 -12
View File
@@ -3,20 +3,55 @@
These instructions are for AI assistants working in this project.
Use the `/trellis:start` command when starting a new session to:
- Initialize your developer identity
- Understand current project context
- Read relevant guidelines
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
Use `@/.trellis/` to learn:
- Development workflow (`workflow.md`)
- Project structure guidelines (`spec/`)
- Developer workspace (`workspace/`)
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
- `.trellis/workspace/` — per-developer journals and session traces
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
If you're using Codex, project-scoped helpers may also live in:
- `.agents/skills/` for reusable Trellis skills
- `.codex/agents/` for optional custom subagents
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
Keep this managed block so 'trellis update' can refresh the instructions.
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
- `.agents/skills/` — reusable Trellis skills
- `.codex/agents/` — optional custom subagents
## Subagents
- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups.
- On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time.
- On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing.
- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken.
- Spawn subagents automatically when:
- Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan)
- Long-running or blocking tasks where a worker can run independently
- Isolation for risky changes or checks
### Codex-only — `spawn_agent` parameters
When calling `spawn_agent`, ALWAYS pass `fork_turns="none"`. Without it the child inherits the parent transcript and sees your prior `spawn_agent(...)` records, then applies the "wait for spawned subagents" rule to itself — causing `wait_agent` self-deadlock.
```text
spawn_agent(agent_type="trellis-implement", message="...", fork_turns="none")
```
### Codex-only — multi-subagent close-loop
When `wait` returns a `completed` notification, treat it as an event signal — not as "all done". Run this loop:
1. Maintain an `expected_agents` set of dispatched sub-agent thread IDs.
2. After each `wait` update:
1. Call `list_agents` to inspect ALL live agents' status.
2. For each agent now in a terminal state:
- Verify its promised deliverable exists (e.g. `{task_dir}/research/*.md`).
- Read or summarize as needed.
- `close_agent` to release the slot.
- Remove from `expected_agents`.
3. If `expected_agents` still contains running agents → keep waiting.
4. If `expected_agents` is empty → continue main flow.
3. Never `wait` on an agent that has already reported `completed`.
4. If a `completed` agent is missing its deliverable, treat it as failed — surface that in your report instead of re-waiting.
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
<!-- TRELLIS:END -->
@@ -164,7 +164,7 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { onHide, onUnload } from '@dcloudio/uni-app'
import { onUnload } from '@dcloudio/uni-app'
import Avatar from '../Avatar/Avatar.vue';
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
@@ -337,6 +337,8 @@ onUnload(() => {
const callRole = callParticipantInfo?.value?.selfInfo?.role;
if (callStatus === CallStatus.IDLE) return;
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
if (callStatus === CallStatus.CONNECTED) return;
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
hangup();
@@ -344,9 +346,6 @@ onUnload(() => {
reject();
}
}
if (callStatus === CallStatus.CONNECTED) {
hangup();
}
});
</script>
@@ -277,6 +277,8 @@ const { proxy } = getCurrentInstance()
let unsubscribeWxAppShow = null;
let allowChatForegroundRelogin = false;
let foregroundReloginTimer = null;
/** 避免 onUnload 重复上报离开 */
let patientLeaveNotified = false;
const CONFIRMATION_MESSAGE = `我已确认以下信息:
1、本人确认已在线下确诊;
@@ -611,6 +613,7 @@ const { proxy } = getCurrentInstance()
}
onLoad(async (options) => {
try {
patientLeaveNotified = false;
console.log('进入onMounted:', options);
const app = getApp();
const userID = options.userID|| app.globalData.userID;
@@ -790,6 +793,11 @@ const { proxy } = getCurrentInstance()
});
onUnload(() => {
// 患者退出聊天页:通知后端(与 notifyOpen 对称,供管理端轮询)
if (!patientLeaveNotified && targetUserID && String(targetUserID).startsWith('doctor_')) {
patientLeaveNotified = true;
void notifyDoctorOnChatClose();
}
allowChatForegroundRelogin = false;
// #ifdef MP-WEIXIN
if (typeof unsubscribeWxAppShow === 'function') {
@@ -855,6 +863,47 @@ const { proxy } = getCurrentInstance()
console.warn('后端通知失败:', e);
}
}
/** 离开会话页时通知医生:IM 自定义消息 + 后端全局事件 */
async function notifyDoctorOnChatClose() {
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
const doctorId = targetUserID.replace('doctor_', '');
const userData = uni.getStorageSync('userData') || {};
const patientName = userData.name || patient_data.value?.patient_name || '患者';
const patientId = (currentUserID || '').replace('patient_', '') || '';
try {
if (chat) {
const customPayload = {
businessID: 'patient_closed_chat',
patientId,
patientName,
doctorId,
time: Date.now()
};
const imMsg = chat.createCustomMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { data: JSON.stringify(customPayload) }
});
await chat.sendMessage(imMsg);
}
} catch (e) {
console.warn('IM 离开通知发送失败:', e);
}
try {
await proxy.apiUrl({
url: '/api/chat/notifyClose',
method: 'POST',
data: {
doctor_id: doctorId,
patient_id: patientId,
patient_name: patientName
}
}, false);
} catch (e) {
console.warn('后端离开通知失败:', e);
}
}
function applyDoctorEnteredSignalFromRawMessage(msg) {
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
@@ -956,6 +1005,9 @@ const { proxy } = getCurrentInstance()
if (customData.businessID === 'patient_opened_chat') {
return null; // 返回null表示不显示此消息
}
if (customData.businessID === 'patient_closed_chat') {
return null;
}
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
if (customData.businessID === 'doctor_entered_consult_room') {
return null;
+45
View File
@@ -19,6 +19,7 @@
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"cos-js-sdk-v5": "^1.10.1",
"css-color-function": "^1.3.3",
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
@@ -6064,6 +6065,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cos-js-sdk-v5": {
"version": "1.10.1",
"resolved": "https://registry.npmmirror.com/cos-js-sdk-v5/-/cos-js-sdk-v5-1.10.1.tgz",
"integrity": "sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"fast-xml-parser": "4.5.0"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -7580,6 +7591,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
"integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -12299,6 +12332,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"cos-js-sdk-v5": "^1.10.1",
"css-color-function": "^1.3.3",
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
+9731
View File
File diff suppressed because it is too large Load Diff
+85 -36
View File
@@ -4,48 +4,50 @@ import useAppStore from '@/stores/modules/app'
import { getToken } from '@/utils/auth'
import request from '@/utils/request'
export type MaterialUploadType = 'image' | 'video' | 'file'
/** 本地上传素材(与 `components/upload` 相同:POST /upload/{type},字段 file + cid */
export async function uploadMaterialFile(
file: File,
type: MaterialUploadType = 'image',
cid: string | number = 0
) {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file)
formData.append('cid', String(cid))
const url = `${config.baseUrl}${config.urlPrefix}/upload/${type}`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
}
/** 本地上传图片(与素材库「本地上传」同一接口),返回 data 含 url 相对路径 */
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file, filename)
formData.append('cid', '0')
const url = `${config.baseUrl}${config.urlPrefix}/upload/image`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
const fileObj =
file instanceof File
? file
: new File([file], filename, { type: file.type || 'image/jpeg' })
return uploadMaterialFile(fileObj, 'image', 0)
}
/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */
export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file, filename)
formData.append('cid', '0')
const url = `${config.baseUrl}${config.urlPrefix}/upload/video`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
const fileObj =
file instanceof File
? file
: new File([file], filename, { type: file.type || 'video/webm' })
return uploadMaterialFile(fileObj, 'video', 0)
}
export function fileCateAdd(params: Record<string, any>) {
@@ -88,3 +90,50 @@ export function fileMove(params: Record<string, any>) {
export function fileRename(params: { id: number; name: string }) {
return request.post({ url: '/file/rename', params })
}
/** 浏览器直传 OSS - 凭证类型 */
export interface OssCredentialsResponse {
provider: string
fallback: boolean
bucket?: string
region?: string
host?: string
cdn_domain?: string
key_prefix?: string
max_size?: number
duration?: number
expired_time?: number
start_time?: number
credentials?: {
tmpSecretId: string
tmpSecretKey: string
sessionToken: string
}
}
/** 申请 STS 临时凭证 */
export function getOssCredentials(params: { type: 'video' }) {
return request.post({
url: '/upload/ossCredentials',
params
}) as Promise<OssCredentialsResponse>
}
/** 直传完成回执:写 file 表 + HEAD 校验 */
export function confirmOssUpload(params: {
type: 'video'
key: string
name: string
size: number
content_type: string
cid?: number
}) {
return request.post({ url: '/upload/ossConfirm', params }) as Promise<{
id: number
cid: number
type: number
name: string
uri: string
url: string
}>
}
+3 -3
View File
@@ -25,7 +25,7 @@ export function deptDetail(params: any) {
return request.get({ url: '/dept.dept/detail', params })
}
// 部门列表全部
export function deptAll() {
return request.get({ url: '/dept.dept/all' })
// 部门列表全部apply_data_scope=1 时按当前账号角色数据权限收窄树(与 DataScopeService 一致)
export function deptAll(params?: { apply_data_scope?: number }) {
return request.get({ url: '/dept.dept/all', params })
}
+61
View File
@@ -42,3 +42,64 @@ export function yejiStatsLeaderboard(params: {
}) {
return request.get({ url: '/stats.yejiStats/leaderboard', params })
}
/** 「未归属中心」行:按订单创建人拆解合计业绩(与看板「合计业绩」未归属部分同口径) */
export function yejiStatsUnassignedBreakdown(params: {
start_date: string
end_date: string
dept_ids?: string
channel_code?: string
tag_id?: string
}) {
return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params })
}
/** 部门行「进线数据」逐条明细(与看板同口径) */
export function yejiStatsLeadLines(params: {
start_date: string
end_date: string
dept_id: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/leadLines', params })
}
/** 医助排行榜「预约诊单」逐条挂号明细;业绩主表部门行传 dept_id(可与 assistant_id 二选一) */
export function yejiStatsAppointmentLines(params: {
start_date: string
end_date: string
assistant_id?: number
dept_id?: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/appointmentLines', params })
}
/** 二中心复诊:部门行下钻医助 × 业务订单笔数 */
export function yejiStatsRevisitBreakdown(params: {
start_date: string
end_date: string
dept_id: number
revisit_slot: number
dept_ids?: string
channel_code?: string
tag_id?: string
}) {
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
}
export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
channel_code?: string
}) {
return request.get({ url: '/stats.doctorDailyStats/overview', params })
}
+98 -9
View File
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
return request.get({ url: '/tcm.diagnosis/lists', params })
}
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + 未服务天数(不含跟踪记录) */
export function diagnosisReadonlyDetail(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
}
/** 跟踪信息(血糖血压 / 饮食 / 运动)按日期区间 lazy load — 由 TrackingMatrix 调用 */
export function diagnosisTrackingWindow(params: {
id: number
start_date?: string
end_date?: string
}) {
return request.get({ url: '/tcm.diagnosis/trackingWindow', params })
}
/** 新增跟踪备注(按天合并追加,仅文字) */
export function diagnosisAddTrackingNote(params: {
diagnosis_id: number
tracking_content: string
}) {
return request.post({ url: '/tcm.diagnosis/addTrackingNote', params })
}
/** 拉取跟踪备注列表(note_date DESC */
export function diagnosisTrackingNotes(params: { diagnosis_id: number }) {
return request.get({ url: '/tcm.diagnosis/trackingNotes', params })
}
// 医助理诊单统计(按部门、按人)
export function assistantDiagnosisStats(params?: { start_time?: string; end_time?: string; days?: number }) {
export function assistantDiagnosisStats(params?: {
start_time?: string
end_time?: string
days?: number
}) {
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
}
@@ -198,14 +229,25 @@ export function startCloudRecording(params: { diagnosis_id: number }) {
return request.post({ url: '/tcm.diagnosis/startCloudRecording', params })
}
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消 */
export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) {
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(可显式传 call_record_id */
export function attachLocalCallRecording(params: {
diagnosis_id: number
file_url: string
call_record_id?: number
}) {
return request.post(
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
export function createManualCallRecord(params: { diagnosis_id: number }) {
return request.post(
{ url: '/tcm.diagnosis/createManualCallRecord', params },
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
return request.get({ url: '/tcm.diagnosis/watchCall', params })
@@ -230,6 +272,11 @@ export function getImChatMessages(params: { diagnosis_id: number; only_archived?
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
}
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
export function triggerImChatSync(data: { diagnosis_id: number }) {
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
}
// 获取企业微信聊天记录
export function getWechatChatRecords(params: any) {
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
@@ -298,7 +345,11 @@ export function prescriptionVoid(params: { id: number }) {
}
/** 处方审核:action approve | reject(驳回同时作废处方) */
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
export function prescriptionAudit(params: {
id: number
action: 'approve' | 'reject'
remark?: string
}) {
return request.post({ url: '/tcm.prescription/audit', params })
}
@@ -401,10 +452,7 @@ export function prescriptionOrderAddPayOrder(params: {
}
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
export function prescriptionOrderLinkPayOrder(params: {
id: number
pay_order_id: number
}) {
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
}
@@ -419,7 +467,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
}
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
export function prescriptionOrderPreviewGancaoRecipel(params: {
id: number
dose_count?: number
medication_days?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
}
@@ -428,6 +480,11 @@ export function prescriptionOrderLogs(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
}
/** 修改订单金额 */
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
}
// ========== 处方库 ==========
// 处方库列表
@@ -454,3 +511,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
export function prescriptionLibraryDetail(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
}
// ========== 诊单待办事项(T5 ==========
/** 待办列表(按 diagnosis_id */
export function diagnosisTodoLists(params: {
diagnosis_id: number
page_no?: number
page_size?: number
status?: number
creator_id?: number
}) {
return request.get({ url: '/tcm.diagnosisTodo/lists', params })
}
/** 新增待办(remind_time 为 unix 秒级时间戳) */
export function diagnosisTodoAdd(params: {
diagnosis_id: number
content: string
remind_time: number
}) {
return request.post({ url: '/tcm.diagnosisTodo/add', params })
}
/** 取消待办(仅 status=0 / 创建人或超管) */
export function diagnosisTodoCancel(params: { id: number }) {
return request.post({ url: '/tcm.diagnosisTodo/cancel', params })
}
/** 待办详情 */
export function diagnosisTodoDetail(params: { id: number }) {
return request.get({ url: '/tcm.diagnosisTodo/detail', params })
}
@@ -9,17 +9,35 @@
v-for="item in notifications"
:key="item.id"
class="chat-notify-card"
:class="{ 'chat-notify-card-complete': item.type === 'consultation_complete' }"
:class="{
'chat-notify-card-complete': item.type === 'consultation_complete',
'chat-notify-card-left': item.type === 'patient_left_chat'
}"
@click="handleClick(item)"
>
<div class="chat-notify-icon" :class="{ 'chat-notify-icon-complete': item.type === 'consultation_complete' }">
<div
class="chat-notify-icon"
:class="{
'chat-notify-icon-complete': item.type === 'consultation_complete',
'chat-notify-icon-left': item.type === 'patient_left_chat'
}"
>
<el-icon :size="22">
<ChatDotRound v-if="item.type !== 'consultation_complete'" />
<CircleCheck v-else />
<ChatDotRound v-if="item.type !== 'consultation_complete' && item.type !== 'patient_left_chat'" />
<CircleCheck v-else-if="item.type === 'consultation_complete'" />
<Right v-else />
</el-icon>
</div>
<div class="chat-notify-body">
<div class="chat-notify-title">{{ item.type === 'consultation_complete' ? '面诊结束' : '患者打开会话' }}</div>
<div class="chat-notify-title">
{{
item.type === 'consultation_complete'
? '面诊结束'
: item.type === 'patient_left_chat'
? '患者离开会话'
: '患者打开会话'
}}
</div>
<div class="chat-notify-desc">
<template v-if="item.type === 'consultation_complete'">
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
@@ -27,6 +45,10 @@
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
完成请及时跟进
</template>
<template v-else-if="item.type === 'patient_left_chat'">
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
已离开问诊会话页面
</template>
<template v-else>
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
已打开与您的会话请及时查看
@@ -45,14 +67,14 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ChatDotRound, CircleCheck, Close } from '@element-plus/icons-vue'
import { ChatDotRound, CircleCheck, Close, Right } from '@element-plus/icons-vue'
import { getChatNotifications } from '@/api/chat'
const router = useRouter()
export interface ChatNotifyItem {
id: string
type?: 'patient_opened_chat' | 'consultation_complete'
type?: 'patient_opened_chat' | 'patient_left_chat' | 'consultation_complete'
doctor_id?: number
assistant_id?: number
patient_id?: string
@@ -173,6 +195,15 @@ html.dark .chat-notify-card:hover {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.chat-notify-card-left {
border-color: rgba(148, 163, 184, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(148, 163, 184, 0.08);
}
.chat-notify-icon-left {
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
}
.chat-notify-body {
flex: 1;
min-width: 0;
+174 -3
View File
@@ -9,7 +9,17 @@
@close="handleClose"
>
<template v-if="!hiddenUpload" #trigger>
<div class="material-select__trigger clearfix" @click.stop>
<div
class="material-select__trigger clearfix"
:class="{ 'is-drop-hover': dropActive }"
tabindex="0"
@click.stop
@dragenter.prevent.stop="onTriggerDragEnter"
@dragleave.prevent.stop="onTriggerDragLeave"
@dragover.prevent.stop="onTriggerDragOver"
@drop.prevent.stop="onTriggerDrop"
@paste="onTriggerPaste"
>
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
<template v-slot:item="{ element, index }">
<div
@@ -82,8 +92,10 @@
import { useThrottleFn } from '@vueuse/core'
import Draggable from 'vuedraggable'
import { uploadMaterialFile } from '@/api/file'
import Popup from '@/components/popup/index.vue'
import useAppStore from '@/stores/modules/app'
import feedback from '@/utils/feedback'
import FileItem from './file.vue'
import Material from './index.vue'
@@ -149,6 +161,11 @@ export default defineComponent({
excludeDomain: {
type: Boolean,
default: false
},
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
uploadCid: {
type: [Number, String],
default: 0
}
},
@@ -162,8 +179,149 @@ export default defineComponent({
const select = ref<any[]>([])
const isAdd = ref(true)
const currentIndex = ref(-1)
const dragDepth = ref(0)
const externalUploading = ref(false)
const { disabled, limit, modelValue } = toRefs(props)
const { getImageUrl } = useAppStore()
const appStore = useAppStore()
const getImageUrl = (url: string) => appStore.getImageUrl(url)
const allowDropPaste = computed(
() =>
!props.hiddenUpload &&
(props.type === 'image' || props.type === 'video' || props.type === 'file')
)
const dropActive = computed(() => dragDepth.value > 0)
function hasFileDrag(dt: DataTransfer | null) {
if (!dt) return false
const types = Array.from(dt.types as unknown as string[])
return types.includes('Files') || types.includes('application/x-moz-file')
}
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
if (uploadType === 'file') return true
const ext = fileName.includes('.')
? fileName.split('.').pop()?.toLowerCase() || ''
: ''
if (uploadType === 'image') {
if (!ext) return true
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
}
if (uploadType === 'video') {
if (!ext) return true
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
ext
)
}
return true
}
function toRelativeStoredUrl(fullUrl: string): string {
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
const oss = String(appStore.config?.oss_domain || '')
.trim()
.replace(/\/$/, '')
if (oss && fullUrl.startsWith(oss)) {
return fullUrl.slice(oss.length).replace(/^\//, '')
}
const origin =
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
if (origin && fullUrl.startsWith(origin)) {
return fullUrl.slice(origin.length).replace(/^\//, '')
}
try {
return new URL(fullUrl).pathname.replace(/^\//, '')
} catch {
return fullUrl
}
}
function clipboardFiles(e: ClipboardEvent): File[] {
const uploadType = props.type
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
isAcceptedExtension(f.name, uploadType)
)
if (fromFiles.length) return fromFiles
if (uploadType !== 'image') return []
const items = e.clipboardData?.items
if (!items) return []
const out: File[] = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) out.push(f)
}
}
return out
}
async function runExternalUpload(files: File[]) {
if (externalUploading.value) return
const maxSlots =
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
if (!maxSlots) {
feedback.msgWarning('已达到上传数量上限')
return
}
const queue = files.slice(0, maxSlots)
if (!queue.length) return
const uploadType =
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
externalUploading.value = true
try {
const paths: string[] = []
for (const file of queue) {
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
}
fileList.value = [...fileList.value, ...paths]
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
emit('update:modelValue', valueImg)
emit('change', valueImg)
handleClose()
} catch (err: any) {
feedback.msgError(err?.message || '上传失败')
} finally {
externalUploading.value = false
}
}
const onTriggerDragEnter = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (!hasFileDrag(e.dataTransfer)) return
dragDepth.value++
}
const onTriggerDragLeave = () => {
if (!allowDropPaste.value) return
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
const onTriggerDragOver = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
}
const onTriggerDrop = async (e: DragEvent) => {
dragDepth.value = 0
if (!allowDropPaste.value || disabled.value) return
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
isAcceptedExtension(f.name, props.type)
)
if (!files.length) return
await runExternalUpload(files)
}
const onTriggerPaste = async (e: ClipboardEvent) => {
if (!allowDropPaste.value || disabled.value) return
const files = clipboardFiles(e)
if (!files.length) return
e.preventDefault()
await runExternalUpload(files)
}
const tipsText = computed(() => {
switch (props.type) {
case 'image':
@@ -264,7 +422,13 @@ export default defineComponent({
showPreview,
handlePreview,
handleClose,
getImageUrl
getImageUrl,
dropActive,
onTriggerDragEnter,
onTriggerDragLeave,
onTriggerDragOver,
onTriggerDrop,
onTriggerPaste
}
}
})
@@ -272,6 +436,13 @@ export default defineComponent({
<style scoped lang="scss">
.material-select {
.material-select__trigger {
outline: none;
&.is-drop-hover {
border-radius: 4px;
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
}
.material-upload,
.material-preview {
position: relative;
+249 -2
View File
@@ -80,6 +80,7 @@
中药处方 (RP)
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
<el-button type="warning" size="small" @click="openPasteRecipeDialog">导入药方</el-button>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
@@ -190,6 +191,18 @@
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
</el-form-item>
</el-col>
<el-col v-if="formData.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
<el-select v-model="formData.dosage_bag_count" placeholder="请选择袋数" class="!w-[120px]">
<el-option label="1袋" :value="1" />
<el-option label="2袋" :value="2" />
<el-option label="3袋" :value="3" />
<el-option label="4袋" :value="4" />
<el-option label="5袋" :value="5" />
</el-select>
</el-form-item>
</el-col>
<!-- 饮片每贴出包数 -->
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
@@ -407,9 +420,9 @@
<div class="prescription-footer">
<div class="footer-left">
<div class="footer-dosage">
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}, {{ savedPrescription.prescription_type }}
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}{{ savedDosageDescText ? '' + savedDosageDescText : '' }}, {{ savedPrescription.prescription_type }}
{{ savedPrescription.usage_instruction }}
</div>
<div class="footer-dosage">
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
@@ -649,6 +662,40 @@
/>
</div>
</el-dialog>
<!-- 粘贴药方文本解析药名与剂量 -->
<el-dialog
v-model="showPasteRecipeDialog"
title="导入药方(识别药材)"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="pasteRecipeText = ''"
>
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
粘贴整张药方文本系统按药名 + 剂量解析药名须与药品库中名称完全一致才会录入否则跳过支持顿号/逗号/换行分隔单行可用空格分味黄芪15g
白术10g黄芪党参各10克等同剂量写法亦可识别
</p>
<el-input
v-model="pasteRecipeText"
type="textarea"
:rows="10"
placeholder="示例:&#10;黄芪15 党参12 茯苓10&#10;柴胡10g、黄芩10、半夏12克&#10;黄芪、白术、茯苓各15克"
/>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="pasteRecipeImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
@@ -656,6 +703,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import feedback from '@/utils/feedback'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
import { medicineLists } from '@/api/medicine'
import { getDictData } from '@/api/app'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
@@ -676,6 +724,7 @@ interface PrescriptionForm {
prescription_type: string
dosage_amount: number | undefined // 用量数值
dosage_unit: string // 用量单位 (g 或 ml)
dosage_bag_count: number // 用量袋数(浓缩水丸,1-5袋)
need_decoction: boolean // 是否代煎(仅饮片)
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
patient_name: string
@@ -739,6 +788,20 @@ const hasPrescriptionOrderBlockVoid = computed(() => {
const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单'))
/** 处方笺底部"用量/袋数"展示文案:浓缩水丸显示"一次X袋(每袋Yg)",其它类型显示"一次Yg" */
const savedDosageDescText = computed(() => {
const v = savedPrescription.value
if (!v) return ''
if (v.dosage_amount == null || v.dosage_amount === '') return ''
const amount = Number(v.dosage_amount)
const unit = v.dosage_unit || 'g'
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
return `一次${bags}袋(每袋${amount}${unit}`
}
return `一次${amount}${unit}`
})
// 处方库相关
const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
@@ -751,6 +814,179 @@ const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
/** 粘贴药方导入 */
const showPasteRecipeDialog = ref(false)
const pasteRecipeText = ref('')
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
const pasteRecipeImportLoading = ref(false)
const PASTE_RECIPE_SKIP_SEGMENT =
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
function normalizeRecipeInput(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48))
return s.trim()
}
function stripRecipeHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
/** 单段解析为多味(含「各」),失败返回 null */
function parseHerbToken(seg: string): { name: string; dosage: number }[] | null {
const s = seg.trim()
if (!s || PASTE_RECIPE_SKIP_SEGMENT.test(s)) return null
if (/^\d/.test(s)) return null
if (/^\d+\s*剂$/.test(s)) return null
const ge = s.match(/^(.+)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$/i)
if (ge) {
const dose = parseFloat(ge[2])
if (!Number.isFinite(dose) || dose <= 0) return null
const names = ge[1]
.split(/[、,,\s]+/)
.map((x) => x.trim())
.filter(Boolean)
if (!names.length) return null
return names.map((name) => ({ name: stripHerbNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipeText(text: string): string[] {
const t = stripRecipeHeaders(normalizeRecipeInput(text))
const out: string[] = []
for (const line of t.split(/\n+/)) {
const ln = line.trim()
if (!ln) continue
for (const piece of ln.split(/[、,,;]/)) {
const p = piece.trim()
if (!p) continue
if (/\s/.test(p) && !parseHerbToken(p)) {
const sub = p.split(/\s+(?=[\u4e00-\u9fff]{2,})/)
for (const x of sub) {
const c = x.trim()
if (c) out.push(c)
}
} else {
out.push(p)
}
}
}
return out
}
function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipeText(text)) {
const parsed = parseHerbToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测) */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
const res = await medicineLists({
name: q,
page_no: 1,
page_size: 200,
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
}
function openPasteRecipeDialog() {
showPasteRecipeDialog.value = true
}
async function handlePasteRecipeImport() {
const parsed = parseRecipeToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: Herb[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolveHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
feedback.msgWarning(
skippedUnique.length
? `药品库中无以下完全同名药材,未录入:${skippedUnique.join('、')}`
: '未能匹配到药品库中的药材'
)
return
}
if (pasteRecipeImportMode.value === 'replace') {
formData.herbs = resolved
} else {
formData.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNames()
const skipHint =
skippedUnique.length > 0 ? `;药品库未收录已跳过:${skippedUnique.join('、')}` : ''
if (dupNames.length) {
feedback.msgWarning(
`已导入 ${resolved.length} 味药材${skipHint}。存在重复药名:${dupNames.join(
'、'
)},请合并剂量或删除多余行`
)
} else if (skippedUnique.length) {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材${skipHint},请核对剂量`)
} else {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材,请核对药名与剂量`)
}
showPasteRecipeDialog.value = false
pasteRecipeText.value = ''
} finally {
pasteRecipeImportLoading.value = false
}
}
const templateConfig = reactive({
stationName: '成都双流甄养堂互联网医院',
showDisclaimer: true,
@@ -764,6 +1000,7 @@ const formData = reactive<PrescriptionForm>({
prescription_type: '浓缩水丸',
dosage_amount: 10,
dosage_unit: 'g',
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
patient_name: '',
@@ -954,6 +1191,7 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.prescription_type = '浓缩水丸'
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
formData.gender_desc = formData.gender === 1 ? '男' : '女'
@@ -980,6 +1218,8 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.doctor_name = userStore.userInfo?.name || ''
formData.doctor_signature = ''
formData.is_shared = 0
formData.need_decoction = false
formData.bags_per_dose = 1
savedPrescription.value = null
visible.value = true
@@ -1206,16 +1446,19 @@ watch(() => formData.prescription_type, (newType) => {
if (newType === '浓缩水丸') {
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
formData.need_decoction = false
formData.bags_per_dose = 1
} else if (newType === '饮片') {
formData.dosage_unit = 'ml'
formData.dosage_amount = 50
formData.dosage_bag_count = 1
formData.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
formData.dosage_unit = 'g'
formData.dosage_amount = undefined
formData.dosage_bag_count = 1
formData.need_decoction = false
formData.bags_per_dose = 1
}
@@ -1275,6 +1518,7 @@ const handleSave = async () => {
dose_unit: formData.dose_unit,
dosage_amount: formData.dosage_amount,
dosage_unit: formData.dosage_unit,
dosage_bag_count: formData.dosage_bag_count,
need_decoction: formData.need_decoction,
bags_per_dose: formData.bags_per_dose,
usage_days: formData.usage_days,
@@ -1354,12 +1598,15 @@ const handleNewPrescription = async () => {
if (formData.prescription_type === '浓缩水丸') {
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
} else if (formData.prescription_type === '饮片') {
formData.dosage_unit = 'ml'
formData.dosage_amount = 50
formData.dosage_bag_count = 1
} else {
formData.dosage_unit = 'g'
formData.dosage_amount = undefined
formData.dosage_bag_count = 1
}
formData.patient_name = saved.patient_name || ''
formData.gender = saved.gender ?? 0
+75 -1
View File
@@ -9,6 +9,7 @@
:show-file-list="false"
:headers="headers"
:data="data"
:http-request="useDirect ? httpRequest : undefined"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-exceed="handleExceed"
@@ -41,7 +42,7 @@
</template>
<script lang="ts">
import type { ElUpload } from 'element-plus'
import type { ElUpload, UploadRequestOptions } from 'element-plus'
import { computed, defineComponent, ref, shallowRef } from 'vue'
import config from '@/config'
@@ -49,6 +50,10 @@ import { RequestCodeEnum } from '@/enums/requestEnums'
import useAppStore from '@/stores/modules/app'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import {
DirectUploadFallbackError,
uploadVideoDirectToCos
} from '@/utils/oss-direct-upload'
export default defineComponent({
components: {},
@@ -77,6 +82,11 @@ export default defineComponent({
showProgress: {
type: Boolean,
default: false
},
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
direct: {
type: Boolean,
default: false
}
},
emits: ['change', 'error', 'success', 'allSuccess'],
@@ -92,6 +102,9 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// 仅 video + direct 时才接管 http-request
const useDirect = computed(() => props.direct && props.type === 'video')
const handleProgress = () => {
visible.value = true
}
@@ -142,12 +155,73 @@ export default defineComponent({
return '*'
}
})
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
const httpRequest = async (options: UploadRequestOptions) => {
visible.value = true
try {
const data = await uploadVideoDirectToCos({
file: options.file,
type: 'video',
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
;(options as any).onProgress?.({
percent: info.percent,
loaded: info.loaded,
total: info.total
})
}
})
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
} catch (err: any) {
if (err instanceof DirectUploadFallbackError) {
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
await defaultXhrUpload(options)
return
}
;(options as any).onError?.(err instanceof Error ? err : new Error(String(err?.message ?? err)))
}
}
// 当 STS 不可用时,回退到 ElUpload 原生 XHR 行为(POST /upload/{type}
const defaultXhrUpload = (options: UploadRequestOptions) =>
new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', action.value)
Object.entries(headers.value).forEach(([k, v]) => xhr.setRequestHeader(k, String(v ?? '')))
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return
const percent = (e.loaded / e.total) * 100
;(options as any).onProgress?.({ percent, loaded: e.loaded, total: e.total })
}
xhr.onload = () => {
try {
const json = JSON.parse(xhr.responseText)
;(options as any).onSuccess?.(json)
} catch (e) {
;(options as any).onError?.(new Error('上传响应解析失败'))
}
resolve()
}
xhr.onerror = () => {
;(options as any).onError?.(new Error('网络错误'))
resolve()
}
const formData = new FormData()
formData.append('file', options.file)
Object.entries(options.data || {}).forEach(([k, v]) => formData.append(k, String(v)))
xhr.send(formData)
})
return {
uploadRefs,
action,
headers,
visible,
fileList,
useDirect,
httpRequest,
getAccept,
handleProgress,
handleSuccess,
+2
View File
@@ -3,6 +3,7 @@
//引入柱状图图表,图表后缀都为 Chart
import {
BarChart,
FunnelChart,
LineChart,
MapChart,
PictorialBarChart,
@@ -43,6 +44,7 @@ echarts.use([
AriaComponent,
ParallelComponent,
BarChart,
FunnelChart,
LineChart,
PieChart,
MapChart,
+2 -1
View File
@@ -1,6 +1,7 @@
<template>
<div class="layout-default flex h-screen w-full">
<div class="app-aside">
<!-- shrink-0避免主内容区宽表格/图表把侧栏压缩到折叠宽度 -->
<div class="app-aside shrink-0">
<layout-sidebar />
</div>
+18
View File
@@ -82,6 +82,24 @@ export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/tcm/diagnosis/h5',
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
},
{
// T1+T2:患者信息详情页(隐藏路由 — 不在侧边栏出现,由诊单列表的「查看」/双击行驱动跳转)
path: '/tcm/diagnosis-readonly',
component: LAYOUT,
meta: { hidden: true },
children: [
{
path: '',
component: () => import('@/views/tcm/diagnosis/readonly.vue'),
name: 'tcmDiagnosisReadonly',
meta: {
title: '患者信息详情',
hidden: true,
activeMenu: '/tcm/diagnosis'
}
}
]
}
// {
// path: '/dev_tools',
+86
View File
@@ -0,0 +1,86 @@
/**
* 血糖 / 血压 阈值与异常判定工具
*
* 阈值规则(与 reception/index.vue 抽离前一致):
* - 年龄 < 50:空腹血糖 ≥ 7、餐后/其他血糖 ≥ 9 视为偏高
* - 年龄 ≥ 50:空腹血糖 ≥ 8、餐后/其他血糖 ≥ 10 视为偏高
* - 收缩压 > 140 / 舒张压 > 90 视为偏高
*
* 任何字段缺失或非数值 → 视为「不偏高」(避免误报红色 ↑)。
*/
/**
* 把任意值转成有限数字;空 / 非法 → null
*/
export function toNumeric(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const num = Number(value as any)
return Number.isFinite(num) ? num : null
}
/**
* 根据年龄返回血糖阈值;年龄缺失/非法 → null(=不判定偏高)
*/
export function getBloodSugarThresholds(
ageValue: unknown
): { fasting: number; postprandial: number } | null {
const age = toNumeric(ageValue)
if (age === null) return null
if (age < 50) return { fasting: 7, postprandial: 9 }
return { fasting: 8, postprandial: 10 }
}
export function isHighFastingBloodSugar(value: unknown, ageValue: unknown): boolean {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.fasting
}
export function isHighPostprandialBloodSugar(value: unknown, ageValue: unknown): boolean {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
/**
* 「其他血糖」按餐后阈值判定(与 reception 原实现一致)
*/
export function isHighOtherBloodSugar(value: unknown, ageValue: unknown): boolean {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
export function isHighSystolicPressure(value: unknown): boolean {
const num = toNumeric(value)
return num !== null && num > 140
}
export function isHighDiastolicPressure(value: unknown): boolean {
const num = toNumeric(value)
return num !== null && num > 90
}
/**
* 一行血糖血压记录是否有任一压偏高
*/
export function isHighBloodPressure(row: {
systolic_pressure?: unknown
diastolic_pressure?: unknown
} | null | undefined): boolean {
if (!row) return false
return isHighSystolicPressure(row.systolic_pressure) || isHighDiastolicPressure(row.diastolic_pressure)
}
/**
* 把一行记录的血压两值格式化为 `xxx / yyy`,缺值 → `—`
*/
export function formatBp(row: {
systolic_pressure?: unknown
diastolic_pressure?: unknown
} | null | undefined): string {
const sys = row?.systolic_pressure
const dia = row?.diastolic_pressure
if (sys == null && dia == null) return '—'
return `${sys ?? '—'} / ${dia ?? '—'}`
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 病例 / 接诊页通用展示辅助
*
* - maskPhone:手机号脱敏 138****1234
* - formatGender0/1 → 女/男
* - formatPeriodmorning/afternoon → 上午/下午
* - joinArray:数组 → 顿号拼接,非数组 → toString
* - makeTextOf:工厂,传入 diag ref/getter,返回闭包后的 textOf 函数
*
* 翻译规则(textOf):诊单字典字段由后端 `AppointmentLogic::enrichDiagnosisLabels`
* 预翻译为 `<field>_text`,前端优先读 `*_text`;找不到时回退原值。
*/
import type { Ref } from 'vue'
export function maskPhone(phone?: string | null): string {
if (!phone) return '—'
if (phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
export function formatGender(g?: number | null): string {
if (g === 1) return '男'
if (g === 0) return '女'
return '—'
}
export function formatPeriod(period?: string | null): string {
if (period === 'morning') return '上午'
if (period === 'afternoon') return '下午'
return '全天'
}
export function joinArray(arr: unknown): string {
if (!arr) return ''
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
return String(arr)
}
/**
* 创建闭包了具体 diag 的 textOf 取值器。
* 同时支持 `Ref` / `() => any` / 普通对象,避免在调用方处反复样板。
*/
export function makeTextOf(diagSource: Ref<any> | (() => any) | Record<string, any>) {
const resolve = (): Record<string, any> => {
if (typeof diagSource === 'function') {
return (diagSource as () => any)() || {}
}
if (diagSource && typeof diagSource === 'object' && 'value' in diagSource) {
return ((diagSource as Ref<any>).value ?? {}) as Record<string, any>
}
return (diagSource as Record<string, any>) || {}
}
return function textOf(field: string): string {
const d = resolve()
const t = d[field + '_text']
if (t !== undefined && t !== null && t !== '') return String(t)
const raw = d[field]
if (raw === undefined || raw === null || raw === '') return ''
return Array.isArray(raw) ? raw.join('、') : String(raw)
}
}
@@ -99,6 +99,15 @@ export function parseImBusinessPayload(raw: string): FriendlyParse | null {
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'patient_closed_chat') {
const parts: string[] = []
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID${String(o.patientId)}`)
const t = formatBizTime(o.time)
if (t) parts.push(t)
return { main: '患者离开聊天页', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'user_typing_status') {
return { main: '对方正在输入…', tag: '状态' }
}
+142
View File
@@ -0,0 +1,142 @@
import COS from 'cos-js-sdk-v5'
import {
confirmOssUpload,
getOssCredentials,
type OssCredentialsResponse
} from '@/api/file'
export type DirectUploadType = 'video'
export interface DirectUploadProgress {
/** 0-100 */
percent: number
loaded: number
total: number
speed: number
}
export interface DirectUploadResult {
id: number
cid: number
type: number
name: string
uri: string
url: string
}
export interface DirectUploadOptions {
file: File
type: DirectUploadType
cid?: number
onProgress?: (progress: DirectUploadProgress) => void
/** 返回 false 时由调用方自行降级到老链路 */
onFallback?: (provider: string) => boolean | void
}
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
const ASYNC_LIMIT = 3
function buildKey(prefix: string, file: File): string {
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
const ts = Date.now()
const rand = Math.random().toString(36).slice(2, 10)
return `${prefix}${ts}-${rand}.${ext}`
}
/**
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
*/
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
if (credentials.fallback) {
const handled = options.onFallback?.(credentials.provider) ?? false
if (!handled) {
throw new DirectUploadFallbackError(credentials.provider)
}
// 调用方自行处理,本函数早退
throw new DirectUploadFallbackError(credentials.provider)
}
if (!credentials.credentials || !credentials.bucket || !credentials.region || !credentials.key_prefix) {
throw new Error('STS 凭证返回缺失字段')
}
if (credentials.max_size && options.file.size > credentials.max_size) {
const mb = Math.round(credentials.max_size / 1024 / 1024)
throw new Error(`视频体积超出上限(${mb}MB`)
}
const cred = credentials.credentials
const expiredTime = credentials.expired_time ?? 0
const startTime = credentials.start_time ?? Math.floor(Date.now() / 1000)
const cos = new COS({
getAuthorization(_options, callback) {
callback({
TmpSecretId: cred.tmpSecretId,
TmpSecretKey: cred.tmpSecretKey,
SecurityToken: cred.sessionToken,
StartTime: startTime,
ExpiredTime: expiredTime
})
}
})
const key = buildKey(credentials.key_prefix, options.file)
await new Promise<void>((resolve, reject) => {
cos.uploadFile(
{
Bucket: credentials.bucket as string,
Region: credentials.region as string,
Key: key,
Body: options.file,
SliceSize: SLICE_SIZE,
AsyncLimit: ASYNC_LIMIT,
onProgress(info) {
if (!options.onProgress) return
const total = info.total || options.file.size
options.onProgress({
percent: Math.min(99, Math.round((info.percent || 0) * 100)),
loaded: info.loaded || 0,
total,
speed: info.speed || 0
})
}
},
(err) => {
if (err) {
reject(err)
return
}
resolve()
}
)
})
const confirmed = await confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
return confirmed
}
/** 当后端 fallback=true 时抛出,调用方据此降级到老链路 */
export class DirectUploadFallbackError extends Error {
readonly provider: string
constructor(provider: string) {
super(`COS 直传不可用,当前驱动: ${provider}`)
this.name = 'DirectUploadFallbackError'
this.provider = provider
}
}
+28 -19
View File
@@ -60,25 +60,26 @@
<el-divider content-position="left">组织信息</el-divider>
<!-- 归属部门 -->
<!-- 归属部门数据权限内仅末级可选父级仅用于展开 -->
<el-form-item label="归属部门" prop="dept_id">
<el-tree-select
class="flex-1"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled(data: any) {
return data.status !== 1
}
}"
check-strictly
:default-expand-all="true"
placeholder="请选择归属部门"
/>
<div class="w-full">
<el-tree-select
class="flex-1 w-full"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled: deptTreeNodeDisabled
}"
check-strictly
:default-expand-all="true"
placeholder="请选择末级归属部门"
/>
<div class="form-tips">仅可选末级部门含下级的父部门不可选与数据权限范围一致</div>
</div>
</el-form-item>
<!-- 岗位 -->
@@ -215,6 +216,13 @@ import { getDictData } from '@/api/app'
import { useDictOptions } from '@/hooks/useDictOptions'
import feedback from '@/utils/feedback'
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
if (data.status !== 1) return true
const ch = data.children
return Array.isArray(ch) && ch.length > 0
}
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const visible = ref(false)
@@ -264,7 +272,8 @@ const { optionsData } = useDictOptions<{
api: jobsAll
},
dept: {
api: deptAll
api: deptAll,
params: { apply_data_scope: 1 }
}
})
+28 -19
View File
@@ -54,25 +54,26 @@
<el-divider content-position="left">组织信息</el-divider>
<!-- 归属部门 -->
<!-- 归属部门数据权限内仅末级可选父级仅用于展开 -->
<el-form-item label="归属部门" prop="dept_id">
<el-tree-select
class="flex-1"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled(data: any) {
return data.status !== 1
}
}"
check-strictly
:default-expand-all="true"
placeholder="请选择归属部门"
/>
<div class="w-full">
<el-tree-select
class="flex-1 w-full"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled: deptTreeNodeDisabled
}"
check-strictly
:default-expand-all="true"
placeholder="请选择末级归属部门"
/>
<div class="form-tips">仅可选末级部门含下级的父部门不可选与数据权限范围一致</div>
</div>
</el-form-item>
<!-- 岗位 -->
@@ -254,6 +255,13 @@ import { getDictData } from '@/api/app'
import { useDictOptions } from '@/hooks/useDictOptions'
import feedback from '@/utils/feedback'
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
if (data.status !== 1) return true
const ch = data.children
return Array.isArray(ch) && ch.length > 0
}
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const visible = ref(false)
@@ -307,7 +315,8 @@ const { optionsData } = useDictOptions<{
api: jobsAll
},
dept: {
api: deptAll
api: deptAll,
params: { apply_data_scope: 1 }
}
})
+3 -2
View File
@@ -99,11 +99,12 @@ import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
// 表单数据 - 固定role_id=1查询医生
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
const formData = reactive({
account: '',
name: '',
role_id: 1 // 固定为医生角色
role_id: 1,
apply_data_scope: 1
})
const showEdit = ref(false)
+160 -9
View File
@@ -669,6 +669,49 @@
</el-table-column>
</el-table>
<el-alert
v-if="editMode === 'edit'"
type="warning"
:closable="false"
show-icon
class="mt-3"
v-loading="rxLinkedOrderHint.loading"
>
<template #title>业务订单要求(请与本处方保持一致)</template>
<div class="text-sm mt-1 leading-relaxed">
<template
v-if="
rxLinkedOrderHint.orderNo ||
rxLinkedOrderHint.medicationDays != null ||
rxLinkedOrderHint.remarkAssistant
"
>
<p v-if="rxLinkedOrderHint.orderNo" class="mb-1.5">
最近关联订单(按创建倒序):
<span class="font-mono text-primary">{{ rxLinkedOrderHint.orderNo }}</span>
</p>
<p class="mb-1.5">
<span class="font-semibold">服用天数(订单)</span>
{{
rxLinkedOrderHint.medicationDays != null
? `${rxLinkedOrderHint.medicationDays} 天`
: '— 订单未填写'
}}
</p>
<p>
<span class="font-semibold">医助备注(订单)</span>
<span class="whitespace-pre-wrap">{{
rxLinkedOrderHint.remarkAssistant || '— 无'
}}</span>
</p>
</template>
<p v-else-if="!rxLinkedOrderHint.loading" class="text-gray-600">
当前处方暂无关联业务订单,或您无订单列表权限;若有订单,将在此处显示订单内
<span class="font-semibold">服用天数</span>与
<span class="font-semibold">医助备注</span>。
</p>
</div>
</el-alert>
</div>
</el-form-item>
@@ -755,6 +798,17 @@
/>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
<el-select v-model="editForm.dosage_bag_count" placeholder="请选择袋数" class="w-[120px]">
<el-option label="1袋" :value="1" />
<el-option label="2袋" :value="2" />
<el-option label="3袋" :value="3" />
<el-option label="4袋" :value="4" />
<el-option label="5袋" :value="5" />
</el-select>
</el-form-item>
</el-col>
<!-- 饮片代煎选项 -->
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
@@ -1183,7 +1237,7 @@
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
<el-input
v-model="createOrderForm.remark_extra"
type="textarea"
@@ -1193,6 +1247,16 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="医助备注">
<el-input
v-model="createOrderForm.remark_assistant"
type="textarea"
:rows="3"
placeholder="选填,医助对内说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
@@ -1333,6 +1397,7 @@ import {
prescriptionDetail,
prescriptionOrderCreate,
prescriptionOrderPaidPayOrders,
prescriptionOrderLists,
getDoctors,
prescriptionLibraryLists
} from '@/api/tcm'
@@ -1475,6 +1540,7 @@ const createOrderForm = reactive({
amount: 0,
internal_cost: undefined as number | undefined,
remark_extra: '',
remark_assistant: '',
pay_order_ids: [] as number[]
})
@@ -1714,6 +1780,7 @@ function resetCreateOrderForm() {
createOrderForm.amount = 0
createOrderForm.internal_cost = undefined
createOrderForm.remark_extra = ''
createOrderForm.remark_assistant = ''
createOrderForm.pay_order_ids = []
createOrderPatientList.value = []
createOrderPaidOrders.value = []
@@ -1884,7 +1951,8 @@ async function submitCreateOrderFromPrescription() {
tracking_number: createOrderForm.tracking_number || '',
fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || ''
remark_extra: createOrderForm.remark_extra || '',
remark_assistant: createOrderForm.remark_assistant || ''
}
// 添加省市区字段
@@ -2020,15 +2088,19 @@ const rxUsageText = computed(() => {
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}次`)
seg.push(`一次${bags}包(约${amount}${unit})`)
seg.push(`每包${amount}${unit}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}袋`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
@@ -2066,6 +2138,22 @@ const rxOutPelletText = computed(() => {
function computeOutPellet(v: any): string {
if (!v) return ''
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const days = Number(v.medication_days ?? v.usage_days ?? v.dose_count)
const times = Number(v.times_per_day)
const doseG = Number(v.dosage_amount)
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
if (
Number.isFinite(days) &&
Number.isFinite(times) &&
Number.isFinite(doseG) &&
days > 0 &&
times > 0 &&
doseG > 0
) {
return formatNum(days * times * doseG * bags)
}
}
const explicit = v.out_pellet || v.total_weight
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
const list = slipHerbsList.value
@@ -2197,6 +2285,7 @@ const editForm = reactive({
prescription_type: '浓缩水丸',
dosage_amount: 1 as number | undefined,
dosage_unit: 'g',
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
patient_name: '',
@@ -2238,6 +2327,52 @@ const editForm = reactive({
times_per_day: 2
})
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
const rxLinkedOrderHint = reactive({
loading: false,
orderNo: '',
medicationDays: null as number | null,
remarkAssistant: ''
})
function clearRxLinkedOrderHint() {
rxLinkedOrderHint.loading = false
rxLinkedOrderHint.orderNo = ''
rxLinkedOrderHint.medicationDays = null
rxLinkedOrderHint.remarkAssistant = ''
}
async function loadRxLinkedOrderHintForEdit(prescriptionId: number) {
rxLinkedOrderHint.orderNo = ''
rxLinkedOrderHint.medicationDays = null
rxLinkedOrderHint.remarkAssistant = ''
if (!prescriptionId) {
rxLinkedOrderHint.loading = false
return
}
rxLinkedOrderHint.loading = true
try {
const data: any = await prescriptionOrderLists({
page_no: 1,
page_size: 5,
prescription_id: prescriptionId
})
const lists = data?.lists
const row = Array.isArray(lists) && lists.length ? lists[0] : null
if (row) {
rxLinkedOrderHint.orderNo = String(row.order_no || '').trim()
const md = row.medication_days
const mdn = md !== null && md !== undefined && String(md).trim() !== '' ? Number(md) : NaN
rxLinkedOrderHint.medicationDays = Number.isFinite(mdn) && mdn > 0 ? mdn : null
rxLinkedOrderHint.remarkAssistant = String(row.remark_assistant || '').trim()
}
} catch {
/* 无列表权限或网络错误:保持空 */
} finally {
rxLinkedOrderHint.loading = false
}
}
const diagnosisViewRef = ref<{
openViewOnly: (id: number) => Promise<void>
} | null>(null)
@@ -2264,16 +2399,19 @@ watch(() => editForm.prescription_type, (newType) => {
if (newType === '浓缩水丸') {
editForm.dosage_unit = 'g'
editForm.dosage_amount = 1
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
} else if (newType === '饮片') {
editForm.dosage_unit = 'ml'
editForm.dosage_amount = 50
editForm.dosage_bag_count = 1
editForm.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
editForm.dosage_unit = 'g'
editForm.dosage_amount = undefined
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
}
@@ -2850,6 +2988,11 @@ const removeHerb = (index: number) => {
const resetForm = () => {
editForm.id = 0
editForm.prescription_type = '浓缩水丸'
editForm.dosage_amount = 1
editForm.dosage_unit = 'g'
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
editForm.patient_name = ''
editForm.gender = 1
editForm.age = 0
@@ -2882,6 +3025,7 @@ const resetForm = () => {
editForm.is_system_auto = 0
editForm.business_prescription_audit_rejected = 0
editForm.business_prescription_audit_remark = ''
clearRxLinkedOrderHint()
}
// 新增处方
@@ -2966,6 +3110,7 @@ const handleEdit = async (row: any) => {
}
}
editForm.dosage_bag_count = src.dosage_bag_count ? Number(src.dosage_bag_count) : 1
editForm.need_decoction = src.need_decoction === 1 || src.need_decoction === true
editForm.bags_per_dose = src.bags_per_dose ? Number(src.bags_per_dose) : 1
editForm.patient_name = src.patient_name || ''
@@ -3009,8 +3154,9 @@ const handleEdit = async (row: any) => {
setTimeout(() => {
isLoadingData.value = false
}, 0)
showEdit.value = true
void loadRxLinkedOrderHintForEdit(Number(editForm.id))
nextTick(() => {
formRef.value?.clearValidate()
scheduleEditSignatureInit()
@@ -3020,9 +3166,14 @@ const handleEdit = async (row: any) => {
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
try {
await formRef.value.validate()
} catch {
// Element Plus:校验失败会 reject,表单项已展示错误,勿再向外抛成 Uncaught
return
}
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
@@ -491,7 +491,11 @@
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
<pagination
v-model="pager"
:page-sizes="[15, 20, 30, 40, 100, 150, 200]"
@change="getLists"
/>
</div>
</el-card>
@@ -663,7 +667,19 @@
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
@@ -801,6 +817,9 @@
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
@@ -974,7 +993,7 @@
<span class="font-medium text-[15px]">履约与收货信息</span>
</template>
<el-descriptions :column="3" border size="small" class="po-desc">
<el-descriptions-item label="诊单ID">
<el-descriptions-item label="诊单ID" :span="3">
<span class="font-mono">{{ detailData.diagnosis_id }}</span>
<el-button
v-if="Number(detailData.diagnosis_id) > 0"
@@ -987,14 +1006,48 @@
查看患者信息
</el-button>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="1">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="业务订单创建人" :span="2">
<div v-if="Number(detailData.creator_id) > 0 || detailData.creator_name" class="text-[13px] space-y-1">
<div>
<span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span>
<span v-if="Number(detailData.creator_id) > 0" class="text-gray-500 ml-2 font-mono text-xs"
>ID {{ detailData.creator_id }}</span
>
</div>
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs leading-relaxed">
{{ detailOrderCreatorMetaLine }}
</div>
</div>
<span v-else class="text-gray-400">—</span>
</el-descriptions-item>
<el-descriptions-item label="创建人所属部门" :span="3">
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
<div
v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs"
:key="idx"
class="min-w-0"
>
<el-breadcrumb
v-if="segments.length"
class="po-diagnosis-creator-dept-breadcrumb text-[13px] leading-relaxed"
separator="/"
>
<el-breadcrumb-item v-for="(name, j) in segments" :key="j">
<span class="text-gray-800 break-all">{{ name }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<span v-else class="text-gray-400">—</span>
</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
<el-descriptions-item label="挂号关联" :span="3">
<template v-if="detailLinkedAppointment">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
<span
>预约 <span class="font-mono text-primary">#{{ detailLinkedAppointment.id }}</span></span
>流转编号(挂号) <span class="font-mono text-primary">#{{ detailLinkedAppointment.id }}</span></span
>
<span class="text-gray-500"
>时间:{{ detailLinkedAppointment.appointment_date }}
@@ -1006,8 +1059,8 @@
<span v-if="detailLinkedAppointment.doctor_name"
>接诊:{{detailLinkedAppointment.doctor_name }}</span
>
<span v-if="detailLinkedAppointment.channel_source" class="text-gray-500"
>渠道:{{ detailLinkedAppointment.channel_source }}</span
<span v-if="detailLinkedAppointmentChannelText" class="text-gray-500"
>渠道:{{ detailLinkedAppointmentChannelText }}</span
>
<el-tag v-if="detailLinkedAppointmentResolvedFromTag" size="small" effect="plain">{{
detailLinkedAppointmentResolvedFromTag
@@ -1056,7 +1109,8 @@
{{ expressCompanyLabel(detailData.express_company) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
</el-descriptions-item>
@@ -1181,6 +1235,48 @@
</div>
</el-drawer>
<el-dialog
v-model="updateAmountVisible"
title="修改订单金额"
width="420px"
destroy-on-close
:close-on-click-modal="false"
@closed="resetUpdateAmountDialog"
>
<el-form
ref="updateAmountFormRef"
:model="updateAmountForm"
:rules="updateAmountRules"
label-width="96px"
class="pr-2"
@submit.prevent="submitUpdateAmount"
>
<el-form-item label="当前金额">
<div class="text-sm text-gray-700 font-medium">¥{{ formatMoney(detailData?.amount) }}</div>
</el-form-item>
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0.01"
:step="0.01"
:precision="2"
controls-position="right"
class="w-full"
placeholder="请输入新金额"
/>
</el-form-item>
<div class="text-xs text-gray-400 pl-[96px] -mt-1 leading-relaxed">
已完成或已取消订单不可修改。提交成功后会刷新详情与操作日志。
</div>
</el-form>
<template #footer>
<el-button @click="updateAmountVisible = false">取消</el-button>
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
确认修改
</el-button>
</template>
</el-dialog>
<!-- 编辑(分步向导与「创建业务订单」弹窗统一) -->
<el-dialog
v-model="editVisible"
@@ -1527,7 +1623,7 @@
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
<el-input
v-model="editForm.remark_extra"
type="textarea"
@@ -1537,6 +1633,16 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="医助备注">
<el-input
v-model="editForm.remark_assistant"
type="textarea"
:rows="3"
placeholder="选填,医助对内说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
@@ -1912,6 +2018,12 @@
</div>
</template>
<div v-loading="prescriptionViewLoading" class="rx-wrap">
<!-- 处方签签 -->
<el-tabs v-model="prescriptionTabType" class="mx-4 mt-2" style="margin-bottom: 0px; z-index: 1; position: relative;" v-if="prescriptionViewData">
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<!-- 状态条(不进入打印/导出范围) -->
<div class="rx-statusbar" v-if="prescriptionViewData">
<el-tag
@@ -1962,7 +2074,7 @@
<!-- A4 处方纸(药房联) -->
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
<div class="rx-title">处方单</div>
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : '处方单' }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
@@ -2020,24 +2132,26 @@
</div>
<div class="rx-rp">
<div class="rx-watermark">药房联</div>
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
<div class="rx-rp-head">
<div class="rx-rp-label">Rp.</div>
<div class="rx-rp-cols-head">
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
</div>
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
</div>
</div>
</div>
<div class="rx-herbs">
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
<span class="rx-herb-name" v-if="prescriptionTabType === 'internal'">{{ h.name }} ({{ h.dosage }}克)</span>
<span class="rx-herb-name" v-else>{{ h.name }}</span>
<span class="rx-herb-total" v-if="prescriptionTabType === 'internal'">{{ rxHerbTotal(h.dosage) }}克</span>
<span class="rx-herb-total" v-else>{{ h.dosage }}克</span>
</div>
</div>
</div>
@@ -2258,6 +2372,7 @@
<script lang="ts" setup name="prescriptionOrderList">
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { Refresh, Loading, ArrowDown, InfoFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import {
@@ -2271,6 +2386,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderShip,
prescriptionOrderLogs,
prescriptionOrderUpdateAmount,
prescriptionOrderAddPayOrder,
prescriptionOrderComplete,
prescriptionOrderRevokeRxAudit,
@@ -2296,6 +2412,7 @@ import useUserStore from '@/stores/modules/user'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
const userStore = useUserStore()
const route = useRoute()
const diagnosisViewRef = ref<{
openViewOnly: (id: number) => Promise<void>
@@ -2496,6 +2613,7 @@ const fulfillmentStatusTabs = [
function handleFulfillmentStatusTabClick(value: number | '') {
activeFocusKey.value = ''
queryParams.exclude_fulfillment_cancelled = 0
queryParams.fulfillment_status = value
resetPage()
}
@@ -2546,7 +2664,9 @@ const queryParams = reactive({
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self'
supply_mode: '' as '' | 'gancao' | 'self',
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
exclude_fulfillment_cancelled: 0 as number
})
const rxAuditTabs = [
@@ -2564,11 +2684,13 @@ const payAuditTabs = [
function handleRxAuditTabClick(val: number | '') {
activeFocusKey.value = ''
queryParams.exclude_fulfillment_cancelled = 0
queryParams.prescription_audit_status = val
resetPage()
}
function handlePayAuditTabClick(val: number | '') {
activeFocusKey.value = ''
queryParams.exclude_fulfillment_cancelled = 0
queryParams.payment_slip_audit_status = val
resetPage()
}
@@ -2580,6 +2702,7 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.fulfillment_status = ''
queryParams.exclude_fulfillment_cancelled = 0
resetPage()
return
}
@@ -2589,9 +2712,11 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.fulfillment_status = ''
queryParams.exclude_fulfillment_cancelled = 0
if (key === 'pendingRx') {
queryParams.prescription_audit_status = 0
queryParams.exclude_fulfillment_cancelled = 1
} else if (key === 'pendingPay') {
queryParams.prescription_audit_status = 1
queryParams.payment_slip_audit_status = 0
@@ -2642,6 +2767,7 @@ async function fetchLists(params: Record<string, unknown>) {
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
if (!p.express_company || p.express_company === '') delete p.express_company
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
delete p.start_time
delete p.end_time
@@ -2662,6 +2788,34 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
params: queryParams
})
/** 业绩看板等入口:URL 携带创建区间、诊单医助部门 / 医助 id */
function applyRouteQueryToPrescriptionOrderList() {
const q = route.query
const normDate = (v: unknown) => {
const s = v != null ? String(v).trim() : ''
if (!s) return ''
return s.length >= 10 ? s.slice(0, 10) : s
}
const st = normDate(q.start_time)
const et = normDate(q.end_time)
if (st) queryParams.start_time = st
if (et) queryParams.end_time = et
const ad = q.assistant_dept_id
if (ad !== undefined && ad !== null && String(ad) !== '') {
const n = Number(ad)
if (Number.isFinite(n) && n > 0) {
queryParams.assistant_dept_id = n
}
}
const ai = q.assistant_id
if (ai !== undefined && ai !== null && String(ai) !== '') {
const n = Number(ai)
if (Number.isFinite(n) && n > 0) {
queryParams.assistant_id = n
}
}
}
/** 选医助部门后,医助下拉仅显示该部门及子部门成员(与后台含子级一致) */
const filteredAssistantOptions = computed(() => {
const deptRaw = queryParams.assistant_dept_id
@@ -2732,6 +2886,9 @@ const listStats = computed(() => {
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
} else if (scope === 'assistant') {
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
} else if (scope === 'own_rx') {
scopeHint =
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
}
const hasCustomTime =
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
@@ -2799,6 +2956,7 @@ watch(
if (show) return
if (queryParams.prescription_audit_status === '' || queryParams.prescription_audit_status === undefined) return
queryParams.prescription_audit_status = ''
queryParams.exclude_fulfillment_cancelled = 0
resetPage()
},
{ immediate: true }
@@ -2820,6 +2978,7 @@ function handleReset() {
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
queryParams.exclude_fulfillment_cancelled = 0
resetParams()
}
@@ -3003,6 +3162,12 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
return fs === 5 || fs === 6
}
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
function canUploadGancaoRow(row: {
prescription_audit_status?: number
gancao_reciperl_order_no?: string
@@ -3164,6 +3329,46 @@ const detailOrderPhoneDisplay = computed(() => {
return s || '—'
})
/** 详情:业务订单创建人部门面包屑(与 edit 表单同结构;后端 order_creator_dept_path / diagnosis_creator_dept_path */
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
const raw = String(
(detailData.value as Record<string, unknown> | null)?.order_creator_dept_path ??
(detailData.value as Record<string, unknown> | null)?.diagnosis_creator_dept_path ??
''
).trim()
if (!raw) {
return [] as string[][]
}
return raw
.split('')
.map((s) => s.trim())
.filter(Boolean)
.map((p) => {
if (p.includes(' / ')) {
return p
.split(' / ')
.map((x) => x.trim())
.filter(Boolean)
}
return p
.split('/')
.map((x) => x.trim())
.filter(Boolean)
})
.filter((segs) => segs.length > 0)
})
const detailOrderCreatorMetaLine = computed(() => {
const d = detailData.value as Record<string, unknown> | null
if (!d) return ''
const account = String(d.creator_account ?? '').trim()
const mobile = String(d.creator_mobile ?? '').trim()
const parts: string[] = []
if (account) parts.push(`登录账号:${account}`)
if (mobile) parts.push(`手机:${mobile}`)
return parts.join(' · ')
})
const detailLinkedAppointment = computed(() => {
const a = detailData.value?.linked_appointment
return a && typeof a === 'object' ? a : null
@@ -3176,6 +3381,15 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
return ''
})
/** 渠道展示:后端 linked_appointment.channel_source_desc(字典名),缺省回退原值 */
const detailLinkedAppointmentChannelText = computed(() => {
const a = detailLinkedAppointment.value
if (!a) return ''
const desc = String(a.channel_source_desc ?? '').trim()
if (desc) return desc
return String(a.channel_source ?? '').trim()
})
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
@@ -3235,6 +3449,29 @@ const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false)
const updateAmountSaving = ref(false)
const updateAmountFormRef = ref<FormInstance>()
const updateAmountForm = reactive({
id: 0,
amount: undefined as number | undefined
})
const updateAmountRules: FormRules = {
amount: [
{ required: true, message: '请输入订单金额', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
@@ -3270,6 +3507,7 @@ function logActionText(act: string) {
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
}
return m[act] || act
@@ -3354,6 +3592,24 @@ function resetPatchRxPatientForm() {
patchRxPatientForm.phone = ''
}
function resetUpdateAmountDialog() {
updateAmountForm.id = 0
updateAmountForm.amount = undefined
updateAmountFormRef.value?.clearValidate()
}
function openUpdateAmount() {
const row = detailData.value
if (!canUpdateAmount(row)) {
feedback.msgWarning('当前订单状态不允许修改金额')
return
}
updateAmountForm.id = Number(row?.id) || 0
updateAmountForm.amount = Number(row?.amount) || undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
async function refreshCurrentPrescriptionOrderDetail() {
const id = detailData.value?.id
if (!id) return
@@ -3393,6 +3649,37 @@ async function submitPatchRxPatient() {
}
}
async function submitUpdateAmount() {
const form = updateAmountFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
if (!canUpdateAmount(detailData.value)) {
feedback.msgWarning('当前订单状态不允许修改金额')
return
}
const orderId = updateAmountForm.id
updateAmountSaving.value = true
try {
await prescriptionOrderUpdateAmount({
id: orderId,
amount: Number(updateAmountForm.amount)
})
feedback.msgSuccess('修改成功')
updateAmountVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(orderId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
updateAmountSaving.value = false
}
}
async function openDetail(id: number) {
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
detailData.value = null
@@ -3480,6 +3767,7 @@ const editForm = reactive({
fee_type: 3,
amount: 0,
remark_extra: '',
remark_assistant: '',
pay_order_ids: [] as number[],
internal_cost: undefined as number | string | undefined,
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
@@ -3730,6 +4018,7 @@ async function openEdit(row: { id: number }) {
editForm.amount = amt
editForm.remark_extra = d.remark_extra || ''
editForm.remark_assistant = d.remark_assistant || ''
const pids = d.pay_order_ids
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
editForm.internal_cost =
@@ -3768,6 +4057,7 @@ async function submitEdit() {
fee_type: editForm.fee_type,
amount: editForm.amount,
remark_extra: editForm.remark_extra || '',
remark_assistant: editForm.remark_assistant || '',
// BUG FIX: 修复之前无法清空部分选填字段的问题
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
@@ -3998,6 +4288,8 @@ onMounted(async () => {
await loadRegionData()
await loadServicePackageOptions()
await loadDoctorAssistantOptions()
applyRouteQueryToPrescriptionOrderList()
await nextTick()
getLists()
})
@@ -4047,6 +4339,7 @@ async function submitQuickTrack() {
fee_type: Number(d.fee_type) || 3,
amount: Number(d.amount) || 0,
remark_extra: d.remark_extra || '',
remark_assistant: d.remark_assistant || '',
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
// 仅覆盖快递字段
@@ -4373,6 +4666,7 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
const prescriptionViewVisible = ref(false)
const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
const prescriptionTabType = ref('internal')
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -4400,19 +4694,21 @@ const slipPerBagDosageText = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
if (d.dosage_amount == null || d.dosage_amount === '') return '—'
return `${d.dosage_amount}${d.dosage_unit || 'g'}`
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
return `${d.dosage_amount}${d.dosage_unit || 'g'} · ${bags}袋`
})
const slipPillGrams = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数 */
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数;出丸 = 每袋用量×袋数×每天次数×服用天数 */
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
const times = Number(d.times_per_day)
const doseG = Number(d.dosage_amount)
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG
return days * times * doseG * bags
})
/* ============================================================
@@ -4505,21 +4801,25 @@ const rxUsageText = computed(() => {
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}次`)
seg.push(`一次${bags}包(约${amount}${unit})`)
seg.push(`每包${amount}${unit}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}袋`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
})
/** 出丸:优先用 slipPillGrams按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
/** 出丸:优先用 slipPillGrams每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
const rxOutPelletGrams = computed(() => {
const pill = slipPillGrams.value
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
@@ -4639,6 +4939,7 @@ async function openPrescriptionView(row: any) {
prescriptionViewVisible.value = true
prescriptionViewLoading.value = true
prescriptionViewData.value = null
prescriptionTabType.value = 'internal'
try {
const res: any = await prescriptionDetail({ id: row.prescription_id })
@@ -677,7 +677,19 @@
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
@@ -819,6 +831,9 @@
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
@@ -1091,7 +1106,8 @@
{{ expressCompanyLabel(detailData.express_company) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
</el-descriptions-item>
@@ -1217,6 +1233,47 @@
</div>
</el-drawer>
<el-dialog
v-model="updateAmountVisible"
title="修改订单金额"
width="92%"
destroy-on-close
:close-on-click-modal="false"
@closed="resetUpdateAmountDialog"
>
<el-form
ref="updateAmountFormRef"
:model="updateAmountForm"
:rules="updateAmountRules"
label-width="84px"
@submit.prevent="submitUpdateAmount"
>
<el-form-item label="当前金额">
<div class="text-sm text-gray-700 font-medium">¥{{ formatMoney(detailData?.amount) }}</div>
</el-form-item>
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0.01"
:step="0.01"
:precision="2"
controls-position="right"
class="w-full"
placeholder="请输入新金额"
/>
</el-form-item>
<div class="text-xs text-gray-400 pl-[84px] -mt-1 leading-relaxed">
已完成或已取消订单不可修改提交成功后会刷新详情与操作日志
</div>
</el-form>
<template #footer>
<el-button @click="updateAmountVisible = false">取消</el-button>
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
确认修改
</el-button>
</template>
</el-dialog>
<!-- 编辑分步向导与创建业务订单弹窗统一 -->
<el-dialog
v-model="editVisible"
@@ -1563,7 +1620,7 @@
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
<el-input
v-model="editForm.remark_extra"
type="textarea"
@@ -1573,6 +1630,16 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="医助备注">
<el-input
v-model="editForm.remark_assistant"
type="textarea"
:rows="3"
placeholder="选填,医助对内说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
@@ -2328,6 +2395,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderShip,
prescriptionOrderLogs,
prescriptionOrderUpdateAmount,
prescriptionOrderAddPayOrder,
prescriptionOrderComplete,
prescriptionOrderRevokeRxAudit,
@@ -2789,6 +2857,9 @@ const listStats = computed(() => {
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
} else if (scope === 'assistant') {
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
} else if (scope === 'own_rx') {
scopeHint =
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
}
const hasCustomTime =
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
@@ -3116,6 +3187,12 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
else if (cmd === 'withdraw') confirmWithdraw(row as any)
}
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
@@ -3327,6 +3404,29 @@ const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false)
const updateAmountSaving = ref(false)
const updateAmountFormRef = ref<FormInstance>()
const updateAmountForm = reactive({
id: 0,
amount: undefined as number | undefined
})
const updateAmountRules: FormRules = {
amount: [
{ required: true, message: '请输入订单金额', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
@@ -3362,6 +3462,7 @@ function logActionText(act: string) {
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
}
return m[act] || act
@@ -3446,6 +3547,24 @@ function resetPatchRxPatientForm() {
patchRxPatientForm.phone = ''
}
function resetUpdateAmountDialog() {
updateAmountForm.id = 0
updateAmountForm.amount = undefined
updateAmountFormRef.value?.clearValidate()
}
function openUpdateAmount() {
const row = detailData.value
if (!canUpdateAmount(row)) {
feedback.msgWarning('当前订单状态不允许修改金额')
return
}
updateAmountForm.id = Number(row?.id) || 0
updateAmountForm.amount = Number(row?.amount) || undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
async function refreshCurrentPrescriptionOrderDetail() {
const id = detailData.value?.id
if (!id) return
@@ -3485,6 +3604,37 @@ async function submitPatchRxPatient() {
}
}
async function submitUpdateAmount() {
const form = updateAmountFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
if (!canUpdateAmount(detailData.value)) {
feedback.msgWarning('当前订单状态不允许修改金额')
return
}
const orderId = updateAmountForm.id
updateAmountSaving.value = true
try {
await prescriptionOrderUpdateAmount({
id: orderId,
amount: Number(updateAmountForm.amount)
})
feedback.msgSuccess('修改成功')
updateAmountVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(orderId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
updateAmountSaving.value = false
}
}
async function openDetail(id: number) {
// Bug
detailData.value = null
@@ -3572,6 +3722,7 @@ const editForm = reactive({
fee_type: 3,
amount: 0,
remark_extra: '',
remark_assistant: '',
pay_order_ids: [] as number[],
internal_cost: undefined as number | string | undefined,
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
@@ -3822,6 +3973,7 @@ async function openEdit(row: { id: number }) {
editForm.amount = amt
editForm.remark_extra = d.remark_extra || ''
editForm.remark_assistant = d.remark_assistant || ''
const pids = d.pay_order_ids
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
editForm.internal_cost =
@@ -3860,6 +4012,7 @@ async function submitEdit() {
fee_type: editForm.fee_type,
amount: editForm.amount,
remark_extra: editForm.remark_extra || '',
remark_assistant: editForm.remark_assistant || '',
// BUG FIX:
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
@@ -4139,6 +4292,7 @@ async function submitQuickTrack() {
fee_type: Number(d.fee_type) || 3,
amount: Number(d.amount) || 0,
remark_extra: d.remark_extra || '',
remark_assistant: d.remark_assistant || '',
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
//
@@ -4492,19 +4646,21 @@ const slipPerBagDosageText = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
if (d.dosage_amount == null || d.dosage_amount === '') return '—'
return `${d.dosage_amount}${d.dosage_unit || 'g'}`
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
return `${d.dosage_amount}${d.dosage_unit || 'g'} · ${bags}`
})
const slipPillGrams = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数 */
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数;出丸 = 每袋用量×袋数×每天次数×服用天数 */
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
const times = Number(d.times_per_day)
const doseG = Number(d.dosage_amount)
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG
return days * times * doseG * bags
})
/* ============================================================
@@ -4597,21 +4753,25 @@ const rxUsageText = computed(() => {
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}`)
seg.push(`一次${bags}包(约${amount}${unit})`)
seg.push(`每包${amount}${unit}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
})
/** 出丸:优先用 slipPillGrams按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
/** 出丸:优先用 slipPillGrams每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
const rxOutPelletGrams = computed(() => {
const pill = slipPillGrams.value
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -9,7 +9,11 @@
</div>
<div>
<div class="toolbar__title">制定业绩目标</div>
<div class="toolbar__desc">按部门设置每月销售目标 0 并保存将删除该记录</div>
<div class="toolbar__desc">
按部门设置每月销售目标 0 并保存将删除该记录列表按当前账号
<strong>角色数据权限</strong>
仅展示<strong>本部门及下级</strong>不展示权限外的上级组织口径与业绩看板数据范围一致
</div>
</div>
</div>
<div class="toolbar__center">
@@ -59,8 +63,16 @@
<div v-if="!loading && formRows.length === 0" class="empty-state">
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
<p class="empty-state__title">暂无部门数据</p>
<p class="empty-state__desc">请检查部门配置后重试</p>
<p class="empty-state__title">
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
</p>
<p class="empty-state__desc">
{{
dataScopeLimited
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
: '请检查部门配置后重试'
}}
</p>
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
</div>
@@ -161,6 +173,8 @@ const yearMonth = ref<string>(ymNow())
const loading = ref(false)
const saving = ref(false)
const formRows = ref<Row[]>([])
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
const dataScopeLimited = ref(false)
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
const drillStack = ref<Row[]>([])
@@ -199,8 +213,10 @@ async function loadMatrix() {
try {
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
dataScopeLimited.value = Boolean(res?.data_scope_limited)
} catch {
formRows.value = []
dataScopeLimited.value = false
} finally {
loading.value = false
}
@@ -217,6 +233,7 @@ async function handleSave() {
})
if (res?.rows) {
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
dataScopeLimited.value = Boolean(res?.data_scope_limited)
drillStack.value = []
drilledId.value = null
} else {
@@ -47,6 +47,7 @@
:src="getImageUrl(url)"
:preview-src-list="note.tongue_images.map(getImageUrl)"
:initial-index="idx"
:z-index="IMAGE_PREVIEW_Z_INDEX"
fit="cover"
class="timeline-thumb"
preview-teleported
@@ -66,6 +67,7 @@
:src="getImageUrl(url)"
:preview-src-list="getImagePreviewList(note.report_files)"
:initial-index="getImagePreviewIndex(note.report_files, idx)"
:z-index="IMAGE_PREVIEW_Z_INDEX"
fit="cover"
class="timeline-thumb"
preview-teleported
@@ -128,6 +130,9 @@ const reportModel = ref<string[]>([])
const prevTongueCount = ref(0)
const prevReportCount = ref(0)
/** 高于诊单抽屉(最高 4000)与多层弹窗叠加后的遮罩,避免预览被挡住 */
const IMAGE_PREVIEW_Z_INDEX = 8000
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const isImageFile = (url: string): boolean => {
@@ -211,6 +216,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
<style scoped lang="scss">
.timeline-actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
+22 -323
View File
@@ -111,126 +111,10 @@
<template v-else-if="detail">
<!-- 患者信息 -->
<div class="card patient-card">
<div class="card-title">患者信息</div>
<div class="patient-hero">
<div class="patient-name">{{ apt.patient_name || '—' }}</div>
<div class="patient-meta">
<div>{{ maskPhone(apt.patient_phone) }} · {{ formatGender(diag.gender) }} · {{ diag.age != null ? diag.age + '岁' : '—' }}</div>
<div>{{ diag.height ? diag.height + 'cm' : '—' }} / {{ diag.weight ? diag.weight + 'kg' : '—' }} · {{ diag.region || '—' }}</div>
<div>预约{{ apt.appointment_date }} {{ apt.appointment_time }} · {{ formatPeriod(apt.period) }}</div>
<div>医生{{ apt.doctor_name || '—' }} 医助{{ apt.assistant_name || '—' }}</div>
<div>状态{{ apt.status_desc || '—' }} · {{ apt.has_prescription ? '已开方' : '未开方' }}</div>
<div v-if="apt.remark">备注{{ apt.remark }}</div>
</div>
</div>
</div>
<PatientInfoCard :apt="apt" :diag="diag" />
<!-- 患者病例完整字段与 tcm/diagnosis/edit.vue 保持同步只读展示 -->
<div class="card case-card">
<div class="card-title">
患者病例
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag.diagnosis_date || apt.appointment_date || '—' }}</span>
</div>
<!-- 基本信息 -->
<div class="grid-block">
<div class="grid-title">基本信息</div>
<div class="kv-grid four">
<KVItem label="诊单ID" :value="diag.patient_id" />
<KVItem label="姓名" :value="diag.patient_name" />
<KVItem label="身份证号" :value="diag.id_card" />
<KVItem label="手机号" :value="maskPhone(diag.phone || apt.patient_phone)" />
<KVItem label="性别" :value="textOf('gender')" />
<KVItem label="年龄" :value="diag.age != null ? diag.age + '岁' : ''" />
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
<KVItem label="地区" :value="diag.region" />
</div>
</div>
<!-- 生命体征 -->
<div class="grid-block">
<div class="grid-title">生命体征</div>
<div class="kv-grid four">
<KVItem label="身高" :value="diag.height ? diag.height + ' cm' : ''" />
<KVItem label="体重" :value="diag.weight ? diag.weight + ' kg' : ''" />
<MetricKVItem label="高压" :value="diag.systolic_pressure" unit=" mmHg" :high="isHighSystolicPressure(diag.systolic_pressure)" />
<MetricKVItem label="低压" :value="diag.diastolic_pressure" unit=" mmHg" :high="isHighDiastolicPressure(diag.diastolic_pressure)" />
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
<MetricKVItem label="空腹血糖" :value="diag.fasting_blood_sugar" unit=" mmol/L" :high="isHighFastingBloodSugar(diag.fasting_blood_sugar, diag.age)" />
<KVItem label="在用药物" :value="diag.current_medications" multiline />
</div>
</div>
<!-- 主诉 -->
<div class="grid-block">
<div class="grid-title">主诉</div>
<div class="kv-grid four">
<KVItem label="当地医院诊断日期" :value="diag.diagnosis_date" />
<KVItem label="发现糖尿病病史" :value="formatDiabetesDiscoveryDisplay ? formatDiabetesDiscoveryDisplay(diag.diabetes_discovery_year) : diag.diabetes_discovery_year" />
<KVItem label="当地就诊医院" :value="diag.local_hospital_name" />
<KVItem label="当地医院诊断结果" :value="joinArray(diag.local_hospital_diagnosis)" />
</div>
</div>
<!-- 现病史后端已翻译 *_text -->
<div class="grid-block">
<div class="grid-title">现病史</div>
<div class="kv-grid three">
<KVItem label="口腔感觉" :value="textOf('appetite')" />
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
<KVItem label="小便情况" :value="textOf('urine_condition')" />
<KVItem label="大便情况" :value="textOf('stool_condition')" />
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
<KVItem label="其他补充" :value="diag.symptoms" span="3" multiline />
</div>
</div>
<!-- 既往史 -->
<div class="grid-block">
<div class="grid-title">既往史</div>
<div class="kv-grid three">
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
</div>
</div>
<!-- 其他病史 -->
<div class="grid-block">
<div class="grid-title">其他病史</div>
<div class="kv-grid three">
<KVItem label="外伤史" :value="textOf('trauma_history')" />
<KVItem label="手术史" :value="textOf('surgery_history')" />
<KVItem label="过敏史" :value="textOf('allergy_history')" />
<KVItem label="家族病史" :value="textOf('family_history')" />
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
</div>
</div>
<!-- 病史补充 -->
<div class="grid-block" v-if="diag.remark">
<div class="grid-title">病史补充</div>
<div class="kv-grid one">
<KVItem label="" :value="diag.remark" multiline />
</div>
</div>
<!-- 处方意见 -->
<div class="grid-block">
<div class="grid-title">处方意见</div>
<div class="rx-note">
{{ apt.has_prescription ? '已开方。' : '当前未开方。' }}
</div>
</div>
</div>
<!-- 患者病例 tcm/diagnosis/readonly 共用同一组件确保两处字段一致 -->
<PatientCaseCard :apt="apt" :diag="diag" />
<!-- 医生备注 & 舌苔照片 -->
<div class="card note-card">
@@ -242,65 +126,16 @@
/>
</div>
<!-- 血糖血压记录 -->
<!-- 日常记录只读与只读病例页同口径 -->
<div class="card metric-card">
<div class="card-title">血糖血压记录</div>
<div class="metric-viewport">
<el-table
:data="bloodRecords"
size="small"
stripe
empty-text="暂无血糖血压记录"
class="metric-table"
max-height="360"
>
<el-table-column prop="record_date" label="记录日期" width="110" />
<el-table-column prop="record_time" label="记录时间" width="100">
<template #default="{ row }">{{ row.record_time || '—' }}</template>
</el-table-column>
<el-table-column label="空腹血糖(mmol/L)" width="150">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age) }">
{{ row.fasting_blood_sugar ?? '—' }}
<span v-if="isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="餐后2小时血糖(mmol/L)" width="180">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age) }">
{{ row.postprandial_blood_sugar ?? '—' }}
<span v-if="isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="其他血糖(mmol/L)" width="150">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighOtherBloodSugar(row.other_blood_sugar, diag.age) }">
{{ row.other_blood_sugar ?? '—' }}
<span v-if="isHighOtherBloodSugar(row.other_blood_sugar, diag.age)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="血压(mmHg)" width="130">
<template #default="{ row }">
<span class="metric-value" :class="{ high: isHighBloodPressure(row) }">
{{ formatBp(row) }}
<span v-if="isHighBloodPressure(row)" class="metric-up"></span>
</span>
</template>
</el-table-column>
<el-table-column label="西药" width="120">
<template #default="{ row }">{{ row.western_medicine || '—' }}</template>
</el-table-column>
<el-table-column label="胰岛素" width="120">
<template #default="{ row }">{{ row.insulin || '—' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="160">
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-title">日常记录</div>
<DailyMatrix
:diagnosis-id="Number(diag?.id ?? 0)"
:patient-id="Number(diag?.patient_id ?? 0)"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
</div>
</template>
</section>
@@ -317,7 +152,11 @@
>
备注
</el-button>
<el-button class="float-action edit" size="large" @click="handleEditDiagnosis">
<el-button
v-perms="['tcm.diagnosis/edit']"
class="float-action edit"
size="large"
@click="handleEditDiagnosis">
编辑病历
</el-button>
<el-button
@@ -349,13 +188,17 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent, onMounted, onUnmounted, h } from 'vue'
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
import NoteTimeline from './components/NoteTimeline.vue'
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
import PatientCaseCard from '@/views/tcm/diagnosis/components/PatientCaseCard.vue'
import DailyMatrix from '@/views/tcm/diagnosis/components/DailyMatrix.vue'
import { completeAppointment } from '@/api/doctor'
import { getCallSignature } from '@/api/tcm'
import feedback from '@/utils/feedback'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
import { formatGender } from '@/utils/diag-display'
import {
Phone,
Search,
@@ -366,55 +209,6 @@ import {
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
/**
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行
*/
const KVItem = (props: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
const val = props.value
const empty = val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
return h(
'div',
{
class: ['kv-item', props.multiline ? 'multiline' : '', empty ? 'empty' : ''],
style: props.span ? `grid-column: span ${props.span}` : ''
},
[
h('span', { class: 'kv-label' }, props.label),
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
]
)
}
const MetricKVItem = (props: {
label: string
value: any
unit?: string
span?: string | number
high?: boolean
}) => {
const empty = props.value === undefined || props.value === null || props.value === ''
return h(
'div',
{
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
style: props.span ? `grid-column: span ${props.span}` : ''
},
[
h('span', { class: 'kv-label' }, props.label),
h(
'span',
{ class: ['kv-value', 'metric-kv-value', props.high ? 'high' : ''] },
empty
? '—'
: [
`${props.value}${props.unit || ''}`,
props.high ? h('span', { class: 'metric-up' }, '↑') : null
]
)
]
)
}
const POLL_MS = 5_000
const PAGE_SIZE = 15
@@ -474,9 +268,7 @@ const noteSaving = ref(false)
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const todayMeta = computed(() => {
const d = new Date()
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
@@ -485,38 +277,12 @@ const todayMeta = computed(() => {
})
const caseTypeLabel = computed(() => {
if (!diag.value) return '初诊'
return diag.value.consultation_type === 'follow_up' ? '复诊' : '初诊'
})
/**
* 诊单字典字段由后端 AppointmentLogic::enrichDiagnosisLabels 预翻译,
* 前端直接读 `<field>_text`;找不到时回退原值。
*/
const textOf = (field: string): string => {
const d: any = diag.value || {}
const t = d[field + '_text']
if (t !== undefined && t !== null && t !== '') return String(t)
const raw = d[field]
if (raw === undefined || raw === null || raw === '') return ''
return Array.isArray(raw) ? raw.join('、') : String(raw)
}
const todayStr = () => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const maskPhone = (phone?: string) => {
if (!phone) return '—'
if (phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
const formatGender = (g?: number) => (g === 1 ? '男' : g === 0 ? '女' : '—')
const formatGenderAge = (row: QueueRow) => {
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
return parts.filter((p) => p && p !== '—').join(' ')
@@ -527,73 +293,6 @@ const formatTimeHM = (time?: string) => {
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
}
const formatPeriod = (period?: string) => {
if (period === 'morning') return '上午'
if (period === 'afternoon') return '下午'
return '全天'
}
const formatBp = (row: any) => {
const sys = row?.systolic_pressure
const dia = row?.diastolic_pressure
if (sys == null && dia == null) return '—'
return `${sys ?? '—'} / ${dia ?? '—'}`
}
const toNumeric = (value: any) => {
if (value === null || value === undefined || value === '') return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const getBloodSugarThresholds = (ageValue: any) => {
const age = toNumeric(ageValue)
if (age === null) return null
if (age < 50) return { fasting: 7, postprandial: 9 }
if (age >= 50) return { fasting: 8, postprandial: 10 }
return null
}
const isHighFastingBloodSugar = (value: any, ageValue: any) => {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.fasting
}
const isHighPostprandialBloodSugar = (value: any, ageValue: any) => {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
const isHighOtherBloodSugar = (value: any, ageValue: any) => {
const num = toNumeric(value)
const thresholds = getBloodSugarThresholds(ageValue)
return num !== null && thresholds !== null && num >= thresholds.postprandial
}
const isHighBloodPressure = (row: any) => {
const sys = toNumeric(row?.systolic_pressure)
const dia = toNumeric(row?.diastolic_pressure)
return (sys !== null && sys > 140) || (dia !== null && dia > 90)
}
const isHighSystolicPressure = (value: any) => {
const num = toNumeric(value)
return num !== null && num > 140
}
const isHighDiastolicPressure = (value: any) => {
const num = toNumeric(value)
return num !== null && num > 90
}
const joinArray = (arr: any): string => {
if (!arr) return ''
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
return String(arr)
}
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
const today = todayStr()
const res: any = await receptionQueue({
+1
View File
@@ -65,6 +65,7 @@ const formData = reactive({
name: '',
desc: '',
sort: 0,
data_scope: 1,
menu_id: [] as any[]
})
+187 -23
View File
@@ -105,24 +105,27 @@
</div>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="14">
<el-col :span="24">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>转化排行</span>
<span class="card-hint">{{ dateRangeText }}</span>
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
</div>
</template>
<v-charts
v-if="rankingChartHasData"
class="stats-chart"
class="stats-chart stats-chart-wide"
:option="rankingChartOption"
autoresize
/>
<el-empty v-else description="暂无图表数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="12">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
@@ -139,10 +142,7 @@
<el-empty v-else description="暂无金额数据" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="10">
<el-col :xs="24" :lg="12">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
@@ -159,35 +159,82 @@
<el-empty v-else description="暂无加粉数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="14">
<el-card class="!border-none" shadow="never">
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :span="24">
<el-card class="!border-none stats-detail-card" shadow="never">
<template #header>
<div class="card-header">
<span>明细列表</span>
<span class="card-hint">{{ entityLabel }}维度</span>
<div class="card-header-main">
<span class="card-title">明细列表</span>
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
</div>
</div>
</template>
<el-table
:data="pager.lists"
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
:row-key="rowKeyFn"
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
:default-expand-all="false"
size="large"
:indent="20"
:row-class-name="rowClassName"
size="default"
style="width: 100%"
max-height="560"
max-height="640"
border
>
<el-table-column
:label="entityLabel"
prop="name"
fixed="left"
min-width="280"
min-width="300"
class-name="dept-name-column"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="dept-name-cell" :title="row.name">
{{ row.name }}
</span>
<div class="dept-name-cell" :title="row.name">
<span
v-if="isMemberRow(row)"
class="member-icon"
:class="{
'is-leader': row.is_leader,
'is-unbound': row.type === 'unbound'
}"
>
<el-icon><User /></el-icon>
</span>
<span class="entity-name">{{ row.name }}</span>
<el-tag
v-if="row.type === 'member' && row.is_leader"
size="small"
type="warning"
effect="light"
round
class="role-tag"
>
组长
</el-tag>
<el-tag
v-if="row.type === 'member' && row.role"
size="small"
:type="row.role === '医生' ? 'success' : 'info'"
effect="plain"
round
class="role-tag"
>
{{ row.role }}
</el-tag>
<el-tag
v-if="row.type === 'unbound'"
size="small"
type="danger"
effect="plain"
round
class="role-tag"
>
未绑定
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column
@@ -215,6 +262,7 @@
<script setup lang="ts" name="conversionStatsPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { getConversionStatsOverview } from '@/api/stats'
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
return String(value ?? 0)
}
const rowKeyFn = (row: Record<string, any>) => {
if (row?.id !== undefined && row?.id !== null) {
return String(row.id)
}
return ''
}
const isMemberRow = (row: Record<string, any>) => {
return row?.type === 'member' || row?.type === 'unbound'
}
const rowClassName = ({ row }: { row: Record<string, any> }) => {
if (row?.type === 'unbound') {
return 'member-row member-row--unbound'
}
if (row?.type === 'member') {
return row.is_leader ? 'member-row member-row--leader' : 'member-row'
}
return ''
}
onMounted(async () => {
await fetchOverview()
})
@@ -672,6 +741,27 @@ onMounted(async () => {
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-header-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.card-header-tip {
display: flex;
align-items: center;
gap: 6px;
color: #909399;
font-size: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
@@ -680,19 +770,93 @@ onMounted(async () => {
}
.dept-name-cell {
display: inline-block;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
max-width: 100%;
white-space: nowrap;
width: 100%;
line-height: 1.4;
overflow: hidden;
}
.entity-name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
color: inherit;
}
.member-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #e6f0ff;
color: #4a78ff;
font-size: 13px;
flex-shrink: 0;
}
.member-icon.is-leader {
background: #fff4e0;
color: #f59e0b;
}
.member-icon.is-unbound {
background: #fde8e8;
color: #ef4444;
}
.role-tag {
margin-left: 0;
flex-shrink: 0;
}
:deep(.dept-name-column .cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
display: flex;
align-items: center;
}
:deep(.member-row) td {
background-color: #fafbff !important;
}
:deep(.member-row--leader) td {
background-color: #fff8ec !important;
}
:deep(.member-row--leader .entity-name) {
color: #d97706;
font-weight: 600;
}
:deep(.member-row--unbound) td {
background-color: #fff5f5 !important;
}
:deep(.member-row--unbound .entity-name) {
color: #ef4444;
font-family: monospace;
font-size: 12px;
}
.stats-detail-card :deep(.el-card__body) {
padding-top: 0;
}
.stats-chart {
height: 360px;
}
.stats-chart-wide {
height: 380px;
}
</style>
@@ -2,8 +2,35 @@
<div class="assign-log-panel">
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
<el-table-column label="操作时间" width="175" prop="create_time_text" />
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
<el-table-column label="原医助" min-width="120">
<template #default="{ row }">
{{ assistantLogCellText(row, 'from') }}
</template>
</el-table-column>
<el-table-column label="新医助" min-width="120">
<template #default="{ row }">
{{ assistantLogCellText(row, 'to') }}
</template>
</el-table-column>
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ relatedPoCreatorCell(row) }}
</template>
</el-table-column>
<el-table-column label="快照·业务单创建时间" width="190">
<template #header>
<span>快照·业务单创建时间</span>
<el-tooltip
placement="top"
content="related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"
>
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
{{ relatedPoCreateTimeCell(row) }}
</template>
</el-table-column>
<el-table-column label="操作人" width="110" prop="operator_name" />
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
@@ -12,6 +39,7 @@
</template>
<script setup lang="ts">
import { QuestionFilled } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
@@ -22,12 +50,56 @@ const props = defineProps<{
const loading = ref(false)
const rows = ref<any[]>([])
/** 对应日志表 related_po_creator_id:触发本次释放/指派的业务订单 creator */
function relatedPoCreatorCell(row: Record<string, unknown>): string {
const name = row.related_po_creator_name
if (name != null && String(name).trim() !== '') {
return String(name)
}
const id = Number(row.related_po_creator_id)
if (Number.isFinite(id) && id > 0) {
return `ID:${id}`
}
return '—'
}
/** 对应日志表 related_po_create_timeUnix)格式化为可读时间 */
function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
const t = row.related_po_create_time_text
if (t != null && String(t).trim() !== '') {
return String(t)
}
const ts = Number(row.related_po_create_time)
if (!Number.isFinite(ts) || ts <= 0) {
return '—'
}
const d = new Date(ts * 1000)
if (Number.isNaN(d.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
const n = row[nameKey]
if (n != null && String(n).trim() !== '' && String(n) !== '—') {
return String(n)
}
const id = Number(row[idKey])
if (Number.isFinite(id) && id > 0) {
return `ID:${id}`
}
return '—'
}
const load = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
rows.value = Array.isArray(data) ? data : []
const list = Array.isArray(data) ? data : []
rows.value = list
} catch (e) {
console.error(e)
rows.value = []
@@ -46,3 +118,12 @@ watch(
defineExpose({ refresh: load })
</script>
<style scoped>
.assign-log-col-hint {
margin-left: 4px;
vertical-align: middle;
color: var(--el-text-color-secondary);
cursor: help;
}
</style>
@@ -1,23 +1,28 @@
<template>
<div class="call-record-panel">
<el-alert
type="info"
show-icon
:closable="false"
class="mb-4"
title="视频通话与录制回放"
>
<!-- <p class="call-record-tip">
下列为与本诊单关联的通话记录医生接通并同步房间号后服务端会尝试自动发起云端混流录制需配置 CAM 密钥与云点播等录制完成后由腾讯云回调写入回放地址同时管理端浏览器会进行本地录制挂断后自动上传并合并到本条记录
</p>
<p class="call-record-tip muted">
回调 URL 示例<code>{{ callbackHint }}</code>
若配置了 <code>trtc.recording_callback_token</code>请在 URL 后附加
<code>?token=你的密钥</code>
</p> -->
</el-alert>
<div class="call-record-toolbar">
<upload
ref="toolbarUploadRef"
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="handleToolbarUploadSuccess"
>
<el-button type="primary">上传视频</el-button>
</upload>
</div>
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无通话记录">
<el-table v-loading="loading" :data="rows" border stripe>
<template #empty>
<div class="call-record-empty">
<div class="call-record-empty__title">暂无通话记录</div>
<div class="call-record-empty__desc">
现在可以直接点击上方上传视频系统会自动生成一条默认通话记录来承载回放
</div>
</div>
</template>
<el-table-column label="开始时间" width="170" prop="start_time_text" />
<el-table-column label="结束时间" width="170" prop="end_time_text" />
<el-table-column label="通话类型" width="100">
@@ -25,7 +30,7 @@
{{ row.call_type === 1 ? '语音' : '视频' }}
</template>
</el-table-column>
<el-table-column label="房间号" width="140">
<el-table-column label="房间号" width="180">
<template #default="{ row }">
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
<span v-else class="text-gray-400"></span>
@@ -42,12 +47,26 @@
{{ row.recording_status_text || '—' }}
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="280">
<el-table-column label="上传回放" width="180">
<template #default="{ row }">
<upload
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="(response: any) => handleRowUploadSuccess(row, response)"
>
<el-button type="primary" plain size="small">上传视频</el-button>
</upload>
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
<div
v-for="(url, idx) in row.recording_urls_list"
:key="idx"
:key="`${row.id}-${idx}`"
class="recording-item"
>
<video
@@ -70,20 +89,30 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { getCallRecords } from '@/api/tcm'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
import { ref, useTemplateRef, watch } from 'vue'
type CallRecordRow = {
id: number
call_type: number
room_id?: string
status: number
recording_status_text?: string
recording_urls_list?: string[]
start_time_text?: string
end_time_text?: string
duration_text?: string
}
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const callbackHint = computed(() => {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/api/trtc/recording-notify`
})
const rows = ref<CallRecordRow[]>([])
const toolbarUploadRef = useTemplateRef<InstanceType<typeof Upload>>('toolbarUploadRef')
const load = async () => {
if (!props.diagnosisId) return
@@ -101,7 +130,7 @@ const load = async () => {
watch(
() => props.diagnosisId,
() => {
load()
void load()
},
{ immediate: true }
)
@@ -120,30 +149,93 @@ function statusText(status: number) {
function isPlayableVideo(url: string) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg)(\?|$)/i.test(url)
return /\.(mp4|webm|ogg|mov|mkv)(\?|$)/i.test(url)
}
async function handleToolbarUploadSuccess(response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
const record = await createManualCallRecord({ diagnosis_id: props.diagnosisId })
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(record?.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
async function handleRowUploadSuccess(row: CallRecordRow, response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(row.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
function normalizeUploadUrl(response: any): string {
return String(response?.data?.uri || response?.data?.url || '').trim()
}
</script>
<style scoped lang="scss">
.call-record-panel {
.call-record-tip {
margin: 0 0 8px;
line-height: 1.5;
font-size: 13px;
&.muted {
color: var(--el-text-color-secondary);
}
code {
font-size: 12px;
word-break: break-all;
}
.call-record-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.call-record-empty {
padding: 28px 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.call-record-empty__title {
font-size: 14px;
color: var(--el-text-color-primary);
}
.call-record-empty__desc {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
.recording-list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.recording-video {
max-width: 100%;
max-height: 180px;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
<template>
<div class="diagnosis-todo-list">
<div class="toolbar">
<el-button
type="primary"
:disabled="!props.diagnosisId || !!props.readOnly || !hasDailyRecordPermission"
@click="handleAdd"
v-perms="DAILY_RECORD_PERMS"
>
+ 新增待办
</el-button>
<el-radio-group v-model="filterStatus" class="ml-3" @change="handleFilterChange">
<el-radio-button :value="''">全部</el-radio-button>
<el-radio-button :value="0">待执行</el-radio-button>
<el-radio-button :value="1">已发送</el-radio-button>
<el-radio-button :value="3">失败</el-radio-button>
<el-radio-button :value="2">已取消</el-radio-button>
</el-radio-group>
<el-button class="ml-3" @click="fetchList">刷新</el-button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
border
class="todo-table"
empty-text="暂无待办"
row-key="id"
>
<el-table-column label="提醒时间" prop="remind_time_text" width="160" />
<el-table-column label="内容" prop="content" min-width="240" show-overflow-tooltip />
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light" size="small">
{{ row.status_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="120">
<template #default="{ row }">{{ row.creator_name || '-' }}</template>
</el-table-column>
<el-table-column label="推送时间" prop="notified_at_text" width="160">
<template #default="{ row }">{{ row.notified_at_text || '-' }}</template>
</el-table-column>
<el-table-column label="失败原因" prop="error" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 3" class="text-danger">{{ row.error || '未知错误' }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="!props.readOnly && row.status === 0 && row.can_cancel"
link
type="danger"
@click="handleCancel(row)"
v-perms="DAILY_RECORD_PERMS"
>
取消
</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<pagination v-model="pager" @change="fetchList" />
</div>
<!-- 新增弹框 -->
<el-dialog
v-model="addDialogVisible"
title="新增待办"
width="520px"
:close-on-click-modal="false"
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
<el-form-item label="提醒时间" prop="remindAt">
<el-date-picker
v-model="addForm.remindAt"
type="datetime"
placeholder="选择提醒时间"
format="YYYY-MM-DD HH:mm"
value-format="x"
:disabled-date="disablePastDate"
class="w-full"
/>
</el-form-item>
<el-form-item label="提醒内容" prop="content">
<el-input
v-model="addForm.content"
type="textarea"
:rows="4"
:maxlength="500"
show-word-limit
placeholder="例:3 天后回访患者复测餐后血糖"
/>
</el-form-item>
<el-form-item label="提醒人">
<span class="text-muted">企微推送给当前登录账号创建人本人</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="handleAddSubmit">
提交
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { diagnosisTodoLists, diagnosisTodoAdd, diagnosisTodoCancel } from '@/api/tcm'
import { computed } from 'vue'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
const DAILY_RECORD_PERMS = ['tcm.diagnosis/dailyRecord']
interface Props {
diagnosisId: number | null | undefined
patientId?: number | null
patientName?: string
readOnly?: boolean
}
const props = defineProps<Props>()
const hasDailyRecordPermission = computed(() => hasPermission(DAILY_RECORD_PERMS))
// pager 与项目内 <pagination v-model="pager" /> 约定的字段保持一致
const pager = reactive({
page: 1,
size: 15,
count: 0,
loading: false,
lists: [] as any[]
})
const filterStatus = ref<number | ''>('')
const fetchList = async () => {
if (!props.diagnosisId || Number(props.diagnosisId) <= 0 || !hasDailyRecordPermission.value) {
pager.lists = []
pager.count = 0
return
}
pager.loading = true
try {
const params: {
page_no: number
page_size: number
diagnosis_id: number
status?: number
} = {
page_no: pager.page,
page_size: pager.size,
diagnosis_id: Number(props.diagnosisId)
}
if (filterStatus.value !== '' && filterStatus.value !== undefined) {
params.status = Number(filterStatus.value)
}
const res: any = await diagnosisTodoLists(params)
pager.count = Number(res?.count ?? 0)
pager.lists = Array.isArray(res?.lists) ? res.lists : []
} catch {
// request 拦截器已有错误 toast
pager.lists = []
pager.count = 0
} finally {
pager.loading = false
}
}
watch(
() => props.diagnosisId,
() => {
pager.page = 1
fetchList()
},
{ immediate: true }
)
const handleFilterChange = () => {
pager.page = 1
fetchList()
}
const statusTagType = (status: number): 'info' | 'success' | 'warning' | 'danger' => {
switch (Number(status)) {
case 0:
return 'info'
case 1:
return 'success'
case 2:
return 'warning'
case 3:
return 'danger'
default:
return 'info'
}
}
// ========== 新增 ==========
const addDialogVisible = ref(false)
const addSubmitting = ref(false)
const addFormRef = ref<FormInstance>()
const addForm = reactive({
remindAt: '' as string | number,
content: ''
})
const addRules: FormRules = {
remindAt: [{ required: true, message: '请选择提醒时间', trigger: 'change' }],
content: [
{ required: true, message: '请输入提醒内容', trigger: 'blur' },
{ max: 500, message: '内容最多 500 字', trigger: 'blur' }
]
}
const disablePastDate = (date: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return date.getTime() < today.getTime()
}
const handleAdd = () => {
if (!props.diagnosisId || props.readOnly || !hasDailyRecordPermission.value) {
feedback.msgWarning('诊单未保存,无法添加待办')
return
}
addForm.remindAt = ''
addForm.content = ''
addDialogVisible.value = true
nextTick(() => addFormRef.value?.clearValidate())
}
const handleAddSubmit = async () => {
if (!addFormRef.value) return
const valid = await addFormRef.value.validate().catch(() => false)
if (!valid) return
const remindMs = Number(addForm.remindAt)
if (!Number.isFinite(remindMs) || remindMs <= Date.now() + 30 * 1000) {
feedback.msgWarning('提醒时间必须晚于当前时间至少 30 秒')
return
}
addSubmitting.value = true
try {
await diagnosisTodoAdd({
diagnosis_id: Number(props.diagnosisId),
content: addForm.content.trim(),
remind_time: Math.floor(remindMs / 1000)
})
addDialogVisible.value = false
fetchList()
} catch {
// request 拦截器已有错误 toast
} finally {
addSubmitting.value = false
}
}
// ========== 取消 ==========
const handleCancel = async (row: any) => {
if (!hasDailyRecordPermission.value || props.readOnly) return
try {
await feedback.confirm(`确认取消「${row.remind_time_text} · ${truncate(row.content, 30)}」吗?`)
} catch {
return
}
try {
await diagnosisTodoCancel({ id: Number(row.id) })
feedback.msgSuccess('已取消')
fetchList()
} catch {
// request 拦截器已有错误 toast
}
}
const truncate = (text: string, len: number) => {
if (!text) return ''
return text.length > len ? text.slice(0, len) + '...' : text
}
defineExpose({ refresh: fetchList })
</script>
<style lang="scss" scoped>
.diagnosis-todo-list {
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.todo-table {
width: 100%;
}
.pagination-wrap {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.text-danger {
color: var(--el-color-danger);
}
.text-muted {
color: var(--el-text-color-placeholder);
}
}
</style>
@@ -4,14 +4,18 @@
<p class="panel-tip">
展示单聊记录已合并患者
<strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序
数据来自 IM 漫游若未开通漫游或某账号与该患者无会话对应侧无消息
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档点击下方按钮可立即触发后台同步
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="loading" @click="refreshLive">
<el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon>
同步最新后台异步
</el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived">
<el-icon class="mr-1"><Refresh /></el-icon>
刷新同步云端最新
重新加载已归档
</el-button>
</div>
@@ -68,8 +72,9 @@
<script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh } from '@element-plus/icons-vue'
import { getImChatMessages } from '@/api/tcm'
import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
import type { FriendlyParse } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
@@ -78,6 +83,7 @@ const props = defineProps<{
}>()
const loading = ref(false)
const syncing = ref(false)
const rawRows = shallowRef<any[]>([])
const patientImId = ref('')
const patientName = ref('')
@@ -139,13 +145,13 @@ function senderLabel(row: any) {
return patientName.value ? `患者(${patientName.value}` : '患者'
}
async function load(onlyArchived = false) {
async function load() {
if (!props.diagnosisId) return
loading.value = true
try {
const res = (await getImChatMessages({
diagnosis_id: props.diagnosisId,
only_archived: onlyArchived ? 1 : 0
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
@@ -162,19 +168,33 @@ async function load(onlyArchived = false) {
}
}
function refreshLive() {
load(false)
function reloadArchived() {
load()
}
async function triggerSync() {
if (!props.diagnosisId) return
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load(true)
load()
},
{ immediate: true }
)
defineExpose({ refresh: refreshLive })
defineExpose({ refresh: reloadArchived })
</script>
<style scoped lang="scss">
@@ -0,0 +1,369 @@
<template>
<div class="card case-card">
<div class="card-title">
患者病例
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag?.diagnosis_date || apt?.appointment_date || '—' }}</span>
</div>
<!-- 基本信息 -->
<div class="grid-block">
<div class="grid-title">基本信息</div>
<div class="kv-grid four">
<KVItem label="诊单ID" :value="diag?.patient_id" />
<KVItem label="姓名" :value="diag?.patient_name" />
<KVItem label="身份证号" :value="diag?.id_card" />
<KVItem label="手机号" :value="maskPhone(diag?.phone || apt?.patient_phone)" />
<KVItem label="性别" :value="textOf('gender')" />
<KVItem label="年龄" :value="diag?.age != null ? diag.age + '岁' : ''" />
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
<KVItem label="地区" :value="diag?.region" />
</div>
</div>
<!-- 生命体征 -->
<div class="grid-block">
<div class="grid-title">生命体征</div>
<div class="kv-grid four">
<KVItem label="身高" :value="diag?.height ? diag.height + ' cm' : ''" />
<KVItem label="体重" :value="diag?.weight ? diag.weight + ' kg' : ''" />
<MetricKVItem
label="高压"
:value="diag?.systolic_pressure"
unit=" mmHg"
:high="isHighSystolicPressure(diag?.systolic_pressure)"
/>
<MetricKVItem
label="低压"
:value="diag?.diastolic_pressure"
unit=" mmHg"
:high="isHighDiastolicPressure(diag?.diastolic_pressure)"
/>
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
<MetricKVItem
label="空腹血糖"
:value="diag?.fasting_blood_sugar"
unit=" mmol/L"
:high="isHighFastingBloodSugar(diag?.fasting_blood_sugar, diag?.age)"
/>
<KVItem label="在用药物" :value="diag?.current_medications" multiline />
</div>
</div>
<!-- 主诉 -->
<div class="grid-block">
<div class="grid-title">主诉</div>
<div class="kv-grid four">
<KVItem label="当地医院诊断日期" :value="diag?.diagnosis_date" />
<KVItem
label="发现糖尿病病史"
:value="formatDiabetesDiscoveryDisplay
? formatDiabetesDiscoveryDisplay(diag?.diabetes_discovery_year)
: diag?.diabetes_discovery_year"
/>
<KVItem label="当地就诊医院" :value="diag?.local_hospital_name" />
<KVItem label="当地医院诊断结果" :value="joinArray(diag?.local_hospital_diagnosis)" />
</div>
</div>
<!-- 现病史后端已翻译 *_text -->
<div class="grid-block">
<div class="grid-title">现病史</div>
<div class="kv-grid three">
<KVItem label="口腔感觉" :value="textOf('appetite')" />
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
<KVItem label="小便情况" :value="textOf('urine_condition')" />
<KVItem label="大便情况" :value="textOf('stool_condition')" />
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
<KVItem label="其他补充" :value="diag?.symptoms" span="3" multiline />
</div>
</div>
<!-- 既往史 -->
<div class="grid-block">
<div class="grid-title">既往史</div>
<div class="kv-grid three">
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
</div>
</div>
<!-- 其他病史 -->
<div class="grid-block">
<div class="grid-title">其他病史</div>
<div class="kv-grid three">
<KVItem label="外伤史" :value="textOf('trauma_history')" />
<KVItem label="手术史" :value="textOf('surgery_history')" />
<KVItem label="过敏史" :value="textOf('allergy_history')" />
<KVItem label="家族病史" :value="textOf('family_history')" />
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
</div>
</div>
<!-- 病史补充 -->
<div class="grid-block" v-if="diag?.remark">
<div class="grid-title">病史补充</div>
<div class="kv-grid one">
<KVItem label="" :value="diag.remark" multiline />
</div>
</div>
<!-- 处方意见 -->
<div class="grid-block">
<div class="grid-title">处方意见</div>
<div class="rx-note">
{{ apt?.has_prescription ? '已开方。' : '当前未开方。' }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import {
isHighSystolicPressure,
isHighDiastolicPressure,
isHighFastingBloodSugar
} from '@/utils/blood-thresholds'
import { maskPhone, joinArray, makeTextOf } from '@/utils/diag-display'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
interface Props {
apt?: Record<string, any> | null
diag?: Record<string, any> | null
}
const props = withDefaults(defineProps<Props>(), {
apt: () => ({}),
diag: () => ({})
})
const caseTypeLabel = computed(() => {
const d: any = props.diag || {}
return d.consultation_type === 'follow_up' ? '复诊' : '初诊'
})
const textOf = makeTextOf(() => props.diag || {})
// ========== 本地函数式子组件 ==========
/**
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行(与抽离前 reception 内联实现一致)
*/
const KVItem = (kvProps: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
const val = kvProps.value
const empty =
val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
return h(
'div',
{
class: ['kv-item', kvProps.multiline ? 'multiline' : '', empty ? 'empty' : ''],
style: kvProps.span ? `grid-column: span ${kvProps.span}` : ''
},
[
h('span', { class: 'kv-label' }, kvProps.label),
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
]
)
}
/**
* 数值型 KV:偏高时红色 + ↑ 箭头(与抽离前 reception 内联实现一致)
*/
const MetricKVItem = (mkvProps: {
label: string
value: any
unit?: string
span?: string | number
high?: boolean
}) => {
const empty = mkvProps.value === undefined || mkvProps.value === null || mkvProps.value === ''
return h(
'div',
{
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
style: mkvProps.span ? `grid-column: span ${mkvProps.span}` : ''
},
[
h('span', { class: 'kv-label' }, mkvProps.label),
h(
'span',
{ class: ['kv-value', 'metric-kv-value', mkvProps.high ? 'high' : ''] },
empty
? '—'
: [
`${mkvProps.value}${mkvProps.unit || ''}`,
mkvProps.high ? h('span', { class: 'metric-up' }, '↑') : null
]
)
]
)
}
</script>
<style lang="scss" scoped>
.card {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--rx-shadow-sm);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: var(--rx-shadow);
}
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--rx-ink);
display: flex;
align-items: center;
gap: 10px;
padding-left: 10px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.case-sub {
font-size: 12px;
font-weight: 400;
color: var(--rx-soft);
font-family: var(--rx-font-data);
}
.grid-block {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px dashed var(--rx-line);
&:first-of-type {
border-top: none;
padding-top: 0;
}
}
.grid-title {
font-size: 13px;
font-weight: 600;
color: var(--rx-accent-strong);
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 6px;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--rx-accent);
}
}
.kv-grid {
display: grid;
gap: 8px 16px;
&.one {
grid-template-columns: 1fr;
}
&.two {
grid-template-columns: repeat(2, 1fr);
}
&.three {
grid-template-columns: repeat(3, 1fr);
}
&.four {
grid-template-columns: repeat(4, 1fr);
}
}
:deep(.kv-item) {
display: flex;
gap: 6px;
font-size: 12.5px;
line-height: 1.55;
min-width: 0;
&.multiline {
flex-direction: column;
gap: 2px;
}
.kv-label {
color: var(--rx-soft);
flex-shrink: 0;
min-width: 58px;
}
.kv-value {
color: var(--rx-ink);
word-break: break-word;
flex: 1;
font-weight: 500;
}
&.empty .kv-value {
color: #c0c4cc;
font-weight: 400;
}
&.multiline .kv-value {
white-space: pre-wrap;
font-weight: 400;
}
}
:deep(.metric-kv-item .metric-kv-value) {
display: inline-flex;
align-items: center;
gap: 4px;
&.high {
color: #dc2626;
font-weight: 700;
}
}
:deep(.metric-up) {
font-size: 14px;
line-height: 1;
color: #dc2626;
}
.rx-note {
font-size: 13px;
color: var(--rx-muted);
line-height: 1.6;
padding: 12px 14px;
background: linear-gradient(135deg, #fafbfd 0%, #f5f7fb 100%);
border-radius: 8px;
border-left: 3px solid var(--rx-accent);
}
</style>
@@ -0,0 +1,124 @@
<template>
<div class="card patient-card">
<div class="card-title">患者信息</div>
<div class="patient-hero">
<div class="patient-name">{{ apt?.patient_name || '—' }}</div>
<div class="patient-meta">
<div>
{{ maskPhone(apt?.patient_phone) }} · {{ formatGender(diag?.gender) }} ·
{{ diag?.age != null ? diag.age + '岁' : '—' }}
</div>
<div>
{{ diag?.height ? diag.height + 'cm' : '—' }} /
{{ diag?.weight ? diag.weight + 'kg' : '—' }} · {{ diag?.region || '—' }}
</div>
<div>
预约{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
{{ formatPeriod(apt?.period) }}
</div>
<div>医生{{ apt?.doctor_name || '—' }} 医助{{ apt?.assistant_name || '—' }}</div>
<div>
状态{{ apt?.status_desc || '—' }} ·
{{ apt?.has_prescription ? '已开方' : '未开方' }}
</div>
<div v-if="apt?.remark">备注{{ apt.remark }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { maskPhone, formatGender, formatPeriod } from '@/utils/diag-display'
interface Props {
apt?: Record<string, any> | null
diag?: Record<string, any> | null
}
withDefaults(defineProps<Props>(), {
apt: () => ({}),
diag: () => ({})
})
</script>
<style lang="scss" scoped>
.card {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--rx-shadow-sm);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: var(--rx-shadow);
}
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--rx-ink);
display: flex;
align-items: center;
gap: 10px;
padding-left: 10px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.patient-hero {
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
border: 1px solid #dde7ff;
border-radius: 12px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
right: -20px;
top: -20px;
width: 100px;
height: 100px;
border-radius: 50%;
background: radial-gradient(circle, rgba(79, 140, 255, 0.08) 0%, transparent 70%);
}
}
.patient-name {
font-size: 22px;
font-weight: 700;
color: var(--rx-ink);
letter-spacing: -0.01em;
}
.patient-meta {
color: var(--rx-muted);
font-size: 13px;
line-height: 1.7;
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
z-index: 1;
}
</style>
@@ -0,0 +1,154 @@
<template>
<div class="patient-order-list">
<div v-if="!patientIdAvailable" class="po-empty-tip">
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
</div>
<template v-else>
<el-table
v-loading="pager.loading"
:data="pager.lists"
border
stripe
empty-text="暂无业务订单"
>
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="医生" prop="doctor_name" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ row.doctor_name || '—' }}
</template>
</el-table-column>
<el-table-column label="医助" prop="assistant_name" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ row.assistant_name || '—' }}
</template>
</el-table-column>
<el-table-column label="履约状态" width="110">
<template #default="{ row }">
<el-tag :type="fulfillmentTagType(row.fulfillment_status)" size="small" effect="light">
{{ fulfillmentText(row.fulfillment_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<pagination v-model="pager" @change="getLists" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm'
const props = defineProps<{
diagnosisId: number
patientId?: number | string
}>()
const patientIdNum = computed(() => {
const n = Number(props.patientId)
return Number.isFinite(n) && n > 0 ? n : 0
})
const patientIdAvailable = computed(() => patientIdNum.value > 0)
const queryParams = reactive<Record<string, number | string>>({})
const { pager, getLists, resetPage } = usePaging({
fetchFun: prescriptionOrderLists,
params: queryParams,
size: 10
})
const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) {
queryParams.context_diagnosis_id = props.diagnosisId
}
if (patientIdAvailable.value) {
queryParams.patient_id = patientIdNum.value
}
queryParams.scene = 'diagnosis_edit'
}
const formatAmount = (value: unknown) => {
const n = Number(value)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const formatTime = (value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
const d = new Date(value * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
return String(value)
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
}
return m[Number(s)] ?? '—'
}
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
const n = Number(s)
if (n === 3 || n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4 || n === 9 || n === 10) return 'danger'
if (n === 2 || n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
return 'info'
}
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
buildParams()
resetPage()
},
{ immediate: true }
)
defineExpose({ refresh: () => getLists() })
</script>
<style scoped lang="scss">
.patient-order-list {
padding: 4px 0;
}
.po-empty-tip {
padding: 24px 0;
}
</style>
@@ -0,0 +1,482 @@
<template>
<div class="tracking-matrix" v-loading="loading">
<div class="toolbar">
<el-radio-group v-model="rangeMode" @change="onRangeModeChange">
<el-radio-button value="7">最近 7 </el-radio-button>
<el-radio-button value="30">最近 30 </el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="rangeMode === 'custom'"
v-model="customRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
size="default"
:disabled-date="disabledFutureDate"
@change="onCustomRangeChange"
/>
<span class="hint">
超阈值红字年龄分段&lt;50空7/餐950空8/餐10
</span>
</div>
<el-empty
v-if="!loading && visibleDates.length === 0"
description="所选区间内无任何打卡记录"
:image-size="80"
/>
<el-table
v-else
:data="rows"
border
stripe
class="matrix-table"
empty-text="暂无数据"
row-key="date"
max-height="640"
>
<el-table-column prop="dateLabel" label="日期" width="120" fixed="left" />
<el-table-column label="血糖 (mmol/L)" min-width="180">
<template #default="{ row }">
<div v-if="row.bloodAny" class="cell-blood">
<span :class="{ 'cell-high': row.fastingHigh }">
<span class="value">{{ row.fasting ?? '—' }}</span>
<span v-if="row.fastingHigh" class="cell-up"></span>
</span>
<span :class="{ 'cell-high': row.postprandialHigh }">
<span class="value">{{ row.postprandial ?? '—' }}</span>
<span v-if="row.postprandialHigh" class="cell-up"></span>
</span>
<span :class="{ 'cell-high': row.otherHigh }">
其他
<span class="value">{{ row.other ?? '—' }}</span>
<span v-if="row.otherHigh" class="cell-up"></span>
</span>
</div>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="血压 (mmHg)" width="120" align="center">
<template #default="{ row }">
<span v-if="row.bp" :class="{ 'cell-high': row.bpHigh }">
{{ row.bp }}
<span v-if="row.bpHigh" class="cell-up"></span>
</span>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="西药" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.westernMedicine">{{ row.westernMedicine }}</span>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="胰岛素" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.insulin">{{ row.insulin }}</span>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="饮食打卡" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.diet"
placement="top"
:disabled="!row.diet"
effect="light"
>
<template #content>
<div class="diet-tip">
<div>{{ row.diet.breakfast || '—' }}</div>
<div>{{ row.diet.lunch || '—' }}</div>
<div>{{ row.diet.dinner || '—' }}</div>
</div>
</template>
<span class="cell-checked">已打卡</span>
</el-tooltip>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
<el-table-column label="运动打卡" min-width="160" align="center">
<template #default="{ row }">
<el-tooltip v-if="row.exercise" placement="top" effect="light">
<template #content>
<div class="exercise-tip">
<div>强度{{ row.exercise.intensityText || '—' }}</div>
<div>时长{{ row.exercise.duration ? row.exercise.duration + ' min' : '—' }}</div>
<div v-if="row.exercise.remark">备注{{ row.exercise.remark }}</div>
</div>
</template>
<span class="cell-checked">
已打卡
<span v-if="row.exercise.duration" class="meta">· {{ row.exercise.duration }}min</span>
<span v-if="row.exercise.intensityText" class="meta">· {{ row.exercise.intensityText }}</span>
</span>
</el-tooltip>
<span v-else class="cell-empty"></span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
isHighFastingBloodSugar,
isHighPostprandialBloodSugar,
isHighOtherBloodSugar,
isHighBloodPressure,
formatBp
} from '@/utils/blood-thresholds'
import { diagnosisTrackingWindow } from '@/api/tcm'
interface BloodRecord {
record_date?: string
record_date_ts?: number
fasting_blood_sugar?: any
postprandial_blood_sugar?: any
other_blood_sugar?: any
systolic_pressure?: any
diastolic_pressure?: any
western_medicine?: string
insulin?: string
[key: string]: any
}
interface DietRecord {
record_date?: string
breakfast?: string
lunch?: string
dinner?: string
[key: string]: any
}
interface ExerciseRecord {
record_date?: string
duration?: number
intensity?: number
intensity_text?: string
remark?: string
[key: string]: any
}
interface Props {
diagnosisId: number
age?: number | null
}
const props = withDefaults(defineProps<Props>(), {
age: null
})
type RangeMode = '7' | '30' | 'custom'
const rangeMode = ref<RangeMode>('30')
const customRange = ref<[string, string] | null>(null)
const loading = ref(false)
const bloodRecords = ref<BloodRecord[]>([])
const dietRecords = ref<DietRecord[]>([])
const exerciseRecords = ref<ExerciseRecord[]>([])
let requestSeq = 0
/** 把今天本地时间格式成 Y-m-d,避免 toISOString 的时区漂移 */
const formatDate = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const computeRange = (mode: RangeMode): { start: string; end: string } => {
if (mode === 'custom') {
const [s, e] = customRange.value || ['', '']
return { start: s || '', end: e || '' }
}
const days = mode === '7' ? 7 : 30
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - (days - 1))
return { start: formatDate(start), end: formatDate(end) }
}
const disabledFutureDate = (date: Date) => date.getTime() > Date.now()
const fetchTracking = async () => {
if (!props.diagnosisId || props.diagnosisId <= 0) {
bloodRecords.value = []
dietRecords.value = []
exerciseRecords.value = []
return
}
const range = computeRange(rangeMode.value)
if (rangeMode.value === 'custom' && (!range.start || !range.end)) {
// 等待用户选完日期
return
}
const seq = ++requestSeq
loading.value = true
try {
const res: any = await diagnosisTrackingWindow({
id: props.diagnosisId,
start_date: range.start,
end_date: range.end
})
if (seq !== requestSeq) return
bloodRecords.value = res?.blood_records || []
dietRecords.value = res?.diet_records || []
exerciseRecords.value = res?.exercise_records || []
} catch {
if (seq === requestSeq) {
bloodRecords.value = []
dietRecords.value = []
exerciseRecords.value = []
}
} finally {
if (seq === requestSeq) loading.value = false
}
}
const onRangeModeChange = () => {
if (rangeMode.value === 'custom') {
// 默认填入「最近 30 天」作为初值,让用户在此基础上调整
if (!customRange.value) {
const r = computeRange('30')
customRange.value = [r.start, r.end]
}
}
fetchTracking()
}
const onCustomRangeChange = () => {
if (rangeMode.value === 'custom') fetchTracking()
}
watch(
() => props.diagnosisId,
(id) => {
if (id && id > 0) fetchTracking()
}
)
onMounted(() => {
if (props.diagnosisId && props.diagnosisId > 0) fetchTracking()
})
/**
* 把同一天可能多条的血糖记录聚合:每段取第一条非空值(与 BloodRecordLogic 趋势图一致)
*/
const bloodByDate = computed(() => {
const map = new Map<string, BloodRecord>()
for (const r of bloodRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) {
map.set(d, {
record_date: d,
record_date_ts: r.record_date_ts
})
}
const acc = map.get(d)!
const fields: Array<keyof BloodRecord> = [
'fasting_blood_sugar',
'postprandial_blood_sugar',
'other_blood_sugar',
'systolic_pressure',
'diastolic_pressure',
'western_medicine',
'insulin'
]
for (const f of fields) {
if (
(acc[f] === undefined || acc[f] === null || acc[f] === '') &&
r[f] !== undefined &&
r[f] !== null &&
r[f] !== ''
) {
;(acc as any)[f] = r[f]
}
}
}
return map
})
const dietByDate = computed(() => {
const map = new Map<string, DietRecord>()
for (const r of dietRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) map.set(d, r)
}
return map
})
const exerciseByDate = computed(() => {
const map = new Map<string, ExerciseRecord>()
for (const r of exerciseRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) map.set(d, r)
}
return map
})
/**
* 已经由后端按区间过滤,前端只需取三类记录的日期并集 + desc 排序
*/
const visibleDates = computed<string[]>(() => {
const set = new Set<string>()
for (const r of bloodRecords.value) if (r.record_date) set.add(String(r.record_date))
for (const r of dietRecords.value) if (r.record_date) set.add(String(r.record_date))
for (const r of exerciseRecords.value) if (r.record_date) set.add(String(r.record_date))
const list = Array.from(set).filter((d) => d.length === 10)
list.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
return list
})
const rows = computed(() =>
visibleDates.value.map((date) => {
const blood = bloodByDate.value.get(date)
const diet = dietByDate.value.get(date)
const exercise = exerciseByDate.value.get(date)
const fasting = blood?.fasting_blood_sugar ?? null
const postprandial = blood?.postprandial_blood_sugar ?? null
const other = blood?.other_blood_sugar ?? null
const sys = blood?.systolic_pressure ?? null
const dia = blood?.diastolic_pressure ?? null
const bloodAny =
(fasting !== null && fasting !== '') ||
(postprandial !== null && postprandial !== '') ||
(other !== null && other !== '')
const bp = sys || dia ? formatBp({ systolic_pressure: sys, diastolic_pressure: dia }) : ''
return {
date,
dateLabel: `${date}`,
// 血糖
bloodAny,
fasting: fasting ?? '—',
postprandial: postprandial ?? '—',
other: other ?? '—',
fastingHigh: isHighFastingBloodSugar(fasting, props.age),
postprandialHigh: isHighPostprandialBloodSugar(postprandial, props.age),
otherHigh: isHighOtherBloodSugar(other, props.age),
// 血压
bp,
bpHigh: isHighBloodPressure({ systolic_pressure: sys, diastolic_pressure: dia }),
// 用药
westernMedicine: blood?.western_medicine || '',
insulin: blood?.insulin || '',
// 饮食
diet: diet
? {
breakfast: diet.breakfast || '',
lunch: diet.lunch || '',
dinner: diet.dinner || ''
}
: null,
// 运动
exercise: exercise
? {
duration: exercise.duration || 0,
intensityText: exercise.intensity_text || '',
remark: exercise.remark || ''
}
: null
}
})
)
defineExpose({ refresh: fetchTracking })
</script>
<style lang="scss" scoped>
.tracking-matrix {
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
.hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.matrix-table {
width: 100%;
}
.cell-blood {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12.5px;
line-height: 1.55;
> span {
display: inline-flex;
align-items: center;
gap: 4px;
.value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
}
}
.cell-high {
color: #dc2626;
font-weight: 700;
}
.cell-up {
font-size: 13px;
color: #dc2626;
}
.cell-empty {
color: var(--el-text-color-placeholder);
}
.cell-checked {
color: #2563eb;
font-weight: 500;
.meta {
color: var(--el-text-color-secondary);
font-weight: 400;
margin-left: 2px;
}
}
}
.diet-tip,
.exercise-tip {
font-size: 12.5px;
line-height: 1.55;
> div {
margin-bottom: 2px;
&:last-child {
margin-bottom: 0;
}
}
}
</style>
@@ -0,0 +1,179 @@
<template>
<div class="tracking-timeline-wrap">
<!-- 编辑模式输入新备注 -->
<div v-if="!readonly && diagnosisId" class="timeline-input">
<el-input
v-model="draft"
type="textarea"
:rows="2"
placeholder="输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录"
maxlength="1000"
show-word-limit
resize="none"
:disabled="saving"
/>
<div class="input-actions">
<el-button
type="primary"
size="small"
:loading="saving"
:disabled="!draft.trim()"
@click="handleSave"
>
添加
</el-button>
</div>
</div>
<!-- 时间轴 -->
<div v-if="notes.length" class="tracking-timeline">
<div v-for="note in notes" :key="note.id" class="timeline-node">
<div class="timeline-dot"></div>
<div class="timeline-date">{{ note.note_date }}</div>
<div class="timeline-body">
<div v-if="note.content" class="timeline-content">
<div
v-for="(line, idx) in splitLines(note.content)"
:key="idx"
class="content-line"
>
{{ line }}
</div>
</div>
</div>
</div>
</div>
<el-empty v-if="!notes.length && readonly" description="暂无跟踪备注" :image-size="48" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { diagnosisAddTrackingNote } from '@/api/tcm'
import feedback from '@/utils/feedback'
export interface TrackingNoteItem {
id: number
note_date: string
content: string | null
}
const props = defineProps<{
notes: TrackingNoteItem[]
diagnosisId?: number
readonly?: boolean
}>()
const emit = defineEmits<{ refresh: [] }>()
const draft = ref('')
const saving = ref(false)
const splitLines = (content: string | null): string[] => {
if (!content) return []
return content.split('\n').filter(Boolean)
}
const handleSave = async () => {
if (!props.diagnosisId) return
const content = draft.value.trim()
if (!content) return
saving.value = true
try {
await diagnosisAddTrackingNote({
diagnosis_id: props.diagnosisId,
tracking_content: content,
})
feedback.msgSuccess('已添加')
draft.value = ''
emit('refresh')
} finally {
saving.value = false
}
}
</script>
<style scoped lang="scss">
.tracking-timeline-wrap {
display: flex;
flex-direction: column;
gap: 14px;
}
.timeline-input {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px dashed #ebeef5;
}
.input-actions {
display: flex;
justify-content: flex-end;
}
.tracking-timeline {
position: relative;
padding-left: 22px;
}
.tracking-timeline::before {
content: '';
position: absolute;
left: 6px;
top: 4px;
bottom: 4px;
width: 2px;
background: #ebeef5;
border-radius: 1px;
}
.timeline-node {
position: relative;
padding-bottom: 16px;
}
.timeline-node:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -19px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #16a34a;
border: 2px solid #dcfce7;
}
.timeline-date {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
font-family: 'IBM Plex Mono', Consolas, monospace;
}
.timeline-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.content-line {
font-size: 12.5px;
color: #606266;
line-height: 1.6;
word-break: break-word;
}
</style>
+91 -35
View File
@@ -562,39 +562,35 @@
</div>
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<!-- 血糖血压记录标签页 -->
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
<blood-record-list
<!-- <el-tab-pane label="跟踪备注" name="trackingNote" :disabled="!formData.id" lazy>
<div v-if="formData.id" class="p-4">
<TrackingNoteTimeline
:notes="trackingNotes"
:diagnosis-id="Number(formData.id)"
:readonly="viewOnly"
@refresh="fetchTrackingNotes"
/>
</div>
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane> -->
<el-tab-pane
v-if="hasDailyRecordPermission"
label="日常记录"
name="daily"
:disabled="!formData.id"
lazy
>
<daily-matrix
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:age="formData.age"
:patient-name="formData.patient_name"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<!-- 饮食记录标签页 -->
<el-tab-pane label="饮食记录" name="diet" :disabled="!formData.id">
<diet-record-list
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加饮食记录" />
</el-tab-pane>
<!-- 运动打卡记录标签页 -->
<el-tab-pane label="运动打卡记录" name="exercise" :disabled="!formData.id">
<exercise-record-list
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
:read-only="viewOnly"
/>
<el-empty v-else description="请先保存诊单后再添加运动打卡记录" />
</el-tab-pane>
<!-- 病历记录标签页开方后自动显示 -->
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
<div v-if="formData.id" class="bingli-tab-content">
@@ -610,6 +606,22 @@
<el-empty v-else description="请先保存诊单,开方后病历将在此显示" />
</el-tab-pane>
<!-- 业务订单复用处方业务订单接口按患者ID聚合 / 按当前诊单过滤 -->
<el-tab-pane
label="业务订单"
name="patientOrders"
:disabled="!formData.id"
lazy
v-if="hasPermission(['tcm.diagnosis/patientOrders'])"
>
<patient-order-list
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id) || 0"
/>
<el-empty v-else description="请先保存诊单后查看业务订单" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['tcm.diagnosis/huifang'])"
label="视频录制回放记录"
@@ -694,7 +706,7 @@
</template>
<script setup lang="ts">
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
@@ -702,15 +714,15 @@ import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import BloodRecordList from './components/BloodRecordList.vue'
import DietRecordList from './components/DietRecordList.vue'
import ExerciseRecordList from './components/ExerciseRecordList.vue'
import DailyMatrix from './components/DailyMatrix.vue'
import CaseRecordList from './components/CaseRecordList.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
import TrackingNoteTimeline from './components/TrackingNoteTimeline.vue'
import PatientOrderList from './components/PatientOrderList.vue'
import TcmPrescription from '@/components/tcm-prescription/index.vue'
import { getDoctorNotes } from '@/api/patient'
@@ -730,6 +742,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
const phoneRevealUnlocked = ref(false)
@@ -748,10 +761,22 @@ const fetchDiagNotes = async () => {
} catch { diagNotes.value = [] }
}
const trackingNotes = ref<any[]>([])
const fetchTrackingNotes = async () => {
if (!formData.value.id) return
try {
trackingNotes.value = await diagnosisTrackingNotes({ diagnosis_id: Number(formData.value.id) }) || []
} catch { trackingNotes.value = [] }
}
watch(() => activeTab.value, async (tab) => {
if (tab === 'doctorNote' && formData.value.id) {
await fetchDiagNotes()
}
if (tab === 'trackingNote' && formData.value.id) {
await fetchTrackingNotes()
}
})
watch([visible, activeTab], () => {
@@ -772,6 +797,9 @@ watch([visible, activeTab], () => {
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
activeTab.value = 'basic'
}
if (activeTab.value === 'daily' && !hasDailyRecordPermission.value) {
activeTab.value = 'basic'
}
})
@@ -1118,6 +1146,8 @@ const open = async (type: string, id?: number) => {
} else if (type === 'add') {
const userStore = useUserStore()
formData.value.assistant_id = userStore.userInfo?.id || ''
// diagnosis_type follow_up server/sql/tcm_diagnosis.sql
formData.value.diagnosis_type = 'follow_up'
}
}
@@ -1579,6 +1609,7 @@ defineExpose({
.el-drawer__body {
background: #f8fafc;
min-width: 0;
}
.el-drawer__footer {
@@ -1587,22 +1618,48 @@ defineExpose({
box-shadow: 0 -4px 24px rgb(15 23 42 / 7%);
}
/* 多 Tab 标签:保证在窄抽屉内可横向滚动(含 flex 子项 min-width 与 EP 内层 overflow 链) */
.tcm-diagnosis-tabs.el-tabs {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.tcm-diagnosis-tabs .el-tabs__header {
min-width: 0;
width: 100%;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap {
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scrollbar-width: thin;
scrollbar-color: rgb(148 163 184 / 55%) transparent;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar {
display: none;
height: 0;
height: 4px;
}
.tcm-diagnosis-tabs .el-tabs__nav-wrap::-webkit-scrollbar-thumb {
background-color: rgb(148 163 184 / 55%);
border-radius: 4px;
}
.tcm-diagnosis-tabs .el-tabs__nav-scroll {
overflow: visible !important;
}
.tcm-diagnosis-tabs .el-tabs__nav {
display: flex;
flex-wrap: nowrap;
float: none !important;
width: max-content;
min-width: 100%;
}
.tcm-diagnosis-tabs .el-tabs__item {
@@ -1769,4 +1826,3 @@ defineExpose({
}
}
</style>
+324 -29
View File
@@ -37,12 +37,35 @@
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
</span>
<span
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
v-perms="['tcm.diagnosis/assign']"
class="pending-assign-wrap"
v-perms="['tcm.diagnosis/assign']"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
<span
:class="['chip', 'chip-pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
</span>
<el-date-picker
v-if="formData.pending_assign === '1'"
v-model="formData.pending_assign_order_month"
type="month"
placeholder="订单月份"
value-format="YYYY-MM"
clearable
class="pending-assign-month"
@change="onPendingAssignOrderMonthChange"
/>
<el-input
v-if="formData.pending_assign === '1'"
v-model="formData.pending_assign_keyword"
placeholder="搜身份证/诊单ID/患者号/备注…"
clearable
class="pending-assign-search"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</span>
</div>
<div class="search-row">
@@ -116,11 +139,13 @@
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} </div>
</div>
<div class="table-wrap px-4">
<el-table
:data="pager.lists"
<el-table
:data="pager.lists"
row-key="id"
size="default"
v-loading="pager.loading"
@selection-change="handleSelectionChange"
@row-dblclick="goReadonly"
:row-class-name="getRowClassName"
class="diagnosis-table"
stripe
@@ -151,7 +176,7 @@
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}</div>
</template>
</el-table-column>
<el-table-column label="挂号" min-width="150">
<el-table-column label="挂号" min-width="175">
<template #default="{ row }">
<div
class="appointment-cell"
@@ -168,6 +193,17 @@
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
<div class="apt-time">{{ apt.time_text || '-' }}</div>
<div v-if="canCancelAppointmentItem(apt)" class="apt-actions">
<el-button
v-perms="['tcm.diagnosis/guahao']"
type="danger"
link
size="small"
@click.stop="handleCancelAppointmentItem(row, apt)"
>
取消挂号
</el-button>
</div>
</div>
</template>
<template v-else>
@@ -215,6 +251,20 @@
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column label="未服务天数" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.last_blood_record_at"
:content="`最近一次血糖打卡:${row.last_blood_record_at}`"
placement="top"
>
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
{{ row.unserved_days ?? '—' }}
</span>
</el-tooltip>
<span v-else class="unserved-days unserved-muted"></span>
</template>
</el-table-column>
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
<template #default="{ row }">
<div class="video-watch-col">
@@ -239,9 +289,18 @@
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="left">
<el-table-column label="操作" width="340" fixed="right" align="left">
<template #default="{ row }">
<div class="action-cell">
<div class="action-cell" @click.stop @dblclick.stop>
<el-button
type="primary"
link
size="small"
@click="goReadonly(row)"
v-perms="['tcm.diagnosis/readonlyDetail']"
>
查看
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
<el-button
v-perms="['tcm.diagnosis/kaifang']"
@@ -280,7 +339,12 @@
</el-dropdown-item>
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
<el-dropdown-item
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
command="cancelApt"
>
<el-icon><CircleClose /></el-icon>取消挂号
</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahaoLogList'])" command="guahaoLogs"><el-icon><List /></el-icon>挂号日志</el-dropdown-item>
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
@@ -371,6 +435,7 @@
v-model="assignDialogVisible"
:title="assignMode === 'batch' ? '批量指派医助' : '指派医助'"
width="500px"
@closed="resetAssignDialog"
>
<el-form :model="assignForm" label-width="100px">
<el-form-item label="选择医助" required>
@@ -380,7 +445,6 @@
class="w-full"
filterable
clearable
value-key="id"
>
<el-option
v-for="item in assistantOptions"
@@ -391,7 +455,7 @@
</el-select>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
</el-form-item>
<el-form-item v-else label="诊单信息">
<div class="text-gray-500">
@@ -616,7 +680,7 @@ import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
@@ -665,7 +729,11 @@ const formData = reactive({
/** 顶部「待预约」Tab:仅展示已建诊单且尚未挂号(无有效预约) */
pending_booking: '' as '' | '1',
/** 顶部「已完成」Tab:至少有一条挂号记录为已完成 status=3 */
completed_appointment: '' as '' | '1'
completed_appointment: '' as '' | '1',
/** 待分配医助:按业务订单创建月份筛选 YYYY-MM,空表示不限月份 */
pending_assign_order_month: '' as string,
/** 待分配医助 Tab:多字段检索(后端扩展至身份证/诊单ID/备注等;不额外套数据权限) */
pending_assign_keyword: '' as string
})
const activeTab = ref('all')
@@ -700,7 +768,65 @@ const setConfirmed = (v: string) => {
fetchDateCounts()
}
/** 待分配 + 宽搜时:请求只吃分页与关键词,不带其它 Tab/日期/筛选(与 buildTcmDiagnosisListRequestPayload 一致) */
function isPendingAssignWideKeywordParams(req: Record<string, unknown>): boolean {
const pa = req.pending_assign === '1' || req.pending_assign === 1
if (!pa) {
return false
}
const k1 = String(req.pending_assign_keyword ?? '').trim()
const k2 = String(req.keyword ?? '').trim()
return k1 !== '' || k2 !== ''
}
/** 待分配宽搜:仅传分页 + pending_assign + 关键词,丢弃其余查询参数 */
function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Record<string, unknown> {
if (!isPendingAssignWideKeywordParams(req)) {
return req
}
const k1 = String(req.pending_assign_keyword ?? '').trim()
const k2 = String(req.keyword ?? '').trim()
const kw = k1 !== '' ? k1 : k2
return {
page_no: req.page_no,
page_size: req.page_size,
pending_assign: 1,
pending_assign_keyword: kw
}
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
/** 宽搜提交前清理界面上的其它检索,避免 Chip 仍显示「已选」但与实际请求不一致 */
function clearSecondaryFiltersWhenPendingAssignWideSearch() {
if (formData.pending_assign !== '1') {
return
}
const kw1 = (formData.pending_assign_keyword ?? '').trim()
const kw2 = (formData.keyword ?? '').trim()
if (kw1 === '' && kw2 === '') {
return
}
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.diagnosis_confirmed = ''
formData.has_appointment = ''
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.start_time = ''
formData.end_time = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
formData.keyword = ''
}
}
const doSearch = () => {
clearSecondaryFiltersWhenPendingAssignWideSearch()
pager.page = 1
getLists()
fetchDateCounts()
@@ -723,6 +849,8 @@ const pendingAssignCount = ref<number | undefined>(undefined)
const handleDateTabClick = async (value: string) => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
@@ -735,6 +863,8 @@ const handleDateTabClick = async (value: string) => {
/** 已创建诊单、尚未挂号的记录(与第二行「未挂号」同条件,便于从 Tab 一键进入) */
const handlePendingBookingTabClick = async () => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = '1'
formData.completed_appointment = ''
formData.appointment_date = ''
@@ -747,6 +877,8 @@ const handlePendingBookingTabClick = async () => {
/** 至少有一条挂号为「已完成」(后端 appointment.status=3) */
const handleCompletedVisitTabClick = async () => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = '1'
formData.has_appointment = ''
@@ -762,6 +894,18 @@ const handlePendingAssignTabClick = async () => {
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
}
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 待分配:切换订单月份后刷新列表与角标 */
const onPendingAssignOrderMonthChange = async (val: string | null) => {
formData.pending_assign_order_month = val || ''
if (formData.pending_assign !== '1') return
pager.page = 1
await getLists()
fetchDateCounts()
@@ -779,7 +923,16 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
tcmDiagnosisLists(
buildTcmDiagnosisListRequestPayload({
pending_assign: 1,
page_no: 1,
page_size: 1,
pending_assign_order_month: formData.pending_assign_order_month,
pending_assign_keyword: formData.pending_assign_keyword,
keyword: formData.keyword
}) as any
)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -913,7 +1066,7 @@ const getAssistantName = (assistantId: number | string) => {
}
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: tcmDiagnosisLists,
fetchFun: fetchTcmDiagnosisListsForPaging,
params: formData
})
@@ -931,6 +1084,8 @@ const handleReset = () => {
formData.appointment_date = ''
formData.has_appointment = ''
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
@@ -944,6 +1099,19 @@ const isDiagnosisConfirmed = (row: any) => {
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
}
/**
* 未服务天数着色规则计划 T3
* null/undefined >=7 3-6 <=2 绿
*/
const unservedDaysClass = (n: unknown) => {
if (n === null || n === undefined) return 'unserved-muted'
const num = Number(n)
if (!Number.isFinite(num)) return 'unserved-muted'
if (num >= 7) return 'unserved-red'
if (num >= 3) return 'unserved-orange'
return 'unserved-green'
}
//
const getRowClassName = ({ row }: { row: any }) => {
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
@@ -1146,6 +1314,8 @@ const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
//
const selectedIds = ref<number[]>([])
/** 批量指派打开弹窗时的诊单 id 快照(弹窗内选医助可能触发表格重绘导致勾选丢失,提交以快照为准) */
const batchAssignIds = ref<number[]>([])
const assignDialogVisible = ref(false)
const assignLoading = ref(false)
const assignMode = ref<'batch' | 'single'>('batch')
@@ -1154,6 +1324,11 @@ const assignForm = ref({
assistant_id: null as number | null
})
function resetAssignDialog() {
batchAssignIds.value = []
assignForm.value.assistant_id = null
}
const handleSelectionChange = (selection: any[]) => {
selectedIds.value = selection.map(item => item.id)
}
@@ -1175,6 +1350,7 @@ const canBatchCancelAssign = computed(() => {
const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
batchAssignIds.value = []
//
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignDialogVisible.value = true
@@ -1189,6 +1365,7 @@ const handleBatchAssign = () => {
assignMode.value = 'batch'
currentRow.value = null
assignForm.value.assistant_id = null
batchAssignIds.value = [...selectedIds.value]
assignDialogVisible.value = true
}
@@ -1207,15 +1384,20 @@ const handleConfirmAssign = async () => {
assistant_id: assignForm.value.assistant_id
})
} else {
//
const promises = selectedIds.value.map(id =>
tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id
// 使 id
const ids = batchAssignIds.value
if (!ids.length) {
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
return
}
for (const id of ids) {
await tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id!,
})
)
await Promise.all(promises)
}
selectedIds.value = []
batchAssignIds.value = []
}
assignDialogVisible.value = false
@@ -1263,10 +1445,13 @@ const handleEdit = (id: number) => {
editRef.value.open('edit', id)
}
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方 */
/** 与预约列表开方一致:已通过且未作废 → 查看,否则开方。(待分配医助 Tab 一律显示「开方」,避免误解为只读) */
function prescriptionActionLabel(row: any) {
if (formData.pending_assign === '1') {
return '开方'
}
if (
row?.prescription_audit_status === 1 &&
Number(row?.prescription_audit_status) === 1 &&
Number(row?.prescription_void_status) !== 1
) {
return '查看'
@@ -1371,13 +1556,29 @@ const appointmentCellClasses = (row: any) => {
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/** 已预约、已过号可取消(后端同步限制) */
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
}
//
/** 单条挂号记录是否允许取消(已预约 1 / 已过号 4) */
const canCancelAppointmentItem = (apt: any) => {
const s = Number(apt?.status)
return !!(apt?.id && (s === 1 || s === 4))
}
/**
* 更多里行级取消仅当列表只展示一条挂号时可点避免多条时与主键 appointment_id 不对齐
* 多条时在挂号列每条旁单独取消
*/
const canCancelAppointmentFromDropdown = (row: any) => {
if (!canCancelAppointmentRow(row)) return false
const rows = appointmentRows(row)
return rows.length <= 1
}
//
const handleCancelAppointment = async (row: any) => {
if (!canCancelAppointmentRow(row)) {
const s = Number(row.appointment_status)
@@ -1388,9 +1589,39 @@ const handleCancelAppointment = async (row: any) => {
}
return
}
const rows = appointmentRows(row)
const target = rows.length === 1 ? rows[0] : null
const aptId = target?.id && canCancelAppointmentItem(target) ? target.id : row.appointment_id
try {
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
await cancelAppointment({ id: row.appointment_id })
await cancelAppointment({ id: aptId })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
await fetchGuahaoLogs(row.id)
}
} catch (e: any) {
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
}
}
/** 取消指定挂号(多条展示时用) */
const handleCancelAppointmentItem = async (row: any, apt: any) => {
if (!canCancelAppointmentItem(apt)) {
const s = Number(apt?.status)
if (s === 3) {
feedback.msgWarning('已完成就诊,无法取消挂号')
} else {
feedback.msgWarning('该挂号无法取消')
}
return
}
const doc = apt.doctor_name || '—'
const t = apt.time_text || '—'
try {
await feedback.confirm(`确定取消「${doc}${t} 的挂号吗?(患者:${row.patient_name || '—'}`)
await cancelAppointment({ id: apt.id })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
@@ -1706,6 +1937,17 @@ const handleDeleteRecord = async (id: number) => {
}
const route = useRoute()
const router = useRouter()
/** 跳到只读病例页(T1+T2):双击行 / 点「查看」按钮均触发 */
const goReadonly = (row: any) => {
// readonlyDetail v-perms
if (!hasPermission(['tcm.diagnosis/readonlyDetail'])) return
const id = Number(row?.id)
if (!id || id <= 0) return
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(id) } })
}
onMounted(() => {
//
formData.appointment_date = todayStr.value
@@ -1829,6 +2071,27 @@ onUnmounted(() => {
background: var(--el-color-warning);
}
}
.pending-assign-wrap {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-left: 8px;
.chip-pending {
margin-left: 0;
}
}
.pending-assign-month {
width: 128px;
}
.pending-assign-search {
width: 220px;
max-width: 42vw;
}
}
.search-row {
@@ -2013,6 +2276,11 @@ onUnmounted(() => {
margin-top: 2px;
}
.apt-actions {
margin-top: 4px;
line-height: 1.2;
}
&.apt-item-active {
.apt-badge {
color: #3a4acc;
@@ -2091,6 +2359,33 @@ onUnmounted(() => {
color: var(--el-text-color-placeholder);
}
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
.unserved-days {
display: inline-block;
min-width: 28px;
font-weight: 600;
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: default;
}
.unserved-muted {
color: var(--el-text-color-placeholder);
font-weight: 400;
}
.unserved-green {
color: #16a34a;
}
.unserved-orange {
color: #ea580c;
}
.unserved-red {
color: #dc2626;
}
.diagnosis-cell {
display: flex;
flex-direction: column;
+279
View File
@@ -0,0 +1,279 @@
<template>
<div class="readonly-page" v-loading="loading">
<!-- 顶部 hero -->
<div class="readonly-hero">
<div class="hero-left">
<el-button link size="default" @click="goBack" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
<span>返回</span>
</el-button>
<span class="title">患者信息详情</span>
</div>
<div v-if="diag?.patient_name" class="hero-right">
<span class="patient-name">{{ diag.patient_name }}</span>
<span class="patient-meta">
{{ formatGender(diag.gender) }}
<template v-if="diag.age != null"> · {{ diag.age }}</template>
</span>
<el-tooltip
v-if="lastBloodAt"
:content="`最近一次血糖打卡:${lastBloodAt}`"
placement="bottom"
>
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
未服务 {{ unservedDays ?? '—' }}
</span>
</el-tooltip>
<span v-else class="unserved-badge unserved-muted">从未打卡血糖</span>
</div>
</div>
<!-- 加载失败 / 越权 -->
<el-empty
v-if="!loading && !detail"
:description="errorMsg || '诊单不存在或无权访问'"
class="empty-state"
/>
<template v-else-if="detail">
<PatientInfoCard :apt="apt" :diag="diag" />
<PatientCaseCard :apt="apt" :diag="diag" />
<!-- 日常记录只读 -->
<div class="card tracking-card">
<div class="card-title">日常记录</div>
<DailyMatrix
:diagnosis-id="Number(diagnosisIdNum)"
:patient-id="Number(diag?.patient_id ?? 0)"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
</div>
<!-- 医生备注复用 reception NoteTimeline但只读 -->
<div v-if="doctorNotes.length" class="card note-card">
<div class="card-title">医生备注 & 舌苔照片 & 检查报告</div>
<NoteTimeline :notes="doctorNotes" :diagnosis-id="diag?.id" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { diagnosisReadonlyDetail } from '@/api/tcm'
import { formatGender } from '@/utils/diag-display'
import PatientInfoCard from './components/PatientInfoCard.vue'
import PatientCaseCard from './components/PatientCaseCard.vue'
import DailyMatrix from './components/DailyMatrix.vue'
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
const route = useRoute()
const router = useRouter()
const diagnosisIdNum = computed(() => Number(route.query.id || 0))
const loading = ref(false)
const errorMsg = ref('')
const detail = ref<any>(null)
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const unservedDays = computed<number | null>(() => {
const v = detail.value?.unserved_days
return v === null || v === undefined ? null : Number(v)
})
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
/**
* 未服务天数着色规则 T3 列表一致
* null/undefined >=7 3-6 <=2 绿
*/
const unservedDaysClass = (n: unknown) => {
if (n === null || n === undefined) return 'unserved-muted'
const num = Number(n)
if (!Number.isFinite(num)) return 'unserved-muted'
if (num >= 7) return 'unserved-red'
if (num >= 3) return 'unserved-orange'
return 'unserved-green'
}
const fetchDetail = async () => {
const id = diagnosisIdNum.value
if (!id || id <= 0) {
errorMsg.value = '诊单 id 缺失'
detail.value = null
return
}
loading.value = true
errorMsg.value = ''
try {
const res: any = await diagnosisReadonlyDetail({ id })
detail.value = res || null
} catch (e: any) {
errorMsg.value = e?.message || ''
detail.value = null
} finally {
loading.value = false
}
}
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push({ path: '/tcm/diagnosis' })
}
}
onMounted(fetchDetail)
</script>
<style lang="scss" scoped>
/* 沿用接诊台的设计变量;该文件挂在管理后台,--rx-* 已在 reception/index.vue 全局可见的同款值 */
.readonly-page {
--rx-surface: #ffffff;
--rx-line: #e6ebf2;
--rx-ink: #1f2937;
--rx-muted: #4b5563;
--rx-soft: #6b7280;
--rx-accent: #2563eb;
--rx-accent-strong: #1d4ed8;
--rx-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--rx-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
--rx-font-data: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.readonly-hero {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
border: 1px solid #dde7ff;
border-radius: 12px;
padding: 12px 16px;
.hero-left {
display: flex;
align-items: center;
gap: 12px;
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.title {
font-size: 16px;
font-weight: 700;
color: var(--rx-ink);
}
}
.hero-right {
display: flex;
align-items: center;
gap: 12px;
.patient-name {
font-size: 18px;
font-weight: 700;
color: var(--rx-ink);
}
.patient-meta {
font-size: 13px;
color: var(--rx-muted);
}
}
}
.unserved-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: rgba(255, 255, 255, 0.7);
border: 1px solid #dde7ff;
cursor: default;
&.unserved-muted {
color: var(--el-text-color-placeholder);
font-weight: 400;
}
&.unserved-green {
color: #16a34a;
border-color: #bbf7d0;
background: rgba(22, 163, 74, 0.06);
}
&.unserved-orange {
color: #ea580c;
border-color: #fed7aa;
background: rgba(234, 88, 12, 0.06);
}
&.unserved-red {
color: #dc2626;
border-color: #fecaca;
background: rgba(220, 38, 38, 0.08);
}
}
.empty-state {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 40px 0;
}
.card {
background: var(--rx-surface);
border: 1px solid var(--rx-line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--rx-shadow-sm);
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--rx-ink);
display: flex;
align-items: center;
gap: 10px;
padding-left: 10px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
</style>
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@
namespace app\adminapi\controller;
use app\common\service\DirectUploadService;
use app\common\service\UploadService;
use Exception;
use think\response\Json;
@@ -77,4 +78,41 @@ class UploadController extends BaseAdminController
}
}
/**
* @notes 浏览器直传 OSS - 签发 STS 临时凭证
* @return Json
*/
public function ossCredentials()
{
$type = trim((string)$this->request->post('type', 'video'));
try {
$result = DirectUploadService::issueCredentials($type);
return $this->success('ok', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 浏览器直传 OSS - 上传完成回执(写 file + HEAD 校验)
* @return Json
*/
public function ossConfirm()
{
try {
$result = DirectUploadService::confirm([
'type' => trim((string)$this->request->post('type', 'video')),
'key' => trim((string)$this->request->post('key', '')),
'name' => trim((string)$this->request->post('name', '')),
'size' => (int)$this->request->post('size', 0),
'content_type' => trim((string)$this->request->post('content_type', '')),
'cid' => (int)$this->request->post('cid', 0),
'admin_id' => $this->adminId,
]);
return $this->success('上传成功', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
}
}
}
@@ -126,7 +126,11 @@ class DeptController extends BaseAdminController
*/
public function all()
{
$result = DeptLogic::getAllData();
$apply = (int) ($this->request->get('apply_data_scope', 0));
$result = $apply === 1
? DeptLogic::getAllDataScoped($this->adminId, $this->adminInfo)
: DeptLogic::getAllData();
return $this->data($result);
}
@@ -21,7 +21,7 @@ class DeptPerformanceTargetController extends BaseAdminController
$params = (new DeptPerformanceTargetValidate())->goCheck('monthMatrix');
$ym = (string) $params['year_month'];
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym));
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo));
}
public function batchSave()
@@ -37,12 +37,18 @@ class DeptPerformanceTargetController extends BaseAdminController
$ym,
$items,
$this->adminId,
$this->adminInfo,
(string) ($this->adminInfo['name'] ?? '')
);
if ($result === false) {
return $this->fail(DeptPerformanceTargetLogic::getError());
}
return $this->success('保存成功', DeptPerformanceTargetLogic::monthMatrix($ym), 1, 1);
return $this->success(
'保存成功',
DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo),
1,
1
);
}
}
@@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController
{
public function overview()
{
$result = ConversionLogic::overview($this->request->get());
$result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
return $this->data($result);
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
/**
* 医生日统计(系统/手动开方、成交、挂号状态)
*
* - GET stats.doctorDailyStats/overview
* ?start_date=&end_date=&channel_code=
* 不传/忽略 dept_ids(医生统计不按展示部门检索);未传日期时由后端默认当日。
*/
class DoctorDailyStatsController extends BaseAdminController
{
public function overview()
{
$params = $this->request->get();
return $this->data(DoctorDailyStatsLogic::overview($params, $this->adminId, $this->adminInfo));
}
}
@@ -10,10 +10,13 @@ use app\adminapi\logic\stats\YejiStatsLogic;
/**
* 业绩看板(部门 × 时间区间)控制器
*
* - GET stats.yeji-stats/overview 单区间
* - GET stats.yeji-stats/multi 多区间一次返回(默认 //今日/昨日 四张表)
* - GET stats.yeji-stats/dept-options 部门下拉
* - GET stats.yeji-stats/leaderboard 医助排行榜(按展示部门分表)
* - GET stats.yejiStats/overview 单区间
* - GET stats.yejiStats/multi 多区间一次返回(默认 //今日/昨日 四张表)
* - GET stats.yejiStats/deptOptions 部门下拉
* - GET stats.yejiStats/leaderboard 医助排行榜(按展示部门分表)
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
*/
class YejiStatsController extends BaseAdminController
{
@@ -23,7 +26,7 @@ class YejiStatsController extends BaseAdminController
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::overview($params));
return $this->data(YejiStatsLogic::overview($params, $this->adminId, $this->adminInfo));
}
/**
@@ -79,13 +82,13 @@ class YejiStatsController extends BaseAdminController
return $this->data([
'ranges' => $ranges,
'tables' => YejiStatsLogic::overviewBatch($ranges, $base),
'tables' => YejiStatsLogic::overviewBatch($ranges, $base, $this->adminId, $this->adminInfo),
]);
}
public function deptOptions()
{
return $this->data(YejiStatsLogic::deptOptions());
return $this->data(YejiStatsLogic::deptOptions($this->adminId, $this->adminInfo));
}
/** 渠道(标签)下拉,按 source_group_name 分组 */
@@ -100,6 +103,42 @@ class YejiStatsController extends BaseAdminController
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::assistantLeaderboards($params));
return $this->data(YejiStatsLogic::assistantLeaderboards($params, $this->adminId, $this->adminInfo));
}
/** 「未归属中心」行:按诊单医助拆解业绩(与表格补差、合计业绩诊单医助归属一致) */
public function unassignedBreakdown()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::unassignedCenterBreakdown($params, $this->adminId, $this->adminInfo));
}
/** 进线数据明细:与看板「进线数据」同口径 */
public function leadLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::leadLineList($params, $this->adminId, $this->adminInfo));
}
/** 医助排行榜「预约诊单」:挂号记录逐条列表(与计数同口径) */
public function appointmentLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::leaderboardAppointmentLines($params, $this->adminId, $this->adminInfo));
}
/** 二中心复诊:按部门行 + 复诊分项拆解医助与业务订单笔数 */
public function revisitBreakdown()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
}
}
@@ -18,6 +18,7 @@ use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\logic\order\OrderActionLogLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\common\model\Order;
use app\common\model\WechatChatRecord;
@@ -113,12 +114,86 @@ class DiagnosisController extends BaseAdminController
*/
public function detail()
{
$params = (new DiagnosisValidate())->goCheck('id');
$result = DiagnosisLogic::detail($params);
return $this->data($result);
}
/**
* @notes 二诊只读病例详情(患者信息 + 病例 + 跟踪记录)
*
* 路由:GET /tcm.diagnosis/readonlyDetail?id=:diagnosisId
* 权限:tcm.diagnosis/readonlyDetail
*
* @return \think\response\Json
*/
public function readonlyDetail()
{
$params = (new DiagnosisValidate())->goCheck('readonlyDetail');
$result = DiagnosisLogic::readonlyDetail($params, $this->adminId, $this->adminInfo);
if (empty($result)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
return $this->data($result);
}
/**
* @notes 跟踪信息(血糖血压 / 饮食 / 运动)—— 按日期区间 lazy load
*
* 路由:GET /tcm.diagnosis/trackingWindow?id=:diagnosisId&start_date=&end_date=
* 权限:tcm.diagnosis/readonlyDetail(与只读病例页同档;接诊台亦复用)
*
* @return \think\response\Json
*/
public function trackingWindow()
{
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
$result = DiagnosisLogic::fetchTrackingWindow(
(int) $params['id'],
(string) ($params['start_date'] ?? ''),
(string) ($params['end_date'] ?? '')
);
return $this->data($result);
}
/**
* @notes 新增跟踪备注(按天合并追加,仅文字)
*
* 路由:POST /tcm.diagnosis/addTrackingNote
* 权限:tcm.diagnosis/addTrackingNote
*
* @return \think\response\Json
*/
public function addTrackingNote()
{
$params = (new DiagnosisValidate())->post()->goCheck('addTrackingNote');
$ok = TrackingNoteLogic::addOrAppend([
'diagnosis_id' => (int) $params['diagnosis_id'],
'admin_id' => (int) $this->adminId,
'content' => (string) $params['tracking_content'],
]);
if ($ok === false) {
return $this->fail(TrackingNoteLogic::getError() ?: '保存失败');
}
return $this->success('保存成功');
}
/**
* @notes 拉取跟踪备注列表(note_date DESC
*
* 路由:GET /tcm.diagnosis/trackingNotes?diagnosis_id=:diagnosisId
* 权限:tcm.diagnosis/trackingNotes
*
* @return \think\response\Json
*/
public function trackingNotes()
{
$params = (new DiagnosisValidate())->goCheck('trackingNotes');
return $this->data(TrackingNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
}
/**
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
*/
@@ -332,6 +407,36 @@ class DiagnosisController extends BaseAdminController
return $this->data($result);
}
/**
* @notes 触发后台异步同步当前诊单的腾讯云 IM 聊天记录到本地归档表
* 请求即返回,真正的同步逻辑在 fastcgi_finish_request 之后执行
*/
public function triggerImChatSync()
{
$diagnosisId = (int)$this->request->post('diagnosis_id', 0);
if ($diagnosisId <= 0) {
$diagnosisId = (int)$this->request->get('diagnosis_id', 0);
}
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
}
register_shutdown_function(function () use ($diagnosisId) {
try {
@set_time_limit(300);
ignore_user_abort(true);
DiagnosisLogic::syncImChatArchiveForDiagnosis($diagnosisId);
} catch (\Throwable $e) {
\think\facade\Log::warning('triggerImChatSync failed', [
'diagnosis_id' => $diagnosisId,
'err' => $e->getMessage(),
]);
}
});
return $this->success('已发起后台同步,几秒后请重新加载查看', ['queued' => true]);
}
/**
* @notes 绑定 TRTC 房间号到当前诊单通话记录(便于云端录制回调关联)
* @return \think\response\Json
@@ -396,6 +501,24 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 手动上传视频前,先创建一条模拟通话记录
* @return \think\response\Json
*/
public function createManualCallRecord()
{
$params = (new DiagnosisValidate())->post()->goCheck('trackingNotes');
$result = DiagnosisLogic::createManualCallRecord([
'diagnosis_id' => (int)$params['diagnosis_id'],
'admin_id' => (int)$this->adminId,
]);
if (!$result) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('创建成功', $result);
}
/**
* @notes 医助旁观诊单当前视频通话(TRTC 进房参数,仅拉流)
* @return \think\response\Json
@@ -0,0 +1,81 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\tcm\DiagnosisTodoLists;
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
use app\adminapi\validate\tcm\DiagnosisTodoValidate;
/**
* 诊单待办事项控制器
*
* 职责:仅 lists / add / cancel / detail;编辑能力故意不开放(要改 = 取消重建)
*
* @package app\adminapi\controller\tcm
*/
class DiagnosisTodoController extends BaseAdminController
{
/**
* @notes 待办列表(按 diagnosis_id
*/
public function lists()
{
(new DiagnosisTodoValidate())->goCheck('list');
return $this->dataLists(new DiagnosisTodoLists());
}
/**
* @notes 新增待办
*/
public function add()
{
$params = (new DiagnosisTodoValidate())->post()->goCheck('add');
$result = DiagnosisTodoLogic::add($params, $this->adminId, $this->adminInfo);
if (!$result) {
return $this->fail(DiagnosisTodoLogic::getError() ?: '创建失败');
}
return $this->success('创建成功', [], 1, 1);
}
/**
* @notes 取消待办(仅 status=0 可取消,仅创建人/超管)
*/
public function cancel()
{
$params = (new DiagnosisTodoValidate())->post()->goCheck('cancel');
$result = DiagnosisTodoLogic::cancel((int) $params['id'], $this->adminId);
if (!$result) {
return $this->fail(DiagnosisTodoLogic::getError() ?: '取消失败');
}
return $this->success('已取消', [], 1, 1);
}
/**
* @notes 待办详情
*/
public function detail()
{
$params = (new DiagnosisTodoValidate())->goCheck('detail');
$result = DiagnosisTodoLogic::detail((int) $params['id'], $this->adminId);
return $this->data($result);
}
}
@@ -105,6 +105,17 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功', $result);
}
public function updateAmount()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('updateAmount');
$result = PrescriptionOrderLogic::updateAmount($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('修改成功');
}
/**
* 修改关联处方的患者姓名与手机号(订单详情场景)
*/
+2 -1
View File
@@ -16,7 +16,8 @@ return [
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => ['app\adminapi\listener\OperationLog'],
// 关闭管理员操作日志写入 zyt_operation_log(需记录时恢复下行 listener
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
]
@@ -82,7 +82,7 @@ class AuthMiddleware
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
$AdminUris = $this->formatUrl($AdminUris);
if (in_array($accessUri, $AdminUris)) {
if (in_array($accessUri, $AdminUris) || $this->matchPermissionAlias($accessUri, $AdminUris)) {
return $next($request);
}
return JsonService::fail('权限不足,无法访问或操作');
@@ -103,6 +103,25 @@ class AuthMiddleware
}, $data);
}
/**
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
*/
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
if (!in_array('tcm.diagnosis/dailyrecord', $adminUris, true)) {
return false;
}
return in_array($accessUri, [
'tcm.diagnosistodo/lists',
'tcm.diagnosistodo/add',
'tcm.diagnosistodo/cancel',
'tcm.diagnosis/trackingnotes',
'tcm.diagnosis/addtrackingnote',
], true);
}
/**
* 面诊进度专用:auth.admin/lists、doctor.appointment/lists + progress_board=1,不校验菜单权限
*/
@@ -118,4 +137,4 @@ class AuthMiddleware
return in_array($c, ['auth.admin', 'doctor.appointment'], true);
}
}
}
+228 -50
View File
@@ -17,7 +17,9 @@ namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\BloodRecord;
use app\common\model\DiagnosisViewRecord;
use app\common\model\Order;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\CallRecord;
@@ -62,36 +64,36 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
// 待分配医助筛选:assistant_id 为空或 0(放在最前面判断,便于跳过医助角色的自我过滤)
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
$pendingWideSearch = $this->isPendingAssignWideKeywordSearchActive();
// 仅「待分配列表」收窄到未分配医助;宽搜不套此池,与「全部」一致可命中已分配医助的诊单
$narrowPendingAssignPool = $pendingAssign && !$pendingWideSearch;
// 构建查询
$query = Diagnosis::where($this->searchWhere);
// 构建查询(待分配+关键词检索时不带诊单启用/禁用 status 条件,并弱化挂号/确认等 Tab 残留参数)
$baseWhere = $pendingWideSearch
? $this->searchWhereWithoutDiagnosisStatus($this->searchWhere)
: $this->searchWhere;
$query = Diagnosis::where($baseWhere);
// 如果是医助角色(role_id=2),只显示自己添加的患者;
// 但当前在查"待分配医助"时应放开,否则 NULL 记录永远不会匹配 assistant_id=adminId
if (in_array(2, $roleIds) && !$pendingAssign) {
// 医助角色(role_id=2):仅在普通列表(非待分配、非待分配宽搜)下限制为本人诊单;待分配窄列表与宽搜均不设此限
if (in_array(2, $roleIds) && !$pendingAssign && !$pendingWideSearch) {
$query->where('assistant_id', $this->adminId);
}
if (!$pendingAssign) {
// 数据权限:待分配(窄列表与宽搜)均不按 assistant 范围过滤;宽搜额外豁免以便全库关键词命中(与即时需求对齐)
if (!$pendingAssign && !$pendingWideSearch) {
$this->applyDataScopeByOwner($query, 'assistant_id');
}
// 关键字搜索:支持患者姓名手机号(模糊匹配)
if (isset($this->params['keyword']) && trim((string) $this->params['keyword']) !== '') {
$keyword = trim((string) $this->params['keyword']);
$query->where(function ($q) use ($keyword) {
$q->whereLike('patient_name', '%' . $keyword . '%')
->whereOr('phone', 'like', '%' . $keyword . '%');
});
}
// 关键字:待分配 Tab 用多字段宽搜;其它 Tab 仍为姓名/手机
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
// 待分配医助:assistant_id 为 NULL 或 0使用 whereRaw 避免闭包 whereOr 歧义
if ($pendingAssign) {
// 待分配医助列表assistant_id 为 NULL 或 0宽搜不加此条件
if ($narrowPendingAssignPool) {
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
}
// 是否确认诊单:1=已确认 0=未确认
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
// 是否确认诊单:1=已确认 0=未确认(待分配+关键词检索时不限制确认态,避免搜不到)
if (!$pendingWideSearch && isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
$confirmed = (int)$this->params['diagnosis_confirmed'];
$dvrTbl = (new DiagnosisViewRecord())->getTable();
$diagTbl = (new Diagnosis())->getTable();
@@ -103,16 +105,16 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
}
// 挂号日期筛选:当天/明天/后天(1=已预约 3=已完成 4=已过号,需展示以便取消等操作
if (!empty($this->params['appointment_date'])) {
// 挂号日期筛选:当天/明天/后天…(待分配+关键词检索时不限制日期
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
$aptDate = addslashes($this->params['appointment_date']);
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.appointment_date = '{$aptDate}' AND apt.status IN (1,3,4)");
}
// 挂号状态:1=已挂号(含已预约/已完成/已过号)0=未挂号
if (isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
// 挂号状态:1=已挂号 0=未挂号(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
if ((int)$this->params['has_appointment'] === 1) {
@@ -122,22 +124,24 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
}
// 至少有一条挂号为「已完成」(status=3),用于列表顶部「已完成」Tab
if (isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
// 至少有一条挂号为「已完成」(status=3)(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
}
// 仅已开方(有处方)的诊单:随访列表传 only_has_prescription=1;问诊等页面不传则不过滤
if (isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
$this->applyPendingAssignBusinessOrderMonthFilter($query);
// 仅已开方(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
$rxTbl = (new Prescription())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.diagnosis_id = {$diagTbl}.id AND rx.delete_time IS NULL");
}
// 医助所属部门(随访等页面传 assistant_dept_id):选父级时包含全部子级部门下医助
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
// 医助所属部门(待分配+关键词检索时不按部门收窄)
if (!$pendingWideSearch && isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
@@ -158,7 +162,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$diagTbl = (new Diagnosis())->getTable();
$aptTbl = (new Appointment())->getTable();
$minAptDateCond = '';
if (!empty($this->params['appointment_date'])) {
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
$sortAptDate = addslashes((string) $this->params['appointment_date']);
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
}
@@ -169,8 +173,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
// 获取最早的挂号时间(用于同状态内排序)
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序,最新在最前;有 appointment_date 时仍只在该日内比
$isCompletedTab = isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
if ($isCompletedTab) {
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
$orderRaw = 'IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
@@ -381,6 +385,25 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
foreach ($lists as &$item) {
$item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null);
}
unset($item);
// 未服务天数:以血糖记录最近一条 record_date 为锚点(口径见 prd T3
// 单次 GROUP BY 聚合,不会成 N+1;走 idx_diagnosis_id 索引。
$diagIdsForUnserved = array_values(array_filter(array_unique(array_column($lists, 'id'))));
$maxBloodByDiag = [];
if (!empty($diagIdsForUnserved)) {
$maxBloodByDiag = BloodRecord::whereIn('diagnosis_id', $diagIdsForUnserved)
->whereNull('delete_time')
->group('diagnosis_id')
->column('MAX(record_date)', 'diagnosis_id');
}
$nowTs = time();
foreach ($lists as &$item) {
$last = (int) ($maxBloodByDiag[(int) $item['id']] ?? 0);
$item['last_blood_record_at'] = $last > 0 ? date('Y-m-d', $last) : null;
$item['unserved_days'] = $last > 0 ? max(0, (int) floor(($nowTs - $last) / 86400)) : null;
}
unset($item);
return $lists;
}
@@ -458,33 +481,29 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
// 构建查询
$query = Diagnosis::where($this->searchWhere);
$pendingWideSearch = $this->isPendingAssignWideKeywordSearchActive();
$narrowPendingAssignPool = $pendingAssign && !$pendingWideSearch;
$baseWhere = $pendingWideSearch
? $this->searchWhereWithoutDiagnosisStatus($this->searchWhere)
: $this->searchWhere;
$query = Diagnosis::where($baseWhere);
// 如果是医助角色(role_id=2),只显示自己添加的患者;查"待分配医助"时放开
if (in_array(2, $roleIds) && !$pendingAssign) {
if (in_array(2, $roleIds) && !$pendingAssign && !$pendingWideSearch) {
$query->where('assistant_id', $this->adminId);
}
if (!$pendingAssign) {
if (!$pendingAssign && !$pendingWideSearch) {
$this->applyDataScopeByOwner($query, 'assistant_id');
}
// 关键字搜索:支持患者姓名或手机号(模糊匹配)
if (isset($this->params['keyword']) && trim((string) $this->params['keyword']) !== '') {
$keyword = trim((string) $this->params['keyword']);
$query->where(function ($q) use ($keyword) {
$q->whereLike('patient_name', '%' . $keyword . '%')
->whereOr('phone', 'like', '%' . $keyword . '%');
});
}
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
// 待分配医助:assistant_id 为 NULL 或 0
if ($pendingAssign) {
if ($narrowPendingAssignPool) {
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
}
// 是否确认诊单
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
if (!$pendingWideSearch && isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
$confirmed = (int)$this->params['diagnosis_confirmed'];
$dvrTbl = (new DiagnosisViewRecord())->getTable();
$diagTbl = (new Diagnosis())->getTable();
@@ -497,7 +516,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
// 挂号日期筛选
if (!empty($this->params['appointment_date'])) {
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
$aptDate = addslashes($this->params['appointment_date']);
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
@@ -505,7 +524,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
// 挂号状态
if (isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
if (!$pendingWideSearch && isset($this->params['has_appointment']) && $this->params['has_appointment'] !== '') {
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
if ((int)$this->params['has_appointment'] === 1) {
@@ -516,12 +535,171 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
// 至少有一条挂号为「已完成」(status=3)
if (isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
if (!$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
}
// 仅已开方(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
$rxTbl = (new Prescription())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.diagnosis_id = {$diagTbl}.id AND rx.delete_time IS NULL");
}
// 医助所属部门(待分配+关键词检索时不按部门收窄)
if (!$pendingWideSearch && isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
return $id > 0;
}));
if ($deptIds === []) {
$query->whereRaw('0 = 1');
} else {
$inList = implode(',', $deptIds);
$adTbl = (new AdminDept())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$adTbl} ad WHERE ad.admin_id = {$diagTbl}.assistant_id AND ad.dept_id IN ({$inList})");
}
}
$this->applyPendingAssignBusinessOrderMonthFilter($query);
return $query->count();
}
/**
* 待分配 Tab 且有关键词时:放宽列表条件(与 `applyDiagnosisListKeywordFilter` 判定一致)。
*/
private function isPendingAssignWideKeywordSearchActive(): bool
{
$pending = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
if (!$pending) {
return false;
}
$wideKw = trim((string) ($this->params['pending_assign_keyword'] ?? ''));
if ($wideKw === '') {
$wideKw = trim((string) ($this->params['keyword'] ?? ''));
}
return $wideKw !== '';
}
/**
* `createWhere` 结果中移除诊单 `status`(启用/禁用),便于宽检索命中任意状态诊单。
*
* @param list<array{0:mixed,1?:mixed,2?:mixed}> $searchWhere
* @return list<array{0:mixed,1?:mixed,2?:mixed}>
*/
private function searchWhereWithoutDiagnosisStatus(array $searchWhere): array
{
$out = [];
foreach ($searchWhere as $cond) {
if (is_array($cond) && isset($cond[0]) && $cond[0] === 'status') {
continue;
}
$out[] = $cond;
}
return $out;
}
/**
* 列表关键字:待分配医助 Tab 为「多字段」检索(pending_assign_keyword 优先,否则回落 keyword);
* 其它 Tab 仍为姓名、手机。待分配 Tab 本身已不套 assistant_id 数据权限,检索可触达全部待分配诊单。
*/
private function applyDiagnosisListKeywordFilter($query, bool $pendingAssign): void
{
if ($pendingAssign) {
$wideKw = trim((string) ($this->params['pending_assign_keyword'] ?? ''));
if ($wideKw === '') {
$wideKw = trim((string) ($this->params['keyword'] ?? ''));
}
if ($wideKw !== '') {
$this->applyPendingAssignWideKeywordSearch($query, $wideKw);
}
return;
}
if (isset($this->params['keyword']) && trim((string) $this->params['keyword']) !== '') {
$keyword = trim((string) $this->params['keyword']);
$query->where(function ($q) use ($keyword) {
$q->whereLike('patient_name', '%' . $keyword . '%')
->whereOr('phone', 'like', '%' . $keyword . '%');
});
}
}
/**
* 待分配医助场景:多列模糊 + 纯数字时诊单 id / 患者号精确匹配。
*/
private function applyPendingAssignWideKeywordSearch($query, string $keyword): void
{
$kw = trim($keyword);
if ($kw === '') {
return;
}
$query->where(function ($q) use ($kw) {
$q->whereLike('patient_name', '%' . $kw . '%')
->whereOr('phone', 'like', '%' . $kw . '%')
->whereOr('id_card', 'like', '%' . $kw . '%')
->whereOr('remark', 'like', '%' . $kw . '%')
->whereOr('symptoms', 'like', '%' . $kw . '%')
->whereOr('current_medications', 'like', '%' . $kw . '%');
if (preg_match('/^\d+$/', $kw)) {
$n = (int) $kw;
if ($n > 0) {
$q->whereOr('id', '=', $n)
->whereOr('patient_id', '=', $n);
}
}
});
}
/**
* 「待分配医助」Tab:按月份筛选——诊单在指定自然月内需存在业务订单(order.patient_id = 诊单 id
* create_time 兼容整型时间戳与 datetime 字符串
*/
private function applyPendingAssignBusinessOrderMonthFilter($query): void
{
if ($this->isPendingAssignWideKeywordSearchActive()) {
return;
}
$pendingAssign = isset($this->params['pending_assign']) && (string) $this->params['pending_assign'] === '1';
if (!$pendingAssign || empty($this->params['pending_assign_order_month'])) {
return;
}
$month = trim((string) $this->params['pending_assign_order_month']);
if (!preg_match('/^(\d{4})-(\d{2})$/', $month, $m)) {
return;
}
$y = (int) $m[1];
$mo = (int) $m[2];
if ($y < 1970 || $y > 2100 || $mo < 1 || $mo > 12) {
return;
}
$tStart = strtotime(sprintf('%04d-%02d-01 00:00:00', $y, $mo));
if ($tStart === false) {
return;
}
$tEnd = strtotime(date('Y-m-t 23:59:59', $tStart));
if ($tEnd === false) {
return;
}
$dsStart = date('Y-m-d H:i:s', $tStart);
$dsEnd = date('Y-m-d H:i:s', $tEnd);
$orderTbl = (new Order())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$sql = "SELECT 1 FROM `{$orderTbl}` o WHERE o.patient_id = {$diagTbl}.id "
. 'AND o.delete_time IS NULL AND ('
. "(o.create_time >= {$tStart} AND o.create_time <= {$tEnd}) OR "
. "(o.create_time >= '{$dsStart}' AND o.create_time <= '{$dsEnd}')"
. ')';
$query->whereExists($sql);
}
}
@@ -0,0 +1,73 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
use app\common\lists\ListsSearchInterface;
use app\common\model\tcm\DiagnosisTodo;
/**
* 诊单待办事项列表
*
* 默认按 remind_time desc 排序;按 diagnosis_id 过滤。
*
* @package app\adminapi\lists\tcm
*/
class DiagnosisTodoLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['diagnosis_id', 'status', 'creator_id'],
];
}
public function lists(): array
{
$lists = DiagnosisTodo::where($this->searchWhere)
->field([
'id', 'diagnosis_id', 'patient_id', 'content', 'remind_time',
'status', 'creator_id', 'creator_name', 'notified_at', 'error',
'cancelled_at', 'cancelled_by', 'create_time', 'update_time',
])
->append([
'status_text',
'remind_time_text',
'notified_at_text',
'cancelled_at_text',
])
->order('remind_time', 'desc')
->order('id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
// 业务态:是否能被「我」取消(前端按钮显隐用)
$adminId = (int) $this->adminId;
foreach ($lists as &$item) {
$item['can_cancel'] = DiagnosisTodoLogic::canCancel($item, $adminId);
}
return $lists;
}
public function count(): int
{
return DiagnosisTodo::where($this->searchWhere)->count();
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
@@ -19,16 +20,19 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService;
use think\facade\Config;
use think\facade\Db;
use think\db\Query;
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
use HasDataScopeFilter;
private const SCENE_DIAGNOSIS_EDIT = 'diagnosis_edit';
public function setSearch(): array
{
return [
'=' => ['prescription_id', 'fulfillment_status', 'prescription_audit_status', 'payment_slip_audit_status'],
'=' => ['prescription_id', 'diagnosis_id', 'fulfillment_status', 'prescription_audit_status', 'payment_slip_audit_status'],
'%like%' => ['order_no'],
'between_time' => 'create_time',
];
@@ -95,13 +99,57 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
{
$this->applyDoctorAssistantFilters($query);
$this->applyPatientKeywordFilter($query);
$this->applyPatientIdFilter($query);
$this->applyExpressCompanyFilter($query);
$this->applyExpressKeywordFilter($query);
$this->applySupplyModeFilter($query);
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
$query->where('creator_id', $this->adminId);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
$this->applyDataScopeForPrescriptionOrder($query);
}
$this->applyDataScopeForPrescriptionOrder($query);
// 业绩看板侧栏等:不展示履约已取消(4),与 stats 业绩统计口径一致
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
}
$this->applyYejiErCenterRevisitOrderFilter($query);
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
}
/**
* 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_timerevisit_slot=0 为全部复诊分项合计)。
*/
private function applyYejiErCenterRevisitOrderFilter($query): void
{
if ((int) ($this->params['yeji_er_center_revisit_only'] ?? 0) !== 1) {
return;
}
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
$slot = (int) ($this->params['yeji_er_center_revisit_slot'] ?? 0);
if ($assistantId <= 0) {
return;
}
$st = trim((string) ($this->params['start_time'] ?? ''));
$et = trim((string) ($this->params['end_time'] ?? ''));
if ($st === '' || $et === '') {
return;
}
$t0 = strtotime($st);
$t1 = strtotime($et);
if ($t0 === false || $t1 === false) {
return;
}
$ids = DeptLogic::listErCenterRevisitOrderIdsForAssistant(
$assistantId,
$slot,
(int) $t0,
(int) $t1
);
if ($ids === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn('id', $ids);
}
/**
@@ -172,6 +220,73 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
}
/**
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则包含关联处方开方人为本人的订单;
* 另含「关联诊单医助为本人」以便医助跟进出单与履约。
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按「诊单医助 / 订单创建人」命中(与 applyDoctorAssistantFilters 一致)。
*/
private function applyCreatorOrOwnPrescriptionVisibility($query): void
{
if (PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
return;
}
$adminId = (int) $this->adminId;
$poTbl = (new PrescriptionOrder())->getTable();
$rxTbl = (new Prescription())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$explicitAssistant = (int) ($this->params['assistant_id'] ?? 0);
$allowExplicitAssistantVisibility = false;
if ($explicitAssistant > 0) {
if (!$this->dataScopeShouldApply()) {
$allowExplicitAssistantVisibility = true;
} else {
$visibleIds = $this->getDataScopeVisibleAdminIds();
if ($visibleIds === null || in_array($explicitAssistant, $visibleIds, true)) {
$allowExplicitAssistantVisibility = true;
}
}
}
$query->where(function ($q) use (
$poTbl,
$rxTbl,
$diagTbl,
$adminId,
$explicitAssistant,
$allowExplicitAssistantVisibility
) {
if (PrescriptionOrderLogic::canViewOrdersForOwnPrescription($this->adminInfo)) {
$q->where(function ($qq) use ($poTbl, $rxTbl, $diagTbl, $adminId) {
$qq->where('creator_id', $adminId);
$qq->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
. " AND rx.`delete_time` IS NULL AND rx.`creator_id` = {$adminId})"
);
$qq->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$adminId})"
);
});
} else {
$q->where(function ($qq) use ($poTbl, $diagTbl, $adminId) {
$qq->where('creator_id', $adminId);
$qq->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$adminId})"
);
});
}
if ($allowExplicitAssistantVisibility) {
$q->whereOr('creator_id', $explicitAssistant);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$explicitAssistant})"
);
}
});
}
/**
* 数据隔离:创建者 可见 admin,或 关联诊单医助 可见 admin
*/
@@ -201,6 +316,29 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
});
}
/**
* 仅诊单编辑页内的患者订单查询可跳过列表页权限,但必须显式带场景且保留患者/诊单范围收敛。
*/
private function shouldBypassListVisibilityForDiagnosisEdit(): bool
{
$scene = trim((string) ($this->params['scene'] ?? ''));
if ($scene !== self::SCENE_DIAGNOSIS_EDIT) {
return false;
}
$patientId = (int) ($this->params['patient_id'] ?? 0);
$contextDiagnosisId = (int) ($this->params['context_diagnosis_id'] ?? 0);
if ($patientId <= 0 || $contextDiagnosisId <= 0) {
return false;
}
$diagnosisPatientId = (int) Diagnosis::where('id', $contextDiagnosisId)
->whereNull('delete_time')
->value('patient_id');
return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId;
}
/**
* 医助:仅「诊单医助=本人」的订单计业绩
*/
@@ -216,28 +354,36 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
/**
* 统计用查询骨架:不含 create_time 区间;其它筛选项 + 统计专用数据域(不重复 where
*
* @param bool $applyYejiDrawerChannel 是否应用业绩侧栏渠道收窄(合计业绩统计须传 false
*/
private function buildBaseQueryForStats(): Query
private function buildBaseQueryForStats(bool $applyYejiDrawerChannel = true): Query
{
$query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time');
$this->applyDoctorAssistantFilters($query);
$this->applyPatientKeywordFilter($query);
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
$this->applyDataScopeForPrescriptionOrder($query);
return $query;
} else {
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
$myRoles = array_map('intval', $this->adminInfo['role_id'] ?? []);
if ($assistantRid > 0 && in_array($assistantRid, $myRoles, true)) {
$this->applyAssistantDiagnosisOnlyFilter($query);
$this->applyDataScopeForPrescriptionOrder($query);
} else {
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
}
$this->applyDataScopeForPrescriptionOrder($query);
}
}
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
$myRoles = array_map('intval', $this->adminInfo['role_id'] ?? []);
if ($assistantRid > 0 && in_array($assistantRid, $myRoles, true)) {
$this->applyAssistantDiagnosisOnlyFilter($query);
$this->applyDataScopeForPrescriptionOrder($query);
return $query;
// 与看板「合计业绩」一致:NULL 安全剔除履约已取消(4),勿用 `<>` 以免误丢 NULL 状态行
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, '');
}
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
$query->where('creator_id', $this->adminId);
if ($applyYejiDrawerChannel) {
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
}
$this->applyDataScopeForPrescriptionOrder($query);
return $query;
}
@@ -365,8 +511,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($diagIdsForAssist !== []) {
$assistantByDiag = Diagnosis::whereIn('id', $diagIdsForAssist)->whereNull('delete_time')->column('assistant_id', 'id');
}
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_values($assistantByDiag)))));
$assistantNames = $assistantIds !== []
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
: [];
foreach ($lists as &$item) {
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
$pid = (int) ($item['id'] ?? 0);
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
'prescription_order_id',
@@ -380,11 +531,19 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->adminInfo,
$assistantByDiag
);
// 添加创建人姓名
$creatorId = (int) ($item['creator_id'] ?? 0);
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
// 添加医助(来自诊单 assistant_id
$diagIdForAssist = (int) ($item['diagnosis_id'] ?? 0);
$assistantIdForItem = (int) ($assistantByDiag[$diagIdForAssist] ?? 0);
$item['assistant_id'] = $assistantIdForItem;
$item['assistant_name'] = $assistantIdForItem > 0
? ($assistantNames[$assistantIdForItem] ?? '')
: '';
// 添加开方人姓名、处方登记手机(与业务订单收货手机比对用)
$rxId = (int) ($item['prescription_id'] ?? 0);
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
@@ -392,6 +551,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
unset($item);
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
PrescriptionOrderLogic::appendLinkedAppointmentsToListRows($lists);
}
return $lists;
}
@@ -400,7 +563,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$s = $this->computeListStatsForExtend();
$focus = $this->computeFocusCountsForExtend();
return [
$base = [
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
'stats_order_amount' => $s['order_amount'],
@@ -422,6 +585,119 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'focus_pending_ship_count' => $focus['pending_ship'],
'focus_risk_count' => $focus['risk'],
];
$yejiConsult = $this->computeYejiDrawerConsultCountIfRequested();
if ($yejiConsult !== null) {
$base['stats_yeji_consult_count'] = $yejiConsult;
}
$yejiDrawerTotal = $this->computeYejiDrawerPerformanceTotalForExtend();
if ($yejiDrawerTotal !== null) {
$base['stats_yeji_drawer_total_performance_amount'] = $yejiDrawerTotal['amount'];
$base['stats_yeji_drawer_total_performance_order_count'] = $yejiDrawerTotal['order_count'];
}
return $base;
}
/**
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
* doctor_appointment.status=3appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
* 请求须带 yeji_order_drawer=1,且 assistant_id assistant_dept_id 与起止时间有效。
*/
private function computeYejiDrawerConsultCountIfRequested(): ?int
{
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
return null;
}
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
$deptRootId = (int) ($this->params['assistant_dept_id'] ?? 0);
if ($assistantId <= 0 && $deptRootId <= 0) {
return null;
}
[$startDate, $endDate] = $this->resolveYejiConsultAppointmentDateRange();
if ($startDate === '' || $endDate === '') {
return null;
}
$eff = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.status', 3)
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
if ($assistantId > 0) {
$query->whereRaw("({$eff}) = " . $assistantId);
} else {
$deptIds = DeptLogic::getSelfAndDescendantIds($deptRootId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
return $id > 0;
}));
if ($deptIds === []) {
return 0;
}
$adTbl = (new AdminDept())->getTable();
$inList = implode(',', $deptIds);
$query->whereRaw(
"EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.admin_id = {$eff} AND ad.dept_id IN ({$inList}))"
);
}
return (int) $query->count();
}
/**
* @return array{0: string, 1: string} Y-m-d
*/
private function resolveYejiConsultAppointmentDateRange(): array
{
$st = trim((string) ($this->params['start_time'] ?? ''));
$et = trim((string) ($this->params['end_time'] ?? ''));
if ($st === '' || $et === '') {
return ['', ''];
}
$t0 = strtotime($st);
$t1 = strtotime($et);
if ($t0 === false || $t1 === false) {
return ['', ''];
}
if ($t1 < $t0) {
$tmp = $t0;
$t0 = $t1;
$t1 = $tmp;
}
return [date('Y-m-d', $t0), date('Y-m-d', $t1)];
}
/**
* 侧栏已选渠道时:「合计业绩」金额与订单数(与看板列同口径,不限渠道收窄)
*
* @return array{amount: float, order_count: int}|null
*/
private function computeYejiDrawerPerformanceTotalForExtend(): ?array
{
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
return null;
}
if (trim((string) ($this->params['channel_code'] ?? '')) === '') {
return null;
}
[$t0, $t1] = $this->resolveStatsTimeWindow();
if ($t0 <= 0 || $t1 <= 0) {
return null;
}
if ($t1 < $t0) {
$tmp = $t0;
$t0 = $t1;
$t1 = $tmp;
}
$q = $this->buildBaseQueryForStats(false)->whereBetween('create_time', [$t0, $t1]);
// 全量「合计业绩」与看板列同口径:始终剔除履约已取消(4),不依赖 exclude 参数
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
$amount = round((float) (clone $q)->sum('amount'), 2);
$count = (int) (clone $q)->count();
return ['amount' => $amount, 'order_count' => $count];
}
/**
@@ -434,9 +710,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$query = PrescriptionOrder::where($this->searchWhereWithoutStatusFilters())->whereNull('delete_time');
$this->applyPermissionAndExtraFilters($query);
$pendingRxQuery = clone $query;
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($pendingRxQuery, '');
return [
// 处方审核待处理
'pending_rx' => (int) (clone $query)->where('prescription_audit_status', 0)->count(),
// 处方审核待处理(不含履约已取消)
'pending_rx' => (int) $pendingRxQuery->where('prescription_audit_status', 0)->count(),
// 支付审核待处理(前提:处方已通过)
'pending_pay' => (int) (clone $query)->where('prescription_audit_status', 1)->where('payment_slip_audit_status', 0)->count(),
// 履约待发货
@@ -551,6 +830,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$ar = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
if ($ar > 0 && in_array($ar, array_map('intval', $this->adminInfo['role_id'] ?? []), true)) {
$scope = 'assistant';
} elseif (PrescriptionOrderLogic::canViewOrdersForOwnPrescription($this->adminInfo)) {
$scope = 'own_rx';
}
}
@@ -575,7 +856,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
/**
* 按开方医生(关联处方 creator_id/ 诊单医助(关联诊单 assistant_id筛选
* 按开方医生(关联处方 creator_id/ 诊单医助筛选
*
* 医助:诊单 assistant_id 与业务订单 creator_id 并举——医助常代建单但诊单未写 assistant_id。
*/
private function applyDoctorAssistantFilters($query): void
{
@@ -590,10 +873,31 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
$assistantId = (int) $this->params['assistant_id'];
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists("SELECT 1 FROM {$diagTbl} dg WHERE dg.id = {$poTbl}.diagnosis_id AND dg.delete_time IS NULL AND dg.assistant_id = {$assistantId}");
$query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) {
$q->where('creator_id', $assistantId);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
);
});
}
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
if ((int) ($this->params['yeji_drawer_match_table_performance'] ?? 0) === 1) {
$rowDeptId = (int) $this->params['assistant_dept_id'];
$adminIds = YejiStatsLogic::collectAdminIdsAttributedToYejiTableRow(
$rowDeptId,
$this->params['dept_ids'] ?? null
);
if ($adminIds === []) {
$query->whereRaw('0 = 1');
return;
}
$this->applyPerformanceAttributionAdminInFilter($query, $adminIds);
return;
}
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
@@ -607,13 +911,48 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$inList = implode(',', $deptIds);
$diagTbl = (new Diagnosis())->getTable();
$adTbl = (new AdminDept())->getTable();
$query->whereExists(
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
. "WHERE dg.id = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
);
$query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) {
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
);
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})",
'OR'
);
});
}
}
/**
* 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单(与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致)。
*
* @param int[] $adminIds
*/
private function applyPerformanceAttributionAdminInFilter($query, array $adminIds): void
{
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static function (int $x): bool {
return $x > 0;
})));
if ($adminIds === []) {
$query->whereRaw('0 = 1');
return;
}
$poTbl = (new PrescriptionOrder())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$rxTbl = (new Prescription())->getTable();
$inList = implode(',', $adminIds);
$query->whereRaw(
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg "
. "LEFT JOIN `{$rxTbl}` rx ON rx.`id` = `{$poTbl}`.`prescription_id` AND rx.`delete_time` IS NULL "
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND dg.`delete_time` IS NULL AND `{$poTbl}`.`diagnosis_id` > 0 AND "
. '(CASE WHEN dg.`assistant_id` > 0 THEN dg.`assistant_id` '
. "WHEN `{$poTbl}`.`creator_id` > 0 AND (rx.`id` IS NULL OR NOT (`{$poTbl}`.`creator_id` <=> rx.`creator_id`)) THEN `{$poTbl}`.`creator_id` "
. 'ELSE 0 END) IN (' . $inList . '))'
);
}
/**
* 按诊单患者姓名 / 手机号模糊检索(关联 diagnosis_id
*/
@@ -636,6 +975,26 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
);
}
/**
* 按真实患者ID精确过滤(tcm_diagnosis.patient_id 是跨诊单的患者唯一ID
* 用于诊单编辑抽屉「业务订单」tab 列出该患者所有诊单产生的处方业务订单。
*/
private function applyPatientIdFilter($query): void
{
$patientId = (int) ($this->params['patient_id'] ?? 0);
if ($patientId <= 0) {
return;
}
$poTbl = (new PrescriptionOrder())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists(
"SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` "
. "AND dg.`delete_time` IS NULL AND dg.`patient_id` = {$patientId}"
);
}
/**
* 按快递单号 / 快递公司 模糊检索
*/
+7 -3
View File
@@ -86,13 +86,17 @@ class RoleLogic extends BaseLogic
try {
$menuId = !empty($params['menu_id']) ? $params['menu_id'] : [];
SystemRole::update([
$roleRow = [
'id' => $params['id'],
'name' => $params['name'],
'desc' => $params['desc'] ?? '',
'sort' => $params['sort'] ?? 0,
'data_scope' => self::normalizeDataScope($params['data_scope'] ?? null),
]);
];
// 分配权限等非编辑页提交的 edit 若不传 data_scope,不得把库里的范围覆盖成默认全部
if (array_key_exists('data_scope', $params)) {
$roleRow['data_scope'] = self::normalizeDataScope($params['data_scope'] ?? null);
}
SystemRole::update($roleRow);
if (!empty($menuId)) {
SystemRoleMenu::where(['role_id' => $params['id']])->delete();
+617 -4
View File
@@ -14,11 +14,12 @@
namespace app\adminapi\logic\dept;
use app\common\enum\YesNoEnum;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
@@ -64,7 +65,9 @@ class DeptLogic extends BaseLogic
if (!empty($lists)) {
$pid = min(array_column($lists, 'pid'));
}
return self::getTree($lists, $pid);
$tree = self::getTree($lists, $pid);
return $tree;
}
/**
@@ -141,6 +144,557 @@ class DeptLogic extends BaseLogic
return $map;
}
/**
* 名称含「二中心」的部门 id(与业绩看板二中心规则一致)。
*
* @return list<int>
*/
private static function findErCenterRootDeptIds(): array
{
$rows = Dept::whereNull('delete_time')
->field(['id', 'name'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$name = (string) ($r['name'] ?? '');
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
$out[] = (int) $r['id'];
}
}
return array_values(array_unique($out));
}
/**
* @param list<int> $rootIds
*
* @return list<int>
*/
private static function unionErCenterSubtreeDeptIds(array $rootIds): array
{
$set = [];
foreach ($rootIds as $rid) {
$rid = (int) $rid;
if ($rid <= 0) {
continue;
}
foreach (self::getSelfAndDescendantIds($rid) as $id) {
$set[(int) $id] = true;
}
}
return array_keys($set);
}
/**
* 名称含「二中心」的部门及其全部下级 idmap),与业绩看板判定一致。
*
* @return array<int, true>
*/
public static function getErCenterSubtreeDeptIdSet(): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
return array_fill_keys($subtreeIds, true);
}
/**
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
*
* @return list<array{diagnosis_id: int, create_time: int, id: int, assistant_id: int}>
*/
private static function fetchErCenterRevisitCandidateOrders(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$q = Db::name('tcm_prescription_order')
->alias('o')
->join('tcm_diagnosis dg', 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw('NOT (o.fulfillment_status <=> ?)', [4]);
if ($orderStartTs !== null && $orderEndTs !== null) {
$q->where('o.create_time', 'between', [$orderStartTs, $orderEndTs]);
}
return $q
->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id'])
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
}
/**
* 二中心子树:各部门复诊 rollup;同时返回按诊单医助聚合的分项(供医助排行榜)。
*
* @param list<int> $subtreeDeptIds
*
* @return array{
* totals: array<int, int>,
* slots: array<int, array<int, int>>,
* by_assistant_slots: array<int, array<int, int>>
* }
*/
private static function computeErCenterRevisitFullPack(
array $subtreeDeptIds,
?int $orderStartTs = null,
?int $orderEndTs = null
): array {
$emptySlots = [];
$emptyTotals = [];
foreach ($subtreeDeptIds as $did) {
$did = (int) $did;
$emptySlots[$did] = [];
$emptyTotals[$did] = 0;
}
if ($subtreeDeptIds === []) {
return ['totals' => [], 'slots' => [], 'by_assistant_slots' => []];
}
$subtreeSet = array_fill_keys($subtreeDeptIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_assistant_slots' => []];
}
$byPatient = [];
foreach ($orderRows as $r) {
$pid = (int) ($r['diagnosis_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
}
}
}
$assistantIds = array_keys($assistantNeed);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($assistantIds, $subtreeSet);
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byAssistantSlot */
$byAssistantSlot = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
$deptId = $canonicalDept[$aid] ?? null;
if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue;
}
if (!isset($leafByDeptSlot[$deptId])) {
$leafByDeptSlot[$deptId] = [];
}
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
if ($aid > 0) {
if (!isset($byAssistantSlot[$aid])) {
$byAssistantSlot[$aid] = [];
}
$byAssistantSlot[$aid][$slot] = ($byAssistantSlot[$aid][$slot] ?? 0) + 1;
}
}
}
$childrenByPid = self::buildAllDeptChildrenByPid();
$rollupSlots = self::rollupRevisitSlotsBySubtree($subtreeDeptIds, $subtreeSet, $childrenByPid, $leafByDeptSlot);
$rollupTotals = [];
foreach ($subtreeDeptIds as $id) {
$id = (int) $id;
$rollupTotals[$id] = array_sum($rollupSlots[$id] ?? []);
}
foreach ($byAssistantSlot as $aid => $sm) {
if ($sm !== []) {
ksort($byAssistantSlot[$aid], SORT_NUMERIC);
}
}
return [
'totals' => $rollupTotals,
'slots' => $rollupSlots,
'by_assistant_slots' => $byAssistantSlot,
];
}
/**
* 二中心子树:各部门复诊合计与分项(第 2 笔订单=复诊2…),供业绩看板等复用。
* $orderStartTs/$orderEndTs 均非 null 时仅统计该 create_time 窗口内业务单(与看板区间一致);均为 null 时不限时间(慎用,数据量大)。
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitRollupIndexedByDept(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
return ['totals' => $pack['totals'], 'slots' => $pack['slots']];
}
/**
* 二中心子树内:各医助成交的复诊业务单笔数(合计 + 复诊2/3/…分项),口径与部门 rollup 同源。
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitCountsByAssistant(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$by = $pack['by_assistant_slots'];
$totals = [];
$slots = [];
foreach ($by as $aid => $slotMap) {
$aid = (int) $aid;
$totals[$aid] = (int) array_sum($slotMap);
$slots[$aid] = $slotMap;
}
return ['totals' => $totals, 'slots' => $slots];
}
/**
* 某医助在区间内、计入二中心复诊的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
*
* @return list<int>
*/
public static function listErCenterRevisitOrderIdsForAssistant(
int $assistantId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
): array {
if ($assistantId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return [];
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
return [];
}
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return [];
}
$subtreeSet = array_fill_keys($subtreeIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return [];
}
$byPatient = [];
foreach ($orderRows as $r) {
$pid = (int) ($r['diagnosis_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
}
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $subtreeSet);
$ids = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid !== $assistantId) {
continue;
}
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$dcan = $canonicalDept[$aid] ?? null;
if ($dcan === null || !isset($subtreeSet[$dcan])) {
continue;
}
$oid = (int) ($orders[$i]['id'] ?? 0);
if ($oid > 0) {
$ids[$oid] = true;
}
}
}
return array_keys($ids);
}
/**
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按诊单医助拆解笔数。
* 与看板行内「复诊」列口径一致:诊单下订单序列第 2 笔起;医助 canonical 部门须落在二中心子树且在该部门行的子树内。
*
* @return array{rows: list<array{admin_id: int, name: string, order_count: int}>}
*/
public static function getErCenterRevisitAssistantBreakdownForDept(
int $rootDeptId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
): array {
if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return ['rows' => []];
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
return ['rows' => []];
}
$erSubtreeSet = self::getErCenterSubtreeDeptIdSet();
if ($erSubtreeSet === []) {
return ['rows' => []];
}
$descIds = self::getSelfAndDescendantIds($rootDeptId);
$descFlip = [];
foreach ($descIds as $did) {
$did = (int) $did;
if ($did > 0) {
$descFlip[$did] = true;
}
}
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return ['rows' => []];
}
$byPatient = [];
foreach ($orderRows as $r) {
$pid = (int) ($r['diagnosis_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
}
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $erSubtreeSet);
/** @var array<int, int> $counts */
$counts = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$canon = $canonicalDept[$aid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue;
}
$counts[$aid] = ($counts[$aid] ?? 0) + 1;
}
}
if ($counts === []) {
return ['rows' => []];
}
$ids = array_keys($counts);
$nameRows = [];
if ($ids !== []) {
$nameRows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->column('name', 'id');
}
$rows = [];
foreach ($counts as $aid => $cnt) {
$rows[] = [
'admin_id' => (int) $aid,
'name' => (string) ($nameRows[$aid] ?? ('#' . $aid)),
'order_count' => (int) $cnt,
];
}
usort($rows, static function (array $a, array $b): int {
if ($a['order_count'] !== $b['order_count']) {
return $b['order_count'] <=> $a['order_count'];
}
return strcmp((string) $a['name'], (string) $b['name']);
});
return ['rows' => $rows];
}
/**
* @return array<int, list<int>>
*/
private static function buildAllDeptChildrenByPid(): array
{
$rows = Dept::field(['id', 'pid'])->select()->toArray();
$out = [];
foreach ($rows as $r) {
$pid = (int) ($r['pid'] ?? 0);
$id = (int) ($r['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!isset($out[$pid])) {
$out[$pid] = [];
}
$out[$pid][] = $id;
}
return $out;
}
/**
* @param array<int, array<int, int>> $leafByDeptSlot
*
* @return array<int, array<int, int>> dept_id => [ slot => count ],已含下级汇总
*/
private static function rollupRevisitSlotsBySubtree(
array $subtreeDeptIds,
array $subtreeSet,
array $childrenByPid,
array $leafByDeptSlot
): array {
$memo = [];
$dfs = static function (int $id) use (&$dfs, $childrenByPid, $leafByDeptSlot, $subtreeSet, &$memo): array {
if (isset($memo[$id])) {
return $memo[$id];
}
$acc = [];
if (isset($leafByDeptSlot[$id])) {
foreach ($leafByDeptSlot[$id] as $slot => $cnt) {
$acc[(int) $slot] = (int) $cnt;
}
}
foreach ($childrenByPid[$id] ?? [] as $cid) {
$cid = (int) $cid;
if (!isset($subtreeSet[$cid])) {
continue;
}
$childAcc = $dfs($cid);
foreach ($childAcc as $slot => $cnt) {
$acc[$slot] = ($acc[$slot] ?? 0) + $cnt;
}
}
if ($acc !== []) {
ksort($acc, SORT_NUMERIC);
}
$memo[$id] = $acc;
return $acc;
};
$out = [];
foreach ($subtreeDeptIds as $id) {
$id = (int) $id;
$out[$id] = $dfs($id);
}
return $out;
}
/**
* @param list<int> $assistantIds
* @param array<int, true> $subtreeSet
*
* @return array<int, int> admin_id => dept_id
*/
private static function buildAssistantCanonicalDeptInSubtree(array $assistantIds, array $subtreeSet): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $assistantIds), static function (int $v): bool {
return $v > 0;
})));
if ($ids === []) {
return [];
}
$subtreeIdList = array_keys($subtreeSet);
$rows = AdminDept::whereIn('admin_id', $ids)
->whereIn('dept_id', $subtreeIdList)
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$candidates = [];
foreach ($rows as $r) {
$a = (int) ($r['admin_id'] ?? 0);
$d = (int) ($r['dept_id'] ?? 0);
if ($a > 0 && $d > 0) {
$candidates[$a][] = $d;
}
}
$depthMap = self::buildDeptIdDepthMap();
$out = [];
foreach ($candidates as $aid => $depts) {
$best = null;
$bestDepth = -1;
foreach ($depts as $d) {
$depth = (int) ($depthMap[$d] ?? 0);
if (
$best === null
|| $depth > $bestDepth
|| ($depth === $bestDepth && $d < (int) $best)
) {
$bestDepth = $depth;
$best = $d;
}
}
if ($best !== null) {
$out[$aid] = (int) $best;
}
}
return $out;
}
/**
* @return array<int, int> dept_id => 从根层深度 0 起的层级
*/
private static function buildDeptIdDepthMap(): array
{
$rows = Dept::field(['id', 'pid'])->select()->toArray();
$pidById = [];
foreach ($rows as $r) {
$pidById[(int) $r['id']] = (int) $r['pid'];
}
$depth = [];
$getDepth = static function (int $id) use (&$getDepth, &$depth, $pidById): int {
if (isset($depth[$id])) {
return $depth[$id];
}
$p = (int) ($pidById[$id] ?? 0);
$depth[$id] = $p > 0 ? $getDepth($p) + 1 : 0;
return $depth[$id];
};
foreach (array_keys($pidById) as $id) {
$getDepth((int) $id);
}
return $depth;
}
/**
* @notes 列表树状结构
* @param $array
@@ -271,15 +825,74 @@ class DeptLogic extends BaseLogic
*/
public static function getAllData()
{
$data = Dept::where(['status' => YesNoEnum::YES])
->order(['sort' => 'desc', 'id' => 'desc'])
// 与业绩看板等统计口径一致:含全部未软删部门(不再仅限 status=启用),
// 避免外链 assistant_dept_id 在树下拉中不存在导致 TreeSelect 初始化异常。
$data = Dept::order(['sort' => 'desc', 'id' => 'desc'])
->select()
->toArray();
if ($data === []) {
return [];
}
$pid = min(array_column($data, 'pid'));
return self::getTree($data, $pid);
}
/**
* 部门树(与 getAllData 同结构),按当前管理员角色数据权限收窄可选节点;
* 保留必选祖先节点以便树形展示(与业绩看板 deptOptions allowed 集合一致)。
*
* @param array<string, mixed> $adminInfo BaseAdminController::$adminInfo 形态(须含 root、role_id 等)
*
* @return array<int, array<string, mixed>>
*/
public static function getAllDataScoped(int $adminId, array $adminInfo): array
{
$full = self::getAllData();
if ($full === []) {
return [];
}
if ($adminId <= 0 || !DataScopeService::isEnabled()) {
return $full;
}
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
if ($allowed === null) {
return $full;
}
if ($allowed === []) {
return [];
}
return self::filterDeptTreeByAllowedIds($full, $allowed);
}
/**
* @param array<int, array<string, mixed>> $nodes
* @param array<int, true> $allowedIdMap
*
* @return array<int, array<string, mixed>>
*/
private static function filterDeptTreeByAllowedIds(array $nodes, array $allowedIdMap): array
{
$out = [];
foreach ($nodes as $node) {
$id = (int) ($node['id'] ?? 0);
$rawChildren = $node['children'] ?? [];
$children = \is_array($rawChildren) && $rawChildren !== []
? self::filterDeptTreeByAllowedIds($rawChildren, $allowedIdMap)
: [];
$inAllowed = isset($allowedIdMap[$id]);
if ($inAllowed || $children !== []) {
$row = $node;
$row['children'] = $children;
$out[] = $row;
}
}
return $out;
}
/**
* 指定部门及其全部下级部门 id(含自身)。筛选时选父级可匹配子级下成员(admin_dept.dept_id)。
*
@@ -13,6 +13,7 @@ use app\api\logic\ChatNotifyLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\BloodRecordLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\common\model\tcm\Prescription;
use app\common\service\wechat\WechatWorkAppMessageService;
@@ -242,7 +243,8 @@ class AppointmentLogic extends BaseLogic
'appointment_time' => $appointmentTime,
'appointment_type' => $params['appointment_type'] ?? 'video',
'remark' => $params['remark'] ?? '',
'channels' => $params['channel_source'] ?? '',
// 表字段为 channel_source(与字典 type_value=channels 的 value 一致);勿再写入不存在的 channels 列
'channel_source' => trim((string) ($params['channel_source'] ?? '')),
'status' => 1,
'create_time' => time(),
'update_time' => time(),
@@ -516,10 +518,8 @@ class AppointmentLogic extends BaseLogic
? DiagnosisLogic::detail(['id' => $diagnosisId])
: [];
// 3) 血糖血压记录
$bloodRecords = $diagnosisId > 0
? BloodRecordLogic::getRecordsByPatient(['diagnosis_id' => $diagnosisId])
: [];
// 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
$assistantName = '';
@@ -551,23 +551,28 @@ class AppointmentLogic extends BaseLogic
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
: [];
// 7.1) 跟踪备注
$trackingNotes = $diagnosisId > 0
? TrackingNoteLogic::getByDiagnosis($diagnosisId)
: [];
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'blood_records' => $bloodRecords,
'doctor_notes' => $doctorNotes,
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'doctor_notes' => $doctorNotes,
'tracking_notes' => $trackingNotes,
];
}
/**
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
* 便于前端(接诊台等只读场景)直接渲染,无需加载字典。
* 便于前端(接诊台 / 只读病例页等只读场景)直接渲染,无需加载字典。
* 原始 code 字段保留不动,edit 场景仍可使用。
*
* @param array $diagnosis
* @return array
*/
private static function enrichDiagnosisLabels(array $diagnosis): array
public static function enrichDiagnosisLabels(array $diagnosis): array
{
if (empty($diagnosis)) {
return $diagnosis;
@@ -8,6 +8,7 @@ use app\adminapi\logic\dept\DeptLogic;
use app\common\logic\BaseLogic;
use app\common\model\dept\Dept;
use app\common\model\finance\DeptPerformanceTarget;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
class DeptPerformanceTargetLogic extends BaseLogic
@@ -15,11 +16,16 @@ class DeptPerformanceTargetLogic extends BaseLogic
/**
* 指定月份:保留部门树层级 + 合并当月已有目标
*
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float}
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float, data_scope_limited: bool}
*/
public static function monthMatrix(string $yearMonth): array
public static function monthMatrix(string $yearMonth, int $adminId = 0, array $adminInfo = []): array
{
$tree = DeptLogic::getAllData();
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
$scoped = $allowed !== null;
if ($scoped) {
$tree = self::filterDeptTreeAllowedForest(is_array($tree) ? $tree : [], $allowed);
}
$rowsDb = DeptPerformanceTarget::where('year_month', $yearMonth)
->select()
@@ -33,19 +39,22 @@ class DeptPerformanceTargetLogic extends BaseLogic
$total = self::sumTargetsInTree($rows);
return [
'year_month' => $yearMonth,
'rows' => $rows,
'total_target' => round($total, 2),
'year_month' => $yearMonth,
'rows' => $rows,
'total_target' => round($total, 2),
'data_scope_limited' => $scoped,
];
}
/**
* @param array<int, array{dept_id?:int|float|string, target_amount?:int|float|string, remark?:string}> $items
*/
public static function batchSave(string $yearMonth, array $items, int $adminId, string $adminName): bool
public static function batchSave(string $yearMonth, array $items, int $adminId, array $adminInfo, string $adminName): bool
{
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
try {
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName): void {
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName, $allowed): void {
foreach ($items as $item) {
if (!is_array($item)) {
continue;
@@ -54,6 +63,10 @@ class DeptPerformanceTargetLogic extends BaseLogic
if ($deptId <= 0) {
continue;
}
// 仅处理 data scope 内的 dept_id(界面树与 monthMatrix 一致,不再含纯展示用父级)
if ($allowed !== null && !isset($allowed[$deptId])) {
continue;
}
$dept = Dept::where('id', $deptId)->whereNull('delete_time')->find();
if (!$dept) {
continue;
@@ -162,4 +175,63 @@ class DeptPerformanceTargetLogic extends BaseLogic
return $s;
}
/**
* 本部门及以下:仅展示 allowed 内节点;无权父级不渲染,从子树中接续(不露出公司/上级中心)。
*
* @param array<int, array<string, mixed>> $nodes
* @param array<int, true> $allowedMap
*
* @return array<int, array<string, mixed>>
*/
private static function filterDeptTreeAllowedForest(array $nodes, array $allowedMap): array
{
if ($allowedMap === []) {
return [];
}
$out = [];
foreach ($nodes as $n) {
if (!is_array($n)) {
continue;
}
$id = (int) ($n['id'] ?? 0);
if ($id <= 0) {
continue;
}
$rawChildren = $n['children'] ?? [];
$kids = is_array($rawChildren) ? $rawChildren : [];
if (isset($allowedMap[$id])) {
$childList = [];
foreach ($kids as $c) {
if (!is_array($c)) {
continue;
}
foreach (self::filterDeptTreeAllowedForest([$c], $allowedMap) as $item) {
$childList[] = $item;
}
}
$node = $n;
if ($childList !== []) {
$node['children'] = $childList;
} else {
unset($node['children']);
}
$out[] = $node;
continue;
}
foreach ($kids as $c) {
if (!is_array($c)) {
continue;
}
foreach (self::filterDeptTreeAllowedForest([$c], $allowedMap) as $item) {
$out[] = $item;
}
}
}
return $out;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;**不按展示部门收窄**(始终按全部「中心」展示树,再套数据权限)。
*
* 医生范围:<b>admin_role.role_id = 1</b> 且管理员 <b>未软删</b>delete_time 为空);再按账号「数据范围」收窄。
*
* - 系统/手动开方:tcm_prescription.prescription_date [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签
* - 成交:订单 create_time、排除履约(4),按处方 creator_id;渠道/标签同 sumPerformance 口径
* - 挂号:appointment_date [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id 标签诊单集
*/
class DoctorDailyStatsLogic
{
/**
* @param array{
* start_date?:string,
* end_date?:string,
* channel_code?:string,
* tag_id?:string,
* doctor_id?:int|string
* } $params
*
* @return array{start_date:string,end_date:string,rows:array,total:array<string,mixed>}
*/
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
// 医生统计不参与「展示部门」检索:忽略 dept_ids,避免与业绩表部门筛选联动
$paramsForCtx = $params;
unset($paramsForCtx['dept_ids']);
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($paramsForCtx, $viewerAdminId, $viewerAdminInfo);
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$startTs = $ctx['startTs'];
$endTs = $ctx['endTs'];
$appointmentChannelValues = $ctx['appointmentChannelValues'];
$channelFilterActive = $ctx['channelFilterActive'];
$tagDiagIds = $ctx['tagDiagIds'];
$tagAssistantIds = $ctx['tagAssistantIds'];
$tagFallback = $ctx['tagFallback'];
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
if ($filterDoctorId > 0) {
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
}
if ($doctorIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
$rxMap = self::loadPrescriptionCounts(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
);
$orderMap = self::loadOrderAggregates(
$startTs,
$endTs,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
);
$apptMap = self::loadAppointmentAggregates(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds
);
$adminRows = Admin::whereIn('id', $doctorIds)
->whereNull('delete_time')
->field(['id', 'name'])
->order('id', 'asc')
->select()
->toArray();
$nameById = [];
foreach ($adminRows as $r) {
$nameById[(int) $r['id']] = (string) ($r['name'] ?? '');
}
$rows = [];
foreach ($doctorIds as $aid) {
$rx = $rxMap[$aid] ?? ['system' => 0, 'manual' => 0];
$ord = $orderMap[$aid] ?? ['amount' => 0.0, 'count' => 0];
$ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0];
$cnt = (int) $ord['count'];
$amt = round((float) $ord['amount'], 2);
$rows[] = [
'admin_id' => $aid,
'doctor_name' => $nameById[$aid] ?? ('#' . $aid),
'system_prescription_count' => (int) $rx['system'],
'manual_prescription_count' => (int) $rx['manual'],
'deal_amount' => $amt,
'deal_order_count' => $cnt,
'avg_deal_amount' => $cnt > 0 ? round($amt / $cnt, 2) : null,
'appointment_completed' => (int) $ap['completed'],
'appointment_missed' => (int) $ap['missed'],
'appointment_cancelled' => (int) $ap['cancelled'],
];
}
usort($rows, static function (array $a, array $b): int {
if (($a['deal_amount'] ?? 0) != ($b['deal_amount'] ?? 0)) {
return ($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0);
}
return strcmp((string) ($a['doctor_name'] ?? ''), (string) ($b['doctor_name'] ?? ''));
});
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => $rows,
'total' => self::sumTotals($rows),
];
}
/**
* 医生角色(role_id=1)、管理员未删除、且在数据范围内的 admin_id。
*
* @return int[]
*/
private static function resolveDoctorAdminIdsForStats(int $viewerAdminId, array $viewerAdminInfo): array
{
$roleDoctors = Db::name('admin_role')->alias('ar')
->join('admin a', 'a.id = ar.admin_id')
->where('ar.role_id', 1)
->whereNull('a.delete_time')
->column('ar.admin_id');
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $roleDoctors), static function (int $v): bool {
return $v > 0;
})));
sort($doctorIds);
if ($viewerAdminId > 0 && DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($viewerAdminId, $viewerAdminInfo);
if ($visibleIds !== null) {
if ($visibleIds === []) {
return [];
}
$flip = array_flip($visibleIds);
$doctorIds = array_values(array_filter($doctorIds, static function (int $id) use ($flip): bool {
return isset($flip[$id]);
}));
}
}
return $doctorIds;
}
/**
* @return array<string, float|int|null>
*/
private static function emptyTotals(): array
{
return [
'system_prescription_count' => 0,
'manual_prescription_count' => 0,
'deal_amount' => 0.0,
'deal_order_count' => 0,
'avg_deal_amount' => null,
'appointment_completed' => 0,
'appointment_missed' => 0,
'appointment_cancelled' => 0,
];
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return array<string, float|int|null>
*/
private static function sumTotals(array $rows): array
{
$t = self::emptyTotals();
foreach ($rows as $r) {
$t['system_prescription_count'] += (int) ($r['system_prescription_count'] ?? 0);
$t['manual_prescription_count'] += (int) ($r['manual_prescription_count'] ?? 0);
$t['deal_amount'] += (float) ($r['deal_amount'] ?? 0);
$t['deal_order_count'] += (int) ($r['deal_order_count'] ?? 0);
$t['appointment_completed'] += (int) ($r['appointment_completed'] ?? 0);
$t['appointment_missed'] += (int) ($r['appointment_missed'] ?? 0);
$t['appointment_cancelled'] += (int) ($r['appointment_cancelled'] ?? 0);
}
$t['deal_amount'] = round((float) $t['deal_amount'], 2);
$dc = (int) $t['deal_order_count'];
$t['avg_deal_amount'] = $dc > 0 ? round((float) $t['deal_amount'] / $dc, 2) : null;
return $t;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
*
* @return array<int, array{system:int, manual:int}>
*/
private static function loadPrescriptionCounts(
string $startDate,
string $endDate,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
$query = Db::name('tcm_prescription')
->alias('rx')
->whereNull('rx.delete_time')
->whereRaw('IFNULL(rx.void_status, 0) <> 1')
->whereBetween('rx.prescription_date', [$startDate, $endDate])
->whereIn('rx.creator_id', $doctorIds)
->where('rx.diagnosis_id', '>', 0);
self::applyPrescriptionChannelTagFilter(
$query,
'rx',
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
);
$query->field([
'rx.creator_id',
Db::raw('SUM(CASE WHEN IFNULL(rx.is_system_auto, 0) = 1 THEN 1 ELSE 0 END) AS system_cnt'),
Db::raw('SUM(CASE WHEN IFNULL(rx.is_system_auto, 0) <> 1 THEN 1 ELSE 0 END) AS manual_cnt'),
])->group('rx.creator_id');
$out = [];
foreach ($query->select()->toArray() as $r) {
$id = (int) ($r['creator_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'system' => (int) ($r['system_cnt'] ?? 0),
'manual' => (int) ($r['manual_cnt'] ?? 0),
];
}
return $out;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
*
* @return array<int, array{amount: float, count: int}>
*/
private static function loadOrderAggregates(
int $startTs,
int $endTs,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
$q = Db::name('tcm_prescription_order')
->alias('o')
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereBetween('o.create_time', [$startTs, $endTs])
->whereRaw('NOT (o.fulfillment_status <=> 4)')
->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$ph = implode(',', array_fill(0, count($normCh), '?'));
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$channelCond = "ap.channels IN ({$ph})";
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$q->whereRaw($existsSql, $strVals);
} elseif ($channelFilterActive) {
self::applyOrderTagFilter($q, 'o', 'rx', $tagDiagIds, $tagAssistantIds, $tagFallback);
}
$q->field([
'rx.creator_id',
Db::raw('SUM(o.amount) AS amount_sum'),
Db::raw('COUNT(*) AS order_cnt'),
])->group('rx.creator_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
$id = (int) ($r['creator_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'amount' => (float) ($r['amount_sum'] ?? 0),
'count' => (int) ($r['order_cnt'] ?? 0),
];
}
return $out;
}
/**
* 订单标签条件(无字典渠道映射时,与业绩 tag 分支一致;开方人维度用 rx.creator_id 兜底)。
*
* @param \think\db\Query $q
*/
private static function applyOrderTagFilter(
$q,
string $orderAlias,
string $rxAlias,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
): void {
if ($tagDiagIds !== null) {
$q->whereIn("{$orderAlias}.diagnosis_id", $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_keys($tagAssistantIds);
if ($tagFallback) {
$q->whereIn("{$rxAlias}.creator_id", array_map('intval', $ids));
} else {
$q->whereIn("{$orderAlias}.creator_id", array_map('intval', $ids));
}
}
}
/**
* @param \think\db\Query $query rx 别名查询
* @param string $rxAlias
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
*/
private static function applyPrescriptionChannelTagFilter(
$query,
string $rxAlias,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
): void {
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$ph = implode(',', array_fill(0, count($normCh), '?'));
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$channelCond = "ap.channels IN ({$ph})";
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = {$rxAlias}.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$query->whereRaw($existsSql, $strVals);
} elseif ($channelFilterActive) {
if ($tagDiagIds !== null) {
$query->whereIn("{$rxAlias}.diagnosis_id", $tagDiagIds);
}
if ($tagAssistantIds !== null && $tagFallback) {
$query->whereIn("{$rxAlias}.creator_id", array_map('intval', array_keys($tagAssistantIds)));
}
}
}
/**
* 标签范围显式为空(0 条诊单/医助)时整段统计不再查询。
*/
private static function tagScopeNonEmpty(?array $tagDiagIds, ?array $tagAssistantIds): bool
{
if ($tagDiagIds !== null && $tagDiagIds === []) {
return false;
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return false;
}
return true;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
*
* @return array<int, array{completed:int, missed:int, cancelled:int}>
*/
private static function loadAppointmentAggregates(
string $startDate,
string $endDate,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds
): array {
$q = Db::name('doctor_appointment')
->whereBetween('appointment_date', [$startDate, $endDate])
->whereIn('doctor_id', $doctorIds);
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$q->whereIn('channels', $strVals);
} elseif ($channelFilterActive && $tagDiagIds !== null) {
if ($tagDiagIds === []) {
return [];
}
$q->whereIn('patient_id', $tagDiagIds);
}
$q->field([
'doctor_id',
Db::raw('SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS completed'),
Db::raw('SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS missed'),
Db::raw('SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS cancelled'),
])->group('doctor_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
$id = (int) ($r['doctor_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'completed' => (int) ($r['completed'] ?? 0),
'missed' => (int) ($r['missed'] ?? 0),
'cancelled' => (int) ($r['cancelled'] ?? 0),
];
}
return $out;
}
/**
* @return int[]
*/
private static function normalizeAppointmentChannelInts(array $raw): array
{
$out = [];
foreach ($raw as $v) {
$i = (int) $v;
if ($i > 0) {
$out[] = $i;
}
}
return array_values(array_unique($out));
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
}
File diff suppressed because it is too large Load Diff
+782 -54
View File
@@ -15,13 +15,26 @@
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\enum\FileEnum;
use app\common\model\file\File as FileModel;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisGuahaoLog;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\ImChatMessage;
use app\common\model\tcm\BloodRecord;
use app\common\model\tcm\DietRecord;
use app\common\model\tcm\ExerciseRecord;
use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\service\ConfigService;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
use app\common\service\storage\Driver as StorageDriver;
use think\facade\Db;
/**
* 中医辨房病因诊单逻辑
@@ -129,7 +142,7 @@ class DiagnosisLogic extends BaseLogic
private static function generatePatientId(): int
{
// 获取当前最大的患者ID
$maxPatientId = Diagnosis::max('patient_id') ?? 100000;
$maxPatientId = Diagnosis::max('id') ?? 10000000;
// 返回下一个患者ID
return $maxPatientId + 1;
@@ -465,7 +478,66 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
*
* @return array{creator_id: int, create_time: int}|null
*/
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
{
if ($diagnosisId <= 0) {
return null;
}
$row = Db::name('tcm_prescription_order')
->where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('create_time', 'desc')
->order('id', 'desc')
->field(['creator_id', 'create_time'])
->find();
if ($row === null || $row === []) {
return null;
}
return [
'creator_id' => (int) ($row['creator_id'] ?? 0),
'create_time' => (int) ($row['create_time'] ?? 0),
];
}
/**
* 诊单 current assistant_id 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
* - 否则若 from_assistant_id > 0:视为释放/取消前的持有人
*/
private static function inferPreviousAssistantIdFromLatestAssignLog(int $diagnosisId): int
{
if ($diagnosisId <= 0) {
return 0;
}
$prev = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->field(['from_assistant_id', 'to_assistant_id'])
->find();
if ($prev === null || $prev === []) {
return 0;
}
$pTo = (int) ($prev['to_assistant_id'] ?? 0);
$pFrom = (int) ($prev['from_assistant_id'] ?? 0);
if ($pTo > 0) {
return $pTo;
}
if ($pFrom > 0) {
return $pFrom;
}
return 0;
}
/**
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)。
* **原医助**:优先诊单 `assistant_id`;若为 0 且本次为指派到某人,则从**最近一条**指派日志推断(覆盖发货释放后库为 0、业务上仍有「前任」医助的情况)。
* @param array $params
* @return bool
*/
@@ -480,33 +552,66 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find();
if (!$diagnosis) {
self::setError('诊单不存在');
$now = time();
$poSnap = self::getLatestPrescriptionOrderSnapshotForDiagnosis($id);
$relatedPoCreatorId = 0;
$relatedPoCreateTime = 0;
if ($poSnap !== null) {
$relatedPoCreatorId = $poSnap['creator_id'];
$relatedPoCreateTime = $poSnap['create_time'];
}
if ($relatedPoCreateTime <= 0) {
$relatedPoCreateTime = $now;
$relatedPoCreatorId = 0;
}
Db::startTrans();
try {
// 事务内 FOR UPDATE 直读库:确保「原医助」与 tcm_diagnosis.assistant_id 一致,避免模型缓存或并发覆盖
$diagLock = Db::name('tcm_diagnosis')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diagLock === null || $diagLock === []) {
Db::rollback();
self::setError('诊单不存在');
return false;
}
$fromAssistantId = (int) ($diagLock['assistant_id'] ?? 0);
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
}
Db::name('tcm_diagnosis')
->where('id', $id)
->whereNull('delete_time')
->update(['assistant_id' => $toAssistantId]);
$req = request();
$admin = $req->adminInfo ?? [];
// Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'create_time' => $now,
]);
} catch (\Throwable $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
$fromAssistantId = (int) ($diagnosis->getAttr('assistant_id') ?? 0);
Db::startTrans();
Diagnosis::where('id', $id)->whereNull('delete_time')->update([
'assistant_id' => $toAssistantId,
]);
$req = request();
$admin = $req->adminInfo ?? [];
DiagnosisAssignLog::create([
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'create_time' => time(),
]);
Db::commit();
return true;
@@ -523,24 +628,32 @@ class DiagnosisLogic extends BaseLogic
*/
public static function assignLogList(int $diagnosisId): array
{
$rows = DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
// Db 直查:避免 AssignLog 模型未维护 schema / fields_strict 时 toArray 丢列,导致前后端「医助」为空
$rows = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->select()
->toArray();
$adminIds = [];
foreach ($rows as $r) {
if (!empty($r['from_assistant_id'])) {
$adminIds[] = (int) $r['from_assistant_id'];
$fid = (int) ($r['from_assistant_id'] ?? 0);
$tid = (int) ($r['to_assistant_id'] ?? 0);
if ($fid > 0) {
$adminIds[] = $fid;
}
if (!empty($r['to_assistant_id'])) {
$adminIds[] = (int) $r['to_assistant_id'];
if ($tid > 0) {
$adminIds[] = $tid;
}
$rcp = (int) ($r['related_po_creator_id'] ?? 0);
if ($rcp > 0) {
$adminIds[] = $rcp;
}
}
$adminIds = array_values(array_unique(array_filter($adminIds)));
$nameMap = [];
if ($adminIds !== []) {
$nameMap = \app\common\model\auth\Admin::whereIn('id', $adminIds)->column('name', 'id');
$nameMap = Admin::whereIn('id', $adminIds)->column('name', 'id');
}
foreach ($rows as &$r) {
@@ -555,10 +668,18 @@ class DiagnosisLogic extends BaseLogic
$r['create_time_text'] = !empty($r['create_time'])
? date('Y-m-d H:i:s', (int) $r['create_time'])
: '';
$rcpId = (int) ($r['related_po_creator_id'] ?? 0);
$r['related_po_creator_name'] = $rcpId > 0
? (string) ($nameMap[$rcpId] ?? ('ID:' . $rcpId))
: '';
$rpct = (int) ($r['related_po_create_time'] ?? 0);
$r['related_po_create_time_text'] = $rpct > 0 ? date('Y-m-d H:i:s', $rpct) : '';
$r['related_po_is_cleared_assistant_creator'] =
($rcpId > 0 && $fromId > 0 && $rcpId === $fromId) ? 1 : 0;
}
unset($r);
return $rows;
return array_values($rows);
}
/**
@@ -954,10 +1075,29 @@ class DiagnosisLogic extends BaseLogic
if (empty($doctorAccounts)) {
return [];
}
$diagnosisId = (int)($diag['id'] ?? 0);
// 增量优化:按 doctor_peer_account 维度取归档表最大时间作为 MinTime 起点,
// 避免每次都从头拉取已归档的历史消息(节约腾讯云 admin_getroammsg 调用配额)
$minTimeMap = [];
if ($diagnosisId > 0) {
$rows = ImChatMessage::field('doctor_peer_account, MAX(msg_time) AS max_time')
->where('diagnosis_id', $diagnosisId)
->group('doctor_peer_account')
->select()
->toArray();
foreach ($rows as $r) {
$acct = (string)($r['doctor_peer_account'] ?? '');
if ($acct !== '') {
$minTimeMap[$acct] = (int)($r['max_time'] ?? 0);
}
}
}
$imService = new \app\common\service\TencentImService();
$merged = [];
foreach ($doctorAccounts as $docAccount) {
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId);
// 已归档过:从最大已知 msg_time 起拉(含等于以兜底边界,配合 INSERT IGNORE 去重)
$minTime = isset($minTimeMap[$docAccount]) ? max(0, $minTimeMap[$docAccount]) : 0;
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId, $minTime);
foreach ($batch as $row) {
$merged[] = $row;
}
@@ -1075,23 +1215,44 @@ class DiagnosisLogic extends BaseLogic
/**
* @return array<int, array<string, mixed>>
*/
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer): array
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer, int $minTime = 0): array
{
$out = [];
$lastKey = null;
$lastTime = null;
$guard = 0;
$maxPages = 80;
do {
$res = $svc->adminGetRoamMsg($operator, $peer, 100, 0, 4294967295, $lastKey, $lastTime);
if (!$res['success']) {
if (!empty($res['error'])) {
\think\facade\Log::warning('IM漫游消息拉取失败', [
'operator' => $operator,
'peer' => $peer,
'error' => $res['error'],
'code' => $res['rawErrorCode'] ?? 0,
]);
// 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃
$res = null;
$attempt = 0;
while ($attempt < 3) {
$res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime);
if (!empty($res['success'])) {
break;
}
$attempt++;
\think\facade\Log::warning('IM漫游消息拉取失败(待重试)', [
'operator' => $operator,
'peer' => $peer,
'attempt' => $attempt,
'error' => $res['error'] ?? '',
'code' => $res['rawErrorCode'] ?? 0,
'page' => $guard + 1,
]);
if ($attempt < 3) {
usleep(300000); // 300ms 退避
}
}
if (empty($res['success'])) {
\think\facade\Log::error('IM漫游消息拉取失败(重试耗尽,本轮中断)', [
'operator' => $operator,
'peer' => $peer,
'page' => $guard + 1,
'fetched_so_far' => count($out),
'error' => $res['error'] ?? '',
'code' => $res['rawErrorCode'] ?? 0,
]);
break;
}
foreach ($res['msgList'] as $raw) {
@@ -1112,7 +1273,16 @@ class DiagnosisLogic extends BaseLogic
break;
}
$guard++;
if ($guard > 80) {
if ($guard >= $maxPages) {
// 触顶安全网:增量优化后通常拉不满 80 页,触顶意味着首次全量或会话异常多
\think\facade\Log::warning('IM漫游消息拉取触发分页上限(可能未拉完)', [
'operator' => $operator,
'peer' => $peer,
'min_time' => $minTime,
'pages_fetched' => $guard,
'fetched_so_far' => count($out),
'last_msg_time' => $lastTime,
]);
break;
}
} while (true);
@@ -1711,22 +1881,14 @@ class DiagnosisLogic extends BaseLogic
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
self::setError('参数错误');
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
// 与 startCall 的 caller_id 不一致或竞态时,回退到该诊单最新一条通话记录
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->find();
}
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
if (!$record) {
self::setError('未找到通话记录');
@@ -1756,6 +1918,158 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
* @param array $params
* @return array|false
*/
public static function uploadCallRecording(array $params)
{
$uploadDir = '';
$mergedPath = '';
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
if (
$diagnosisId <= 0 ||
$adminId <= 0 ||
$uploadId === '' ||
$fileName === '' ||
$fileSize <= 0 ||
$chunkIndex < 0 ||
$chunkTotal <= 0
) {
self::setError('上传参数不完整');
return false;
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, config('project.file_video'), true)) {
self::setError('视频格式不支持');
return false;
}
if ($callRecordId <= 0) {
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName);
$callRecordId = (int)($record['id'] ?? 0);
} else {
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId, false);
}
if (!$record) {
self::setError('通话记录不存在');
return false;
}
$chunkFile = request()->file('file');
if (!$chunkFile) {
self::setError('未找到上传分片');
return false;
}
$uploadDir = self::callRecordingChunkDir($uploadId);
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true) && !is_dir($uploadDir)) {
self::setError('创建上传目录失败');
return false;
}
file_put_contents($uploadDir . DIRECTORY_SEPARATOR . 'meta.json', json_encode([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_name' => $fileName,
'file_size' => $fileSize,
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex);
$moved = $chunkFile->move($uploadDir, basename($chunkPath));
if (!$moved) {
self::setError($chunkFile->getError() ?: '保存分片失败');
return false;
}
$uploadedCount = self::countUploadedCallRecordingChunks($uploadDir);
if ($uploadedCount < $chunkTotal) {
return [
'completed' => false,
'uploaded_chunks' => $uploadedCount,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
];
}
$mergedPath = $uploadDir . DIRECTORY_SEPARATOR . 'merged.' . $ext;
self::mergeCallRecordingChunks($uploadDir, $mergedPath, $chunkTotal);
if (!is_file($mergedPath) || filesize($mergedPath) <= 0) {
self::setError('合并后的文件无效');
return false;
}
$uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId);
if (empty($uploadResult['uri'])) {
self::setError('保存视频失败');
return false;
}
$attached = self::attachLocalCallRecording([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
]);
if (!$attached) {
return false;
}
self::cleanupCallRecordingChunkDir($uploadDir);
return [
'completed' => true,
'uploaded_chunks' => $chunkTotal,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'recording_status' => 2,
];
} catch (\Throwable $e) {
if ($mergedPath !== '' && is_file($mergedPath)) {
@unlink($mergedPath);
}
self::setError($e->getMessage());
return false;
}
}
public static function createManualCallRecord(array $params)
{
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
self::setError('参数错误');
return false;
}
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, 'manual-upload');
if (!$record) {
self::setError(self::getError() ?: '创建通话记录失败');
return false;
}
return [
'id' => (int)($record['id'] ?? 0),
'diagnosis_id' => $diagnosisId,
];
}
/**
* @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致)
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
@@ -3324,4 +3638,418 @@ class DiagnosisLogic extends BaseLogic
return $logs;
}
/**
* @notes 二诊只读病例详情
*
* 用途:医助从诊单列表「查看 / 双击」进入只读页(T1+T2),返回与接诊台同结构的
* 三块内容(appointment + diagnosis + 跟踪记录)。
*
* 数据权限(与列表 lists 接口一致):
* - 医助 (role_id=2):仅能访问 assistant_id == self 的诊单
* - DataScope 启用:assistant_id 须落在 visibleAdminIds
* - 越权 setError + 返回 []
*
* @param array $params 至少含 id
* @param int $adminId
* @param array<string,mixed> $adminInfo request->adminInfo
* @return array
*/
public static function readonlyDetail(array $params, int $adminId, array $adminInfo): array
{
$diagnosisId = (int) ($params['id'] ?? 0);
if ($diagnosisId <= 0) {
self::setError('诊单 id 不能为空');
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
// 医助仅看自己被指派的
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return [];
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return [];
}
// 2) 诊单详情(含图片聚合等)+ 字典翻译
$diagnosis = self::detail(['id' => $diagnosisId]);
if (empty($diagnosis)) {
self::setError('诊单数据为空');
return [];
}
$diagnosis = AppointmentLogic::enrichDiagnosisLabels($diagnosis);
// 3) 最近一条挂号(PatientInfoCard 显示用,无挂号则给默认空对象)
$appointment = self::buildLatestAppointmentForReadonly((int) ($diagnosis['id'] ?? $diagnosisId), $diagnosis);
// 4) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
// 5) 医生备注
$doctorNotes = DoctorNoteLogic::getByDiagnosis($diagnosisId);
// 5.1) 跟踪备注(只读展示,按 note_date DESC
$trackingNotes = TrackingNoteLogic::getByDiagnosis($diagnosisId);
// 6) 未服务天数(与 T3 列同口径)
$maxBloodTs = BloodRecord::where('diagnosis_id', $diagnosisId)
->max('record_date');
$maxBloodTs = (int) $maxBloodTs;
$unservedDays = $maxBloodTs > 0 ? max(0, (int) floor((time() - $maxBloodTs) / 86400)) : null;
$lastBloodRecordAt = $maxBloodTs > 0 ? date('Y-m-d', $maxBloodTs) : null;
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'doctor_notes' => $doctorNotes,
'tracking_notes' => $trackingNotes,
'unserved_days' => $unservedDays,
'last_blood_record_at' => $lastBloodRecordAt,
];
}
/**
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail
* 医生接诊台 reception 通过独立接口 lazy load。
*
* 区间语义:闭区间 [startDate, endDate]Y-m-d),均不传则不限。
*
* @return array{
* blood_records: array<int,array<string,mixed>>,
* diet_records: array<int,array<string,mixed>>,
* exercise_records: array<int,array<string,mixed>>,
* start_date: string,
* end_date: string,
* }
*/
public static function fetchTrackingWindow(int $diagnosisId, string $startDate = '', string $endDate = ''): array
{
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'start_date' => $startDate,
'end_date' => $endDate,
];
}
/**
* 拼一份「最近一条挂号」给只读页 PatientInfoCard 用;没有挂号则给默认空骨架。
*
* @param int $diagnosisId
* @param array<string,mixed> $diagnosis 已字典翻译过的诊单数据
* @return array<string,mixed>
*/
private static function buildLatestAppointmentForReadonly(int $diagnosisId, array $diagnosis): array
{
$apt = Appointment::where('patient_id', $diagnosisId)
->order('id', 'desc')
->find();
$patientName = (string) ($diagnosis['patient_name'] ?? '');
$patientPhone = (string) ($diagnosis['phone'] ?? '');
if (!$apt) {
return [
'id' => 0,
'patient_id' => $diagnosisId,
'patient_name' => $patientName,
'patient_phone' => $patientPhone,
'doctor_id' => 0,
'doctor_name' => '',
'assistant_id' => (int) ($diagnosis['assistant_id'] ?? 0),
'assistant_name' => self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)),
'appointment_date' => '',
'appointment_time' => '',
'period' => '',
'status' => 0,
'status_desc' => '无挂号',
'has_prescription' => 0,
'remark' => '',
];
}
$aptArr = $apt->toArray();
$aptArr['patient_name'] = $patientName;
$aptArr['patient_phone'] = $patientPhone;
// 医生姓名
$aptArr['doctor_name'] = '';
$docId = (int) ($aptArr['doctor_id'] ?? 0);
if ($docId > 0) {
$aptArr['doctor_name'] = (string) Admin::where('id', $docId)->value('name');
}
$aptArr['assistant_id'] = (int) ($diagnosis['assistant_id'] ?? 0);
$aptArr['assistant_name'] = self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0));
// status_desc 与现有列表口径保持简单映射
$statusMap = [
0 => '未挂号',
1 => '已预约',
2 => '已取消',
3 => '已完成',
4 => '已过号',
];
$aptArr['status_desc'] = $statusMap[(int) ($aptArr['status'] ?? 0)] ?? '';
$aptArr['has_prescription'] = (int) ($diagnosis['has_prescription'] ?? 0);
return $aptArr;
}
private static function resolveAssistantName(int $assistantId): string
{
if ($assistantId <= 0) {
return '';
}
return (string) Admin::where('id', $assistantId)->value('name');
}
/**
* @return array<int,array<string,mixed>>
*/
private static function fetchBloodRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
{
$query = BloodRecord::where('diagnosis_id', $diagnosisId);
if ($sinceTs > 0) {
$query->where('record_date', '>=', $sinceTs);
}
if ($untilTs > 0) {
$query->where('record_date', '<=', $untilTs);
}
$rows = $query->order('record_date', 'desc')
->order('record_time', 'desc')
->select()
->toArray();
// 给前端一个 Y-m-d 字符串方便按日期透视;保留原 record_date 整数戳作日期窗口比较
foreach ($rows as &$row) {
$ts = (int) ($row['record_date'] ?? 0);
$row['record_date_ts'] = $ts;
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
}
return $rows;
}
/**
* @return array<int,array<string,mixed>>
*/
private static function fetchDietRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
{
$query = DietRecord::where('diagnosis_id', $diagnosisId);
if ($sinceTs > 0) {
$query->where('record_date', '>=', $sinceTs);
}
if ($untilTs > 0) {
$query->where('record_date', '<=', $untilTs);
}
$rows = $query->order('record_date', 'desc')->select()->toArray();
foreach ($rows as &$row) {
$ts = (int) ($row['record_date'] ?? 0);
$row['record_date_ts'] = $ts;
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
}
return $rows;
}
/**
* @return array<int,array<string,mixed>>
*/
private static function fetchExerciseRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
{
$query = ExerciseRecord::where('diagnosis_id', $diagnosisId);
if ($sinceTs > 0) {
$query->where('record_date', '>=', $sinceTs);
}
if ($untilTs > 0) {
$query->where('record_date', '<=', $untilTs);
}
$rows = $query->order('record_date', 'desc')
->append(['intensity_text'])
->select()
->toArray();
foreach ($rows as &$row) {
$ts = (int) ($row['record_date'] ?? 0);
$row['record_date_ts'] = $ts;
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
}
return $rows;
}
private static function resolveCallRecordForAttachment(
int $diagnosisId,
int $adminId,
int $callRecordId = 0,
bool $allowFallback = true
)
{
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->find();
if ($record) {
return $record;
}
if (!$allowFallback) {
return null;
}
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
if ($record || !$allowFallback) {
return $record;
}
return \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->find();
}
private static function createSyntheticCallRecord(int $diagnosisId, int $adminId, string $fileName = '')
{
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
if (!$diagnosis) {
self::setError('诊单不存在');
return null;
}
$now = time();
$roomSeed = substr(md5($diagnosisId . '-' . $adminId . '-' . $fileName . '-' . $now), 0, 8);
return \app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $diagnosisId,
'caller_id' => $adminId,
'caller_type' => 'doctor',
'callee_id' => (int)($diagnosis['patient_id'] ?? 0),
'callee_type' => 'patient',
'call_type' => 2,
'status' => 2,
'start_time' => $now,
'end_time' => $now,
'duration' => 0,
'room_id' => 'manual_upload_' . $roomSeed,
'recording_status' => 0,
'create_time' => $now,
'update_time' => $now,
]);
}
private static function callRecordingChunkDir(string $uploadId): string
{
$safeId = preg_replace('/[^a-zA-Z0-9_-]/', '', $uploadId);
return runtime_path() . 'call_recording_chunks' . DIRECTORY_SEPARATOR . $safeId;
}
private static function callRecordingChunkName(int $chunkIndex): string
{
return sprintf('chunk_%06d.part', $chunkIndex);
}
private static function countUploadedCallRecordingChunks(string $uploadDir): int
{
$files = glob($uploadDir . DIRECTORY_SEPARATOR . 'chunk_*.part');
return is_array($files) ? count($files) : 0;
}
private static function mergeCallRecordingChunks(string $uploadDir, string $mergedPath, int $chunkTotal): void
{
$out = fopen($mergedPath, 'wb');
if ($out === false) {
throw new \RuntimeException('创建合并文件失败');
}
try {
for ($i = 0; $i < $chunkTotal; $i++) {
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($i);
if (!is_file($chunkPath)) {
throw new \RuntimeException('上传分片缺失,无法合并');
}
$in = fopen($chunkPath, 'rb');
if ($in === false) {
throw new \RuntimeException('读取上传分片失败');
}
stream_copy_to_stream($in, $out);
fclose($in);
}
} finally {
fclose($out);
}
}
private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local' => []],
];
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = 'uploads/video/' . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败');
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
'uri' => $relativePath,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => $adminId,
'create_time' => time(),
]);
return [
'id' => (int)($storedFile['id'] ?? 0),
'uri' => FileService::getFileUrl($relativePath),
'url' => $relativePath,
];
}
private static function cleanupCallRecordingChunkDir(string $uploadDir): void
{
if ($uploadDir === '' || !is_dir($uploadDir)) {
return;
}
foreach ((array)glob($uploadDir . DIRECTORY_SEPARATOR . '*') as $item) {
if (is_file($item)) {
@unlink($item);
}
}
@rmdir($uploadDir);
}
}
@@ -0,0 +1,188 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisTodo;
/**
* 诊单待办事项逻辑
*
* 状态机:
* add() status=0 (待执行)
* cron status=1 (已发送) / status=3 (失败)
* cancel() status=2 (已取消,仅 status=0 可取消)
*
* 鉴权:
* add:诊单需存在;数据权限交由路由层统一「tcm.diagnosis/dailyRecord」权限域控制,
* 并兼容历史 diagnosisTodo/* 接口路由。
* cancel:仅创建人本人或超级管理员 (role_id=1) 可取消,且仅 status=0 时。
*
* @package app\adminapi\logic\tcm
*/
class DiagnosisTodoLogic extends BaseLogic
{
/**
* @notes 新增待办
*
* @param array<string,mixed> $params 已通过 sceneAdd 验证
* @param int $adminId 当前 admin id
* @param array<string,mixed> $adminInfo request->adminInfo
*/
public static function add(array $params, int $adminId, array $adminInfo): bool
{
try {
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
$diagnosis = Diagnosis::findOrEmpty($diagnosisId);
if ($diagnosis->isEmpty()) {
self::setError('诊单不存在');
return false;
}
$remindTime = (int) ($params['remind_time'] ?? 0);
if ($remindTime <= time()) {
self::setError('提醒时间必须晚于当前时间');
return false;
}
$creatorName = trim((string) ($adminInfo['name'] ?? ''));
if ($creatorName === '' && $adminId > 0) {
$creatorName = (string) Admin::where('id', $adminId)->value('name');
}
DiagnosisTodo::create([
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) ($diagnosis->getAttr('patient_id') ?? 0),
'content' => trim((string) ($params['content'] ?? '')),
'remind_time' => $remindTime,
'status' => DiagnosisTodo::STATUS_PENDING,
'creator_id' => $adminId,
'creator_name' => $creatorName,
]);
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 取消待办(人工)
*/
public static function cancel(int $id, int $adminId): bool
{
try {
$todo = DiagnosisTodo::findOrEmpty($id);
if ($todo->isEmpty()) {
self::setError('待办不存在');
return false;
}
$status = (int) $todo->getAttr('status');
if ($status !== DiagnosisTodo::STATUS_PENDING) {
self::setError('该待办已不是「待执行」状态,无法取消');
return false;
}
$creatorId = (int) $todo->getAttr('creator_id');
$isSuper = self::isSuperAdmin($adminId);
if ($creatorId !== $adminId && !$isSuper) {
self::setError('仅创建人或超级管理员可取消');
return false;
}
$todo->save([
'status' => DiagnosisTodo::STATUS_CANCELLED,
'cancelled_at' => time(),
'cancelled_by' => $adminId,
]);
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 详情
*
* @return array<string,mixed>
*/
public static function detail(int $id, int $adminId = 0): array
{
$todo = DiagnosisTodo::findOrEmpty($id);
if ($todo->isEmpty()) {
return [];
}
$arr = $todo->append([
'status_text',
'remind_time_text',
'notified_at_text',
'cancelled_at_text',
])->toArray();
// 业务态:是否能被「我」取消(前端按钮显隐用)
$arr['can_cancel'] = self::canCancel($todo->toArray(), $adminId);
return $arr;
}
/**
* 是否超管:role_id=1
*/
public static function isSuperAdmin(int $adminId): bool
{
if ($adminId <= 0) {
return false;
}
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
return in_array(1, array_map('intval', $roleIds), true);
}
/**
* 业务判定:当前 admin 能否取消该待办
*
* @param array<string,mixed> $todoRow
*/
public static function canCancel(array $todoRow, int $adminId): bool
{
if ((int) ($todoRow['status'] ?? -1) !== DiagnosisTodo::STATUS_PENDING) {
return false;
}
if ($adminId <= 0) {
return false;
}
if ((int) ($todoRow['creator_id'] ?? 0) === $adminId) {
return true;
}
return self::isSuperAdmin($adminId);
}
}
@@ -152,6 +152,19 @@ class PrescriptionLogic
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
}
private static function normalizeDosageBagCount($raw): int
{
$count = (int) $raw;
if ($count < 1) {
return 1;
}
if ($count > 5) {
return 5;
}
return $count;
}
/**
* 同一诊单、同一开方人、同一处方日期:仅允许一张「未作废」的未删除记录;已作废的可再新开一张。
*
@@ -230,6 +243,7 @@ class PrescriptionLogic
'prescription_type' => $params['prescription_type'] ?? '浓缩水丸',
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : null,
'dosage_unit' => $params['dosage_unit'] ?? '',
'dosage_bag_count' => self::normalizeDosageBagCount($params['dosage_bag_count'] ?? 1),
'need_decoction' => (int)($params['need_decoction'] ?? 0),
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : 1,
'diagnosis_id' => (int)($params['diagnosis_id'] ?? 0),
@@ -359,6 +373,7 @@ class PrescriptionLogic
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
'dosage_amount' => isset($params['dosage_amount']) ? (float)$params['dosage_amount'] : $prescription->dosage_amount,
'dosage_unit' => $params['dosage_unit'] ?? $prescription->dosage_unit,
'dosage_bag_count' => self::normalizeDosageBagCount($params['dosage_bag_count'] ?? ($prescription->dosage_bag_count ?? 1)),
'need_decoction' => isset($params['need_decoction']) ? (int)$params['need_decoction'] : (int)($prescription->need_decoction ?? 0),
'bags_per_dose' => isset($params['bags_per_dose']) ? (int)$params['bags_per_dose'] : (int)($prescription->bags_per_dose ?? 1),
'patient_name' => $params['patient_name'] ?? $prescription->patient_name,
@@ -811,6 +826,7 @@ class PrescriptionLogic
'prescription_type' => '浓缩水丸',
'dosage_amount' => 1.0,
'dosage_unit' => 'g',
'dosage_bag_count' => 1,
'need_decoction' => 0,
'bags_per_dose' => 1,
'diagnosis_id' => $diagnosisId,
@@ -15,6 +15,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use app\common\service\ExpressTrackService;
use app\common\service\gancao\GancaoScmRecipelService;
use think\facade\Config;
@@ -111,6 +112,55 @@ class PrescriptionOrderLogic
return self::roleIntersect($adminInfo, is_array($roles) ? $roles : []);
}
/**
* 菜单权限:非全量角色可额外查看「关联处方开方人为本账号」的业务订单(医助代建单)
*
* perms: tcm.prescriptionOrder/viewOrdersForOwnPrescription
*/
public static function canViewOrdersForOwnPrescription(array $adminInfo): bool
{
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.prescriptionOrder/viewOrdersForOwnPrescription', $perms, true);
}
/**
* 业务订单关联处方的开方人是否为指定管理员(处方 creator_id
*/
public static function isPrescriptionOrderPrescriber(int $prescriptionId, int $adminId): bool
{
if ($prescriptionId <= 0 || $adminId <= 0) {
return false;
}
$cid = (int) Prescription::where('id', $prescriptionId)->whereNull('delete_time')->value('creator_id');
return $cid === $adminId;
}
/**
* PrescriptionOrderLists 数据域一致:创建人或关联诊单医助属于当前账号可见 admin 集合
*/
private static function orderWithinDataScopeOwners(PrescriptionOrder $row, int $adminId, array $adminInfo): bool
{
if (!DataScopeService::isEnabled()) {
return false;
}
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($ids === null || $ids === []) {
return false;
}
if (in_array((int) $row->creator_id, $ids, true)) {
return true;
}
$did = (int) $row->diagnosis_id;
if ($did <= 0) {
return false;
}
$da = (int) Diagnosis::where('id', $did)->whereNull('delete_time')->value('assistant_id');
return $da > 0 && in_array($da, $ids, true);
}
/**
* @param PrescriptionOrder $row
*/
@@ -119,16 +169,40 @@ class PrescriptionOrderLogic
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
return true;
}
if ((int) $row->creator_id === $adminId) {
return true;
}
$did = (int) $row->diagnosis_id;
if ($did > 0) {
$da = (int) Diagnosis::where('id', $did)->whereNull('delete_time')->value('assistant_id');
if ($da === $adminId) {
return true;
}
}
if (self::canViewOrdersForOwnPrescription($adminInfo)) {
if (self::isPrescriptionOrderPrescriber((int) $row->prescription_id, $adminId)) {
return true;
}
}
if (self::orderWithinDataScopeOwners($row, $adminId, $adminInfo)) {
return true;
}
return (int) $row->creator_id === $adminId;
return false;
}
/**
* 撤回:仅创建人或全量角色(避免开方医生仅「查看医助代建单」权限误撤他人单子)
*
* @param PrescriptionOrder $row
*/
public static function canWithdrawOrder($row, int $adminId, array $adminInfo): bool
{
return self::canAccessOrder($row, $adminId, $adminInfo);
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
return true;
}
return (int) $row->creator_id === $adminId;
}
public static function canViewInternalCost(array $adminInfo): bool
@@ -203,6 +277,31 @@ class PrescriptionOrderLogic
}
}
/**
* 菜单权限:查看/编辑业务订单「备注」(对内 remark_extra
*
* perms: tcm.prescriptionOrder/editRemarkExtra
*/
public static function canEditOrderRemarkExtra(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.prescriptionOrder/editRemarkExtra', $perms, true);
}
/**
* @param array<string,mixed> $row
*/
public static function maskRemarkExtraIfNeeded(array &$row, array $adminInfo): void
{
if (!self::canEditOrderRemarkExtra($adminInfo)) {
unset($row['remark_extra']);
}
}
/** 定金门槛(元),0 表示不校验 */
public static function depositMinAmount(): float
{
@@ -576,7 +675,10 @@ class PrescriptionOrderLogic
$order->fee_type = (int) $params['fee_type'];
$order->amount = round((float) $params['amount'], 2);
$order->internal_cost = $internalCost;
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
$order->remark_extra = self::canEditOrderRemarkExtra($adminInfo)
? (string) ($params['remark_extra'] ?? '')
: '';
$order->remark_assistant = (string) ($params['remark_assistant'] ?? '');
$order->linked_pay_order_id = $payOrderIds !== [] ? $payOrderIds[0] : null;
$order->prescription_audit_status = 0;
$order->prescription_audit_remark = '';
@@ -600,6 +702,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -621,15 +724,22 @@ class PrescriptionOrderLogic
}
$arr = $row->toArray();
self::maskInternalCostIfNeeded($arr, $adminInfo);
self::maskRemarkExtraIfNeeded($arr, $adminInfo);
self::attachLinkedPayOrders($arr);
// 添加创建人姓名
// 添加创建人姓名、登录账号(业务订单 creator_id;la_admin 无手机列则不发手机号)
$creatorId = (int) ($arr['creator_id'] ?? 0);
$arr['creator_name'] = '';
$arr['creator_account'] = '';
$arr['creator_mobile'] = '';
if ($creatorId > 0) {
$creatorName = Admin::where('id', $creatorId)->value('name');
$arr['creator_name'] = $creatorName ? (string) $creatorName : '';
} else {
$arr['creator_name'] = '';
// 须包含 idAdmin 模型 $append 在 toArray 时会依赖 data['id']
$adm = Admin::where('id', $creatorId)->whereNull('delete_time')->field(['id', 'name', 'account'])->find();
if ($adm !== null) {
$a = $adm->toArray();
$arr['creator_name'] = (string) ($a['name'] ?? '');
$arr['creator_account'] = (string) ($a['account'] ?? '');
}
}
$rxId = (int) ($arr['prescription_id'] ?? 0);
@@ -707,6 +817,8 @@ class PrescriptionOrderLogic
}
}
$arr['diagnosis_creator_dept_path'] = $deptPath;
/** 与 diagnosis_creator_dept_path 相同语义:业务订单创建人所属部门(多部门「;」、路径「 / 」),便于前端字段自解释 */
$arr['order_creator_dept_path'] = $deptPath;
// 挂号预约摘要:优先处方 appointment_id;否则按诊单 id 匹配预约表 patient_id(诊单)
$arr['linked_appointment'] = null;
@@ -824,25 +936,31 @@ class PrescriptionOrderLogic
}
/**
* 挂号预约简要信息(业务订单详情展示
* 渠道字典 value => 名称(与挂号 channel_source 存值一致
*
* @return array<string, mixed>|null
* @return array<string, string>
*/
private static function buildAppointmentBrief(int $appointmentId): ?array
private static function channelSourceDictMap(): array
{
if ($appointmentId <= 0) {
return null;
}
$ap = Appointment::find($appointmentId);
if ($ap === null) {
return null;
}
$row = $ap->toArray();
$doctorName = '';
$did = (int) ($row['doctor_id'] ?? 0);
if ($did > 0) {
$doctorName = (string) (Admin::where('id', $did)->value('name') ?? '');
try {
/** @var array<string, string> $m */
$m = Db::name('dict_data')->where('type_value', 'channels')->column('name', 'value');
return $m;
} catch (\Throwable) {
return [];
}
}
/**
* @param array<string, mixed> $row doctor_appointment
* @param array<string, string> $channelDict
*
* @return array<string, mixed>
*/
private static function formatAppointmentBriefFromRow(array $row, string $doctorName, array $channelDict = []): array
{
$apptId = (int) ($row['id'] ?? 0);
$statusMap = [
1 => '已预约',
2 => '已取消',
@@ -862,9 +980,33 @@ class PrescriptionOrderLogic
}
$status = (int) ($row['status'] ?? 0);
$atype = (string) ($row['appointment_type'] ?? '');
$chSrc = trim((string) ($row['channel_source'] ?? ''));
if ($chSrc === '') {
$legacy = $row['channels'] ?? null;
if ($legacy !== null && $legacy !== '') {
$chSrc = is_numeric($legacy)
? (string) (int) $legacy
: trim((string) $legacy);
}
}
$chDesc = '';
if ($chSrc !== '') {
$chDesc = (string) ($channelDict[$chSrc] ?? '');
if ($chDesc === '' && is_numeric($chSrc)) {
$chDesc = (string) ($channelDict[(string) (int) $chSrc] ?? '');
}
if ($chDesc === '') {
$chDesc = $chSrc;
}
}
$channelDictValue = null;
if ($chSrc !== '' && is_numeric($chSrc)) {
$channelDictValue = (int) $chSrc;
}
return [
'id' => $appointmentId,
'id' => $apptId,
'appointment_date' => (string) ($row['appointment_date'] ?? ''),
'period' => $period,
'appointment_time' => $timePart,
@@ -873,11 +1015,146 @@ class PrescriptionOrderLogic
'status' => $status,
'status_desc' => $statusMap[$status] ?? '未知',
'doctor_name' => $doctorName,
'channel_source' => (string) ($row['channel_source'] ?? ''),
'channel_source' => $chSrc,
'channel_source_desc' => $chDesc,
/** 字典 type_value=channels 的 value(整型);与业绩看板挂号 channels 收窄、进线统计所用字典为同套 */
'channel_dict_value' => $channelDictValue,
'remark' => (string) ($row['remark'] ?? ''),
];
}
/**
* 挂号预约简要信息(业务订单详情展示)
*
* @return array<string, mixed>|null
*/
private static function buildAppointmentBrief(int $appointmentId): ?array
{
if ($appointmentId <= 0) {
return null;
}
$ap = Appointment::find($appointmentId);
if ($ap === null) {
return null;
}
$row = $ap->toArray();
$doctorName = '';
$did = (int) ($row['doctor_id'] ?? 0);
if ($did > 0) {
$doctorName = (string) (Admin::where('id', $did)->value('name') ?? '');
}
$channelDict = self::channelSourceDictMap();
return self::formatAppointmentBriefFromRow($row, $doctorName, $channelDict);
}
/**
* 业务订单列表行批量附加 linked_appointment(解析规则与详情一致:优先处方 appointment_id,否则诊单最近一条预约)
*
* @param array<int, array<string, mixed>> $lists
*/
public static function appendLinkedAppointmentsToListRows(array &$lists): void
{
if ($lists === []) {
return;
}
foreach ($lists as &$item) {
$item['linked_appointment'] = null;
}
unset($item);
$rxIds = array_values(array_unique(array_filter(array_map(
static fn ($x) => (int) ($x['prescription_id'] ?? 0),
$lists
))));
$rxApptByRx = [];
if ($rxIds !== []) {
$rxApptByRx = Prescription::whereIn('id', $rxIds)->whereNull('delete_time')->column('appointment_id', 'id');
}
$diagNeedFallback = [];
foreach ($lists as $item) {
$rxId = (int) ($item['prescription_id'] ?? 0);
$dgId = (int) ($item['diagnosis_id'] ?? 0);
$apFromRx = $rxId > 0 ? (int) ($rxApptByRx[$rxId] ?? 0) : 0;
if ($apFromRx <= 0 && $dgId > 0) {
$diagNeedFallback[$dgId] = true;
}
}
$diagIds = array_keys($diagNeedFallback);
$latestApptByDiag = [];
if ($diagIds !== []) {
try {
$agg = Db::name('doctor_appointment')
->whereIn('patient_id', $diagIds)
->fieldRaw('patient_id, MAX(id) as max_id')
->group('patient_id')
->select()
->toArray();
foreach ($agg as $r) {
$pid = (int) ($r['patient_id'] ?? 0);
$mid = (int) ($r['max_id'] ?? 0);
if ($pid > 0 && $mid > 0) {
$latestApptByDiag[$pid] = $mid;
}
}
} catch (\Throwable) {
$latestApptByDiag = [];
}
}
/** @var array<int, array{0: int, 1: int}> $resolvedPairs index => [ resolvedApptId, rxAppointmentId ] */
$resolvedPairs = [];
foreach ($lists as $idx => $item) {
$rxId = (int) ($item['prescription_id'] ?? 0);
$dgId = (int) ($item['diagnosis_id'] ?? 0);
$rxApptId = $rxId > 0 ? (int) ($rxApptByRx[$rxId] ?? 0) : 0;
$resolved = $rxApptId;
if ($resolved <= 0 && $dgId > 0) {
$resolved = (int) ($latestApptByDiag[$dgId] ?? 0);
}
if ($resolved > 0) {
$resolvedPairs[$idx] = [$resolved, $rxApptId];
}
}
if ($resolvedPairs === []) {
return;
}
$allApptIds = array_values(array_unique(array_map(static fn ($p) => $p[0], $resolvedPairs)));
$appts = Appointment::whereIn('id', $allApptIds)->select()->toArray();
$apptById = [];
foreach ($appts as $a) {
$aid = (int) ($a['id'] ?? 0);
if ($aid > 0) {
$apptById[$aid] = $a;
}
}
$doctorIds = array_values(array_unique(array_filter(array_map(
static fn ($a) => (int) ($a['doctor_id'] ?? 0),
$appts
))));
$doctorNames = [];
if ($doctorIds !== []) {
$doctorNames = Admin::whereIn('id', $doctorIds)->column('name', 'id');
}
$channelDict = self::channelSourceDictMap();
foreach ($resolvedPairs as $idx => $pair) {
$resolvedId = $pair[0];
$rxApptId = $pair[1];
$row = $apptById[$resolvedId] ?? null;
if ($row === null || !is_array($row)) {
continue;
}
$did = (int) ($row['doctor_id'] ?? 0);
$dname = $did > 0 ? (string) ($doctorNames[$did] ?? '') : '';
$brief = self::formatAppointmentBriefFromRow($row, $dname, $channelDict);
$brief['resolved_from'] = $rxApptId === $resolvedId ? 'prescription' : 'diagnosis';
$lists[$idx]['linked_appointment'] = $brief;
}
}
/**
* 物流轨迹(顺丰/京东等,优先快递100;未配置密钥时返回官网链接)
*
@@ -1013,7 +1290,10 @@ class PrescriptionOrderLogic
}
$order->fee_type = (int) $params['fee_type'];
$order->amount = round((float) $params['amount'], 2);
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
if (self::canEditOrderRemarkExtra($adminInfo)) {
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
}
$order->remark_assistant = (string) ($params['remark_assistant'] ?? '');
$diagId = (int) $order->diagnosis_id;
@@ -1104,6 +1384,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1182,6 +1463,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1250,6 +1532,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1328,6 +1611,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1466,6 +1750,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1552,6 +1837,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1622,6 +1908,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1738,6 +2025,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1848,6 +2136,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1935,6 +2224,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -2040,6 +2330,12 @@ class PrescriptionOrderLogic
if ((int) ($item['creator_id'] ?? 0) === $adminId) {
return true;
}
if (
self::canViewOrdersForOwnPrescription($adminInfo)
&& self::isPrescriptionOrderPrescriber((int) ($item['prescription_id'] ?? 0), $adminId)
) {
return true;
}
$did = (int) ($item['diagnosis_id'] ?? 0);
$aid = (int) ($assistantByDiag[$did] ?? 0);
@@ -2346,6 +2642,60 @@ class PrescriptionOrderLogic
];
}
/**
* 修改订单金额
*/
public static function updateAmount(array $params, int $adminId, array $adminInfo): bool
{
self::$error = '';
$id = (int) $params['id'];
$newAmount = round((float) $params['amount'], 2);
// 查询订单
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$order) {
self::setError('订单不存在');
return false;
}
// 数据权限检查
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::setError('无权限操作此订单');
return false;
}
// 状态检查:已完成(3)和已取消(4)不允许修改
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
self::setError('已完成或已取消的订单不允许修改金额');
return false;
}
$oldAmount = round((float) $order->amount, 2);
$oldAmountCents = (int) round($oldAmount * 100);
$newAmountCents = (int) round($newAmount * 100);
// 金额未变化
if ($newAmountCents === $oldAmountCents) {
self::setError('金额未发生变化');
return false;
}
// 更新金额
$order->amount = $newAmount;
try {
$order->save();
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
// 记录日志
$summary = sprintf('将订单金额从 ¥%.2f 修改为 ¥%.2f', $oldAmount, $newAmount);
self::writeLog($id, $adminId, $adminInfo, 'update_amount', $summary);
return true;
}
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
{
$adminName = $adminInfo['name'] ?? '';
@@ -0,0 +1,95 @@
<?php
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\tcm\TrackingNote;
/**
* 诊单跟踪备注 Logic
*
* - diagnosis_id + 当天 find-or-create
* - 多次追加 content(换行分隔,带 [HH:MM] 前缀)
* - 不涉及附件(与医生备注 DoctorNoteLogic 不同)
*/
class TrackingNoteLogic extends BaseLogic
{
/**
* 追加跟踪备注(按天合并)
*
* @param array $params diagnosis_id (int)、admin_id (int)、content (string)
* @return bool
*/
public static function addOrAppend(array $params): bool
{
try {
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
$adminId = (int) ($params['admin_id'] ?? 0);
$newContent = trim((string) ($params['content'] ?? ''));
if ($diagnosisId <= 0) {
self::setError('诊单ID缺失');
return false;
}
if ($newContent === '') {
self::setError('备注内容不能为空');
return false;
}
$today = date('Y-m-d');
$time = date('H:i');
$line = "[{$time}] {$newContent}";
$existing = TrackingNote::where('diagnosis_id', $diagnosisId)
->where('note_date', $today)
->whereNull('delete_time')
->find();
if ($existing) {
$prev = trim((string) ($existing->content ?? ''));
$existing->content = $prev !== '' ? ($prev . "\n" . $line) : $line;
$existing->save();
} else {
TrackingNote::create([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'note_date' => $today,
'content' => $line,
]);
}
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* diagnosis_id 获取跟踪备注列表(note_date DESC
*
* @param int $diagnosisId
* @param int $limit 最近 N
* @return array
*/
public static function getByDiagnosis(int $diagnosisId, int $limit = 60): array
{
try {
if ($diagnosisId <= 0) {
return [];
}
$records = TrackingNote::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('note_date', 'desc')
->limit($limit)
->select()
->toArray();
return $records;
} catch (\Exception $e) {
self::setError($e->getMessage());
return [];
}
}
}
@@ -22,6 +22,7 @@ use app\common\cache\AdminAccountSafeCache;
use app\common\service\ConfigService;
use app\common\validate\BaseValidate;
use think\facade\Config;
use think\facade\Log;
/**
* 登录验证
@@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate
*/
public function password($password, $other, $data)
{
// 本地调试:免密登录开关。须同时满足 APP_DEBUG=1 + LOCAL_DEBUG.LOGIN_SKIP_PASSWORD=true
// 任一不满足都走原始密码校验。每次免密登录都会记一条 warning 日志,便于发现误开。
if (self::isLocalDebugLoginBypassEnabled()) {
$adminInfo = Admin::where('account', '=', $data['account'])
->field(['password,disable'])
->findOrEmpty();
if ($adminInfo->isEmpty()) {
return '账号不存在';
}
if ((int)($adminInfo['disable'] ?? 0) === 1) {
return '账号已禁用';
}
Log::warning(sprintf(
'[LOCAL_DEBUG] login bypass password: account=%s, ip=%s',
(string)($data['account'] ?? ''),
(string)request()->ip()
));
return true;
}
// 登录限制
$config = [
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
@@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate
return true;
}
/**
* 是否启用"本地调试免密登录"
*
* 双重闸门:
* 1. APP_DEBUG 必须为 true(即应用本身处于调试模式)
* 2. LOCAL_DEBUG.LOGIN_SKIP_PASSWORD 必须显式为 true / 1 / yes / on
*
* 任何一条不满足都返回 false,强制走原始密码校验。这样即使误把
* LOGIN_SKIP_PASSWORD 配进生产,只要 APP_DEBUG=0 仍然安全。
*/
private static function isLocalDebugLoginBypassEnabled(): bool
{
$appDebug = strtolower((string) env('app_debug', '0'));
if (!in_array($appDebug, ['1', 'true', 'yes', 'on'], true)) {
return false;
}
$flag = strtolower((string) env('local_debug.login_skip_password', ''));
return in_array($flag, ['1', 'true', 'yes', 'on'], true);
}
}
@@ -0,0 +1,93 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\validate\tcm;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisTodo;
use app\common\validate\BaseValidate;
/**
* 诊单待办事项验证
* Class DiagnosisTodoValidate
* @package app\adminapi\validate\tcm
*/
class DiagnosisTodoValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|number',
'diagnosis_id' => 'require|number|gt:0|checkDiagnosis',
'content' => 'require|length:1,500',
'remind_time' => 'require|number|checkRemindFuture',
];
protected $message = [
'id.require' => '参数缺失',
'diagnosis_id.require' => '诊单 id 不能为空',
'diagnosis_id.gt' => '诊单 id 非法',
'content.require' => '请输入提醒内容',
'content.length' => '提醒内容长度需在 1-500 字',
'remind_time.require' => '请选择提醒时间',
'remind_time.number' => '提醒时间格式错误',
];
public function sceneAdd()
{
return $this->only(['diagnosis_id', 'content', 'remind_time']);
}
public function sceneCancel()
{
return $this->only(['id']);
}
public function sceneDetail()
{
return $this->only(['id']);
}
public function sceneList()
{
return $this->only(['diagnosis_id'])
->append('diagnosis_id', 'require|number|gt:0');
}
/**
* 诊单存在校验
*/
protected function checkDiagnosis($value): bool|string
{
$diagnosis = Diagnosis::findOrEmpty((int) $value);
if ($diagnosis->isEmpty()) {
return '诊单不存在';
}
return true;
}
/**
* 提醒时间必须在当前时间之后(至少 30 秒),避免立刻就过期
*/
protected function checkRemindFuture($value): bool|string
{
$ts = (int) $value;
if ($ts <= time() + 30) {
return '提醒时间必须晚于当前时间';
}
return true;
}
}
@@ -37,6 +37,10 @@ class DiagnosisValidate extends BaseValidate
'tongue_images' => 'array',
'report_files' => 'array',
'diabetes_discovery_year' => 'max:50',
'start_date' => 'date',
'end_date' => 'date|checkDateRange',
'diagnosis_id' => 'require|integer|checkDiagnosisId',
'tracking_content' => 'require|length:1,1000',
];
protected $message = [
@@ -55,11 +59,19 @@ class DiagnosisValidate extends BaseValidate
'status.in' => '状态参数错误',
'current_medications.max' => '在用药物最多2000个字符',
'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符',
'start_date.date' => '开始日期格式不正确',
'end_date.date' => '结束日期格式不正确',
'diagnosis_id.require' => '诊单ID不能为空',
'tracking_content.require' => '跟踪备注内容不能为空',
'tracking_content.length' => '跟踪备注最多1000个字符',
];
public function sceneAdd()
{
return $this->remove('id', true);
// 全局规则中含 tracking 相关字段,新增诊单不应校验诊单 ID
return $this->remove('id', true)
->remove('diagnosis_id', true)
->remove('tracking_content', true);
}
public function sceneEdit()
@@ -71,7 +83,29 @@ class DiagnosisValidate extends BaseValidate
{
return $this->only(['id']);
}
public function sceneReadonlyDetail()
{
return $this->only(['id']);
}
public function sceneTrackingWindow()
{
return $this->only(['id', 'start_date', 'end_date']);
}
/** 新增跟踪备注:诊单ID + 备注内容 */
public function sceneAddTrackingNote()
{
return $this->only(['diagnosis_id', 'tracking_content']);
}
/** 拉取跟踪备注列表:诊单ID */
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
@@ -107,6 +141,26 @@ class DiagnosisValidate extends BaseValidate
return true;
}
/** 校验 diagnosis_id 字段(addTrackingNote 等场景使用,避开 id 字段) */
protected function checkDiagnosisId($value)
{
$diagnosis = Diagnosis::findOrEmpty($value);
if ($diagnosis->isEmpty()) {
return '诊单不存在';
}
return true;
}
/** end_date 不能早于 start_date */
protected function checkDateRange($value, $rule, $data = [])
{
$start = $data['start_date'] ?? '';
if ($start && $value && strtotime($value) < strtotime($start)) {
return '结束日期不能早于开始日期';
}
return true;
}
/** 视频场景需 doctor_id,确认诊单需 diagnosis_id */
protected function checkQrcodeIds($value, $rule, $data = [])
{
@@ -29,6 +29,7 @@ class PrescriptionOrderValidate extends BaseValidate
'pay_amount' => 'require|float|gt:0',
'pay_remark' => 'max:200',
'remark_extra' => 'max:500',
'remark_assistant' => 'max:500',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
@@ -55,13 +56,13 @@ class PrescriptionOrderValidate extends BaseValidate
'create' => [
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'pay_order_ids',
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
],
'detail' => ['id'],
'edit' => [
'id', 'recipient_name', 'recipient_phone', 'shipping_address',
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'pay_order_ids',
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
],
'logisticsTrace' => ['id', 'phone_tail'],
'auditPrescription' => ['id', 'action', 'remark'],
@@ -76,5 +77,13 @@ class PrescriptionOrderValidate extends BaseValidate
'submitGancaoRecipel' => ['id'],
'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
'updateAmount' => ['id', 'amount'],
];
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])
->append('id', 'require|integer|gt:0')
->append('amount', 'require|float|gt:0');
}
}
@@ -12,6 +12,7 @@ class PrescriptionValidate extends BaseValidate
'id' => 'require',
'diagnosis_id' => 'number',
'appointment_id' => 'number',
'dosage_bag_count' => 'integer|between:1,5',
'patient_name' => 'require',
'clinical_diagnosis' => 'require',
'herbs' => 'require|array',
@@ -20,6 +21,8 @@ class PrescriptionValidate extends BaseValidate
];
protected $message = [
'dosage_bag_count.integer' => '用量袋数必须为整数',
'dosage_bag_count.between' => '用量袋数必须在1到5袋之间',
'patient_name.require' => '患者姓名不能为空',
'clinical_diagnosis.require' => '临床诊断不能为空',
'herbs.require' => '请添加中药',
@@ -28,7 +31,7 @@ class PrescriptionValidate extends BaseValidate
public function sceneAdd()
{
return $this->only([
'prescription_type', 'dosage_amount', 'dosage_unit', 'need_decoction', 'bags_per_dose',
'prescription_type', 'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'need_decoction', 'bags_per_dose',
'patient_name', 'gender', 'age',
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
@@ -41,7 +44,7 @@ class PrescriptionValidate extends BaseValidate
public function sceneEdit()
{
return $this->only([
'id', 'prescription_type', 'dosage_amount', 'dosage_unit', 'need_decoction', 'bags_per_dose',
'id', 'prescription_type', 'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'need_decoction', 'bags_per_dose',
'patient_name', 'gender', 'age',
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
+18 -1
View File
@@ -10,7 +10,7 @@ use think\facade\Db;
*/
class ChatController extends BaseApiController
{
public array $notNeedLogin = ['notifyOpen', 'logAvPermission'];
public array $notNeedLogin = ['notifyOpen', 'notifyClose', 'logAvPermission'];
/**
* 上报音视频权限拒绝日志
@@ -55,4 +55,21 @@ class ChatController extends BaseApiController
ChatNotifyLogic::addNotify($doctorId, $patientId ?: '0', $patientName);
return $this->success('已通知');
}
/**
* 患者离开会话页时通知医生(管理后台轮询 + 可与 IM 信令配合)
*/
public function notifyClose()
{
$doctorId = (int) ($this->request->post('doctor_id') ?: $this->request->post('doctorId'));
$patientId = trim((string) ($this->request->post('patient_id') ?: $this->request->post('patientId') ?: ''));
$patientName = trim((string) ($this->request->post('patient_name') ?: $this->request->post('patientName') ?: '患者'));
if ($doctorId <= 0) {
return $this->fail('医生ID无效');
}
ChatNotifyLogic::addPatientLeftNotify($doctorId, $patientId ?: '0', $patientName);
return $this->success('已通知');
}
}
+24
View File
@@ -43,6 +43,30 @@ class ChatNotifyLogic extends BaseLogic
return true;
}
/**
* 患者离开会话页/诊室时通知医生端(管理后台轮询)
*/
public static function addPatientLeftNotify(int $doctorId, string $patientId, string $patientName): bool
{
$key = self::CACHE_PREFIX . $doctorId;
$item = [
'id' => uniqid('', true),
'type' => 'patient_left_chat',
'doctor_id' => $doctorId,
'patient_id' => $patientId,
'patient_name' => $patientName,
'created_at' => time(),
];
$list = Cache::get($key) ?: [];
if (!is_array($list)) {
$list = [];
}
array_unshift($list, $item);
$list = array_slice($list, 0, self::MAX_PER_DOCTOR);
Cache::set($key, $list, self::CACHE_TTL);
return true;
}
/**
* 添加面诊结束通知(医生完成接诊后通知对应医助)
* @param int $assistantId 医助ID (admin_id)
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\model\auth\Admin;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisTodo;
use app\common\service\wechat\WechatWorkAppMessageService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Db;
use think\facade\Log;
/**
* 诊单待办事项 - 企业微信定时推送
*
* 使用方法:
* php think tcm:diagnosis-todo-notify
*
* 推荐 cron(每分钟执行):
* * * * * * cd /path/to/server && php think tcm:diagnosis-todo-notify >> /var/log/zyt-todo.log 2>&1
*
* 行为:
* 1. 取出 status=0 remind_time<=now 的待办,按 remind_time asc,限 200
* 2. 每条用乐观锁 (update set status=1 where id=? and status=0) 抢占,避免并发重复推送
* 3. 找创建人 admin.work_wechat_userid,未绑定 status=3 + error
* 4. WechatWorkAppMessageService::sendTextToUser,失败 status=3 + error(终态,不重试)
* 5. 整批不中断;输出 处理 N / 成功 X / 失败 Y / 跳过未绑定 Z
*/
class DiagnosisTodoNotify extends Command
{
/** 单次扫描最大条数 */
private const BATCH_LIMIT = 200;
protected function configure()
{
$this->setName('tcm:diagnosis-todo-notify')
->setDescription('诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息');
}
protected function execute(Input $input, Output $output): int
{
$startTs = microtime(true);
$now = time();
$output->writeln('[' . date('Y-m-d H:i:s', $now) . '] 开始扫描诊单待办事项...');
$totalProcessed = 0;
$totalSent = 0;
$totalFailed = 0;
$totalSkipped = 0; // 被并发抢占
$totalUnbound = 0; // 创建人未绑定企微(计入 failed)
try {
$todoTable = (new DiagnosisTodo())->getTable();
$rows = Db::name(self::stripTablePrefix($todoTable))
->where('status', DiagnosisTodo::STATUS_PENDING)
->where('remind_time', '<=', $now)
->whereNull('delete_time')
->order('remind_time', 'asc')
->limit(self::BATCH_LIMIT)
->select()
->toArray();
$output->writeln('待处理数量:' . count($rows));
foreach ($rows as $row) {
$totalProcessed++;
$todoId = (int) ($row['id'] ?? 0);
if ($todoId <= 0) {
continue;
}
try {
// 乐观锁:抢占成功时影响 1 行;并发场景下另一个进程已抢走则影响 0 行 → 跳过
$affected = Db::name(self::stripTablePrefix($todoTable))
->where('id', $todoId)
->where('status', DiagnosisTodo::STATUS_PENDING)
->update([
'status' => DiagnosisTodo::STATUS_SENT,
'notified_at' => $now,
'update_time' => $now,
]);
if ($affected <= 0) {
$totalSkipped++;
continue;
}
// 取创建人 work_wechat_userid
$creatorId = (int) ($row['creator_id'] ?? 0);
$wxId = '';
if ($creatorId > 0) {
$wxId = (string) Admin::where('id', $creatorId)->value('work_wechat_userid');
}
if ($wxId === '') {
$this->markFailed($todoTable, $todoId, '创建人未绑定企业微信 userid');
$totalUnbound++;
$totalFailed++;
continue;
}
// 拼推送文本
$text = $this->buildText($row);
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
if (!($res['ok'] ?? false)) {
$errMsg = (string) ($res['message'] ?? '企业微信推送失败');
$this->markFailed($todoTable, $todoId, $errMsg);
$totalFailed++;
continue;
}
$totalSent++;
} catch (\Throwable $e) {
Log::error('诊单待办推送异常 todo_id=' . $todoId . ' msg=' . $e->getMessage());
try {
$this->markFailed($todoTable, $todoId, '系统异常: ' . $e->getMessage());
} catch (\Throwable $ee) {
Log::error('回写失败状态出错 todo_id=' . $todoId . ' msg=' . $ee->getMessage());
}
$totalFailed++;
}
}
} catch (\Throwable $e) {
Log::error('诊单待办批处理异常: ' . $e->getMessage());
$output->error('批处理异常: ' . $e->getMessage());
return 1;
}
$duration = round(microtime(true) - $startTs, 3);
$output->writeln(sprintf(
'处理完成。总数: %d, 成功: %d, 失败: %d (其中未绑定企微: %d), 并发跳过: %d, 耗时: %ss',
$totalProcessed,
$totalSent,
$totalFailed,
$totalUnbound,
$totalSkipped,
$duration
));
return 0;
}
/**
* 拼接推送文本
*
* @param array<string,mixed> $row 待办记录原始行
*/
private function buildText(array $row): string
{
$patientName = '';
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
if ($diagnosisId > 0) {
$patientName = (string) Diagnosis::where('id', $diagnosisId)->value('patient_name');
}
$remindAt = (int) ($row['remind_time'] ?? 0);
$content = trim((string) ($row['content'] ?? ''));
$lines = [
'【患者跟踪提醒】',
'患者:' . ($patientName !== '' ? $patientName : '-'),
'时间:' . ($remindAt > 0 ? date('Y-m-d H:i', $remindAt) : '-'),
'内容:' . ($content !== '' ? $content : '-'),
'— 二中心跟踪系统',
];
return implode("\n", $lines);
}
/**
* 标记一条待办为「发送失败」,写入 error 字段
*/
private function markFailed(string $todoTable, int $todoId, string $errMsg): void
{
$errMsg = mb_substr($errMsg, 0, 500);
Db::name(self::stripTablePrefix($todoTable))
->where('id', $todoId)
->update([
'status' => DiagnosisTodo::STATUS_FAILED,
'error' => $errMsg,
'update_time' => time(),
]);
}
/**
* Db::name() 接收的是不带前缀的表名,BloodRecord Model::getTable() 返回的是带前缀的全名。
* 这里去掉首段 `<prefix>_`。注意:项目实际前缀是 `zyt_`,但 think-orm Db::name()
* 内部会用 config('database.prefix') 自动拼回,所以这里只需剥离一次即可。
*/
private static function stripTablePrefix(string $tableWithPrefix): string
{
$prefix = (string) config('database.connections.mysql.prefix', '');
if ($prefix !== '' && str_starts_with($tableWithPrefix, $prefix)) {
return substr($tableWithPrefix, strlen($prefix));
}
return $tableWithPrefix;
}
}
+9 -5
View File
@@ -15,8 +15,11 @@ use think\console\Output;
* 使用方法:
* php think express:auto-update
*
* 配置crontab(每10分钟执行一次):
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关)
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
*
* 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
*/
class ExpressAutoUpdate extends Command
{
@@ -33,7 +36,8 @@ class ExpressAutoUpdate extends Command
$startTime = microtime(true);
try {
$result = ExpressTrackingService::autoUpdateBatch(200);
$result = ExpressTrackingService::autoUpdateBatch(500);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(500);
$duration = round(microtime(true) - $startTime, 2);
@@ -41,9 +45,9 @@ class ExpressAutoUpdate extends Command
$output->writeln("总数: {$result['total']}");
$output->writeln("成功: {$result['success']}");
$output->writeln("失败: {$result['failed']}");
$output->writeln("医助已移除: " . (int) ($result['assistant_cleared'] ?? 0));
$output->writeln("医助保留(有指派记录): " . (int) ($result['assistant_skipped_assign_log'] ?? 0));
$lines = $result['assistant_lines'] ?? [];
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
$output->writeln("医助已移除(履约已发货/签收核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
if ($lines === []) {
$output->writeln('医助明细: (无)');
} else {
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\command;
use app\common\service\ExpressTrackingService;
use app\common\service\gancao\GancaoLogisticsRouteService;
use app\common\service\gancao\GancaoScmRecipelService;
use think\console\Command;
@@ -27,6 +28,9 @@ use think\console\Output;
*
* 建议 crontab(每 30 分钟执行一次):
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> runtime/log/gancao_sync.log 2>&1
*
* 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
*/
class GancaoSyncLogisticsRoute extends Command
{
@@ -60,6 +64,10 @@ class GancaoSyncLogisticsRoute extends Command
try {
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(200);
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
$stats['reconcile_lines'] = $recon['lines'] ?? [];
} catch (\Throwable $e) {
$output->error('同步异常:' . $e->getMessage());
return 1;
@@ -94,9 +102,9 @@ class GancaoSyncLogisticsRoute extends Command
$output->writeln('总数:' . $stats['total']);
$output->writeln('成功:' . $stats['success']);
$output->writeln('失败:' . $stats['failed']);
$output->writeln('医助已移除:' . (int) ($stats['assistant_cleared'] ?? 0));
$output->writeln('医助保留(有指派记录)' . (int) ($stats['assistant_skipped_assign_log'] ?? 0));
$lines = $stats['assistant_lines'] ?? [];
$output->writeln('医助已移除(甘草同步+指派日志)' . (int) ($stats['assistant_cleared'] ?? 0));
$output->writeln('医助已移除(履约已发货/签收核对)' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
if ($lines === []) {
$output->writeln('医助明细:(无)');
} else {
+13 -4
View File
@@ -47,10 +47,15 @@ trait ListsSearchTrait
case 'in':
foreach ($whereFields as $whereField) {
$paramsName = substr_symbol_behind($whereField);
if (!isset($this->params[$paramsName]) || $this->params[$paramsName] == '') {
if (!isset($this->params[$paramsName])) {
continue;
}
$where[] = [$whereField, $whereType, $this->params[$paramsName]];
$paramVal = $this->params[$paramsName];
// 勿用 == ''PHP 中 0 == '' 为 true,会误跳过 prescription_audit_status=0 等合法筛选
if ($paramVal === '' || $paramVal === null) {
continue;
}
$where[] = [$whereField, $whereType, $paramVal];
}
break;
case '%like%':
@@ -95,10 +100,14 @@ trait ListsSearchTrait
case 'find_in_set': // find_in_set查询
foreach ($whereFields as $whereField) {
$paramsName = substr_symbol_behind($whereField);
if (!isset($this->params[$paramsName]) || $this->params[$paramsName] == '') {
if (!isset($this->params[$paramsName])) {
continue;
}
$where[] = [$whereField, 'find in set', $this->params[$paramsName]];
$paramVal = $this->params[$paramsName];
if ($paramVal === '' || $paramVal === null) {
continue;
}
$where[] = [$whereField, 'find in set', $paramVal];
}
break;
}
@@ -0,0 +1,99 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单待办事项模型
*
* 状态机:0=待执行 1=已发送 / 2=已取消 / 3=发送失败(终态,不重试)
*
* @package app\common\model\tcm
*/
class DiagnosisTodo extends BaseModel
{
protected $name = 'tcm_diagnosis_todo';
// 自动时间戳类型设置为整型
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $deleteTime = 'delete_time';
// 取出字段保持整型,前端按需要再格式化
protected $dateFormat = false;
/** 状态:待执行 */
public const STATUS_PENDING = 0;
/** 状态:已发送(成功) */
public const STATUS_SENT = 1;
/** 状态:已取消(人工) */
public const STATUS_CANCELLED = 2;
/** 状态:发送失败(终态,不重试) */
public const STATUS_FAILED = 3;
/**
* 状态文本映射
*
* @var array<int,string>
*/
public const STATUS_TEXT_MAP = [
self::STATUS_PENDING => '待执行',
self::STATUS_SENT => '已发送',
self::STATUS_CANCELLED => '已取消',
self::STATUS_FAILED => '发送失败',
];
/**
* @notes 状态文本访问器
*/
public function getStatusTextAttr($value, $data): string
{
$status = (int) ($data['status'] ?? 0);
return self::STATUS_TEXT_MAP[$status] ?? '未知';
}
/**
* @notes 提醒时间格式化(Y-m-d H:i
*/
public function getRemindTimeTextAttr($value, $data): string
{
$ts = (int) ($data['remind_time'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/**
* @notes 推送时间格式化
*/
public function getNotifiedAtTextAttr($value, $data): string
{
$ts = (int) ($data['notified_at'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/**
* @notes 取消时间格式化
*/
public function getCancelledAtTextAttr($value, $data): string
{
$ts = (int) ($data['cancelled_at'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
}
@@ -24,6 +24,7 @@ class Prescription extends BaseModel
// 字段类型转换
protected $type = [
'dosage_amount' => 'float',
'dosage_bag_count' => 'integer',
'need_decoction' => 'integer',
];
@@ -0,0 +1,21 @@
<?php
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单跟踪备注模型
* zyt_tracking_note:按天一条,多次追加更新;只存文字。
*/
class TrackingNote extends BaseModel
{
protected $name = 'tracking_note';
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $deleteTime = 'delete_time';
protected $dateFormat = false;
}
@@ -19,7 +19,8 @@ use think\facade\Config;
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
* - SELF (4) = 仅本人
*
* 多角色时取「范围最大」= data_scope 最小值。
* 多角色时取「最严格」可见范围 = data_scope 最大值(1=全部 4=仅本人),
* 与常见「数据权限取交集」一致,避免挂了一个「全部」角色就把其它角色的部门范围冲掉。
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
*
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 nullALL = 不过滤)。
@@ -42,10 +43,7 @@ class DataScopeService
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return self::SCOPE_ALL;
}
$roleIds = array_values(array_filter(array_map(
'intval',
is_array($adminInfo['role_id'] ?? null) ? $adminInfo['role_id'] : []
)));
$roleIds = self::normalizeRoleIds($adminInfo['role_id'] ?? null);
$exempt = array_map('intval', Config::get('project.data_scope.exempt_roles', []) ?: []);
if ($roleIds !== [] && array_intersect($roleIds, $exempt) !== []) {
return self::SCOPE_ALL;
@@ -59,11 +57,50 @@ class DataScopeService
$scopes = array_values(array_filter(array_map('intval', $scopes), static function (int $v): bool {
return $v >= self::SCOPE_ALL && $v <= self::SCOPE_SELF;
}));
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到「仅本人」,避免误放开到全站
if ($scopes === []) {
return self::SCOPE_ALL;
return self::SCOPE_SELF;
}
return (int) min($scopes);
return (int) max($scopes);
}
/**
* 统一解析 token/cache 中的 role_id(数组 | 单整数 | JSON 字符串)。
*
* @return int[]
*/
private static function normalizeRoleIds(mixed $raw): array
{
if ($raw === null || $raw === '') {
return [];
}
if (\is_int($raw) || \is_float($raw)) {
$v = (int) $raw;
return $v > 0 ? [$v] : [];
}
if (\is_string($raw) && is_numeric($raw)) {
$v = (int) $raw;
return $v > 0 ? [$v] : [];
}
if (\is_string($raw)) {
$decoded = json_decode($raw, true);
if (\is_array($decoded)) {
$raw = $decoded;
} else {
return [];
}
}
if (!\is_array($raw)) {
return [];
}
return array_values(array_filter(array_map(
static fn ($v): int => (int) $v,
$raw
), static fn (int $v): bool => $v > 0));
}
/**
@@ -133,6 +170,43 @@ class DataScopeService
return self::getEffectiveScope($adminInfo) === self::SCOPE_ALL;
}
/**
* 数据范围下:可见成员所在部门及其下级部门 id(与业绩看板 deptOptions、部门类下拉收窄一致)。
*
* @return array<int, true>|null null 表示不限制;[] 表示无可选部门
*/
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
{
if ($adminId <= 0 || !self::isEnabled()) {
return null;
}
$visibleIds = self::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === null) {
return null;
}
if ($visibleIds === []) {
return [];
}
$set = [];
foreach ($visibleIds as $aid) {
$deptRows = AdminDept::where('admin_id', (int) $aid)->column('dept_id');
foreach ($deptRows as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
$x = (int) $x;
if ($x > 0) {
$set[$x] = true;
}
}
}
}
return $set;
}
/**
* 文字描述(日志 / 接口返回可选使用)
*/
@@ -0,0 +1,150 @@
<?php
namespace app\common\service;
use app\common\enum\FileEnum;
use app\common\model\file\File;
use app\common\service\storage\engine\Qcloud as QcloudEngine;
use Exception;
/**
* 浏览器直传 OSS 服务
* - 目前仅支持腾讯云 COSqcloud
* - 其他 driver 返回 fallback=true,由前端降级到老链路
*
* Class DirectUploadService
* @package app\common\service
*/
class DirectUploadService
{
/** 视频允许的扩展名(沿用 config/project.file_video */
public const TYPE_VIDEO = 'video';
/** 默认凭证有效期 30 分钟 */
public const DEFAULT_DURATION = 1800;
/** 允许扩展类型 → 大小上限(字节) */
private const MAX_SIZE = [
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
];
/**
* @notes 签发临时凭证
* @param string $type
* @return array
* @throws Exception
*/
public static function issueCredentials(string $type): array
{
if (!isset(self::MAX_SIZE[$type])) {
throw new Exception('不支持的上传类型: ' . $type);
}
$default = ConfigService::get('storage', 'default', 'local');
if ($default !== 'qcloud') {
// 其他 driver 不支持直传,前端降级
return ['provider' => $default, 'fallback' => true];
}
$storageConfig = ConfigService::get('storage', 'qcloud');
if (empty($storageConfig['bucket']) || empty($storageConfig['region'])
|| empty($storageConfig['access_key']) || empty($storageConfig['secret_key'])) {
throw new Exception('腾讯云 COS 配置不完整');
}
$keyPrefix = self::buildKeyPrefix($type);
$engine = new QcloudEngine($storageConfig);
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
return [
'provider' => 'qcloud',
'fallback' => false,
'bucket' => $sts['bucket'],
'region' => $sts['region'],
'host' => $sts['host'],
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
'key_prefix' => $keyPrefix,
'max_size' => self::MAX_SIZE[$type],
'duration' => self::DEFAULT_DURATION,
'expired_time' => $sts['expiredTime'],
'start_time' => $sts['startTime'],
'credentials' => $sts['credentials'],
];
}
/**
* @notes 直传完成后的回执:HEAD 校验 + file
* @param array $params {key, type, name, size, content_type, cid, admin_id}
* @return array {id, cid, type, name, uri, url}
* @throws Exception
*/
public static function confirm(array $params): array
{
$type = (string)($params['type'] ?? '');
if (!isset(self::MAX_SIZE[$type])) {
throw new Exception('不支持的上传类型');
}
$default = ConfigService::get('storage', 'default', 'local');
if ($default !== 'qcloud') {
throw new Exception('当前存储驱动不支持直传回执');
}
$key = ltrim((string)($params['key'] ?? ''), '/');
$allowedPrefix = self::buildKeyPrefix($type);
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
throw new Exception('对象 Key 非法');
}
$storageConfig = ConfigService::get('storage', 'qcloud');
$engine = new QcloudEngine($storageConfig);
$head = $engine->headObject($key);
if ($head === false) {
throw new Exception('对象未找到,请确认上传是否完成');
}
if ($head['size'] <= 0 || $head['size'] > self::MAX_SIZE[$type]) {
throw new Exception('文件大小超出限制');
}
$name = trim((string)($params['name'] ?? ''));
if ($name === '') {
$name = basename($key);
}
if (strlen($name) > 128) {
$name = substr($name, 0, 123) . substr($name, -5);
}
$file = File::create([
'cid' => (int)($params['cid'] ?? 0),
'type' => self::resolveFileType($type),
'name' => $name,
'uri' => $key,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => (int)($params['admin_id'] ?? 0),
'create_time' => time(),
]);
$url = FileService::getFileUrl($key);
return [
'id' => $file['id'],
'cid' => $file['cid'],
'type' => $file['type'],
'name' => $file['name'],
'uri' => $url,
'url' => $url,
];
}
private static function buildKeyPrefix(string $type): string
{
return 'uploads/' . $type . '/' . date('Ymd') . '/';
}
private static function resolveFileType(string $type): int
{
return match ($type) {
self::TYPE_VIDEO => FileEnum::VIDEO_TYPE,
default => FileEnum::FILE_TYPE,
};
}
}
@@ -4,14 +4,19 @@ declare(strict_types=1);
namespace app\common\service;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\model\ExpressTracking;
use app\common\model\ExpressTrace;
use app\common\model\ExpressStateLog;
use app\common\model\ExpressQueryLog;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use think\facade\Db;
use think\facade\Log;
/**
@@ -176,7 +181,7 @@ class ExpressTrackingService
self::logStateChange($tracking, $oldState);
}
// 定时拉轨迹:业务订单已发货时释放诊单医助(有正式指派记录则跳过,与结案逻辑一致)
// 定时拉轨迹:履约已发货/已签收时与甘草侧共用 applyAssistantReleaseForShippedPrescriptionOrder,释放诊单医助并写指派日志
$assistantSync = null;
if ($isAuto) {
$assistantSync = self::maybeClearDiagnosisAssistantWhenShippedPrescription($tracking);
@@ -242,20 +247,21 @@ class ExpressTrackingService
}
/**
* 业务订单已发货(5)/已签收(6)清空关联诊单医助;存在 tcm_diagnosis_assign_log 则跳过
* express:auto-update、gancao:sync-logistics、甘草回调等共用
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则
* 例外:指派日志中若存在「从某医助改派到另一医助」(原、新医助 ID 均大于 0)的记录,视为人工指派链,自动释放跳过。
* 例外:患者当前医助在「二中心」及其全部子部门(与后台部门树一致)下任职时,不自动清空医助(按 admin_dept dept 树判定)。
* 非二中心且已自动释放:诊单写入 shipped_non_er_assistant_cleared_atassistant_id 已为 0 时后续履约核对不再重复扫描(重新指派医助后可再次处理)。
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
* action: 'cleared'|'skipped_assign_log',
* action: 'cleared',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
*/
@@ -284,52 +290,114 @@ class ExpressTrackingService
if (!$diag) {
return null;
}
$oldAssistant = (string) ($diag->getAttr('assistant_id') ?? '');
if ($oldAssistant === '' || $oldAssistant === '0') {
$shippedClearedAt = (int) ($diag->getAttr('shipped_non_er_assistant_cleared_at') ?? 0);
$diagAssistantIdNow = (int) ($diag->getAttr('assistant_id') ?? 0);
if ($shippedClearedAt > 0 && $diagAssistantIdNow === 0) {
return null;
}
// 诊单 assistant_id 与列表「医助」筛选一致:医助常代建单但诊单未写 assistant_id,此时用创建人作为待释放身份(创建人即开方医生时不采用,避免误记指派日志)
$fromAssistantId = (int) ($diag->getAttr('assistant_id') ?? 0);
if ($fromAssistantId <= 0) {
$creatorId = (int) ($order->getAttr('creator_id') ?? 0);
if ($creatorId > 0) {
$rxId = (int) ($order->getAttr('prescription_id') ?? 0);
$rxCreator = 0;
if ($rxId > 0) {
$rxCreator = (int) Prescription::where('id', $rxId)->whereNull('delete_time')->value('creator_id');
}
if ($rxId <= 0 || $creatorId !== $rxCreator) {
$fromAssistantId = $creatorId;
}
}
}
if ($fromAssistantId <= 0) {
return null;
}
$assignLogCount = (int) DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->count();
if ($assignLogCount > 0) {
return [
'action' => 'skipped_assign_log',
'source' => $source,
'diagnosis_id' => $diagnosisId,
if (self::diagnosisHasFormalAssistantReassignInAssignLog($diagnosisId)) {
Log::info('Skipped auto assistant release (shipped/signed): formal assistant reassign in assign log', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'assistant_id' => $oldAssistant,
'assign_log_count' => $assignLogCount,
'fulfillment_status' => $fs,
];
'source' => $source,
'current_assistant_id' => $fromAssistantId,
]);
return null;
}
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => '']);
if (self::currentAssistantBelongsToErzhongxinDeptTree($fromAssistantId)) {
Log::info('Skipped auto assistant release (shipped/signed): assistant under 二中心 dept tree', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
'current_assistant_id' => $fromAssistantId,
]);
Log::info('Cleared diagnosis assistant (shipped/signed prescription order)', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $oldAssistant,
return null;
}
$operatorName = match ($source) {
'express_auto_update' => '系统·物流自动同步',
'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步',
'gancao_callback' => '系统·甘草回调',
'shipped_fulfillment_reconcile' => '系统·已发货履约核对',
default => '系统·订单已发货/签收',
};
$relatedPoCreatorId = (int) ($order->getAttr('creator_id') ?? 0);
$relatedPoCreateTime = (int) ($order->getAttr('create_time') ?? 0);
Db::startTrans();
try {
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update([
'assistant_id' => 0,
'shipped_non_er_assistant_cleared_at' => time(),
]);
DiagnosisAssignLog::create([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => 0,
'operator_admin_id' => 0,
'operator_name' => $operatorName,
'operator_account' => '',
'ip' => '',
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'create_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
Log::info('Cleared diagnosis assistant (prescription shipped/signed), assign log written', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $fromAssistantId,
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'assistant_created_order' => ($relatedPoCreatorId === $fromAssistantId && $relatedPoCreateTime > 0) ? 1 : 0,
]);
return [
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => $oldAssistant,
'fulfillment_status' => $fs,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => (string) $fromAssistantId,
'fulfillment_status' => $fs,
];
} catch (\Throwable $e) {
Log::warning('applyAssistantReleaseForShippedPrescriptionOrder failed: ' . $e->getMessage(), [
'source' => $source,
'source' => $source,
'order_id' => $orderId,
]);
@@ -338,19 +406,68 @@ class ExpressTrackingService
}
/**
* @return array{
* action: 'cleared'|'skipped_assign_log',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
* 部门名称为「二中心」的根节点 id(启用、未删除的首条),无则 0
* 与后台 adminapi 部门数据源一致(表 zyt_dept,树由 pid 构成)。
*/
private static function getErzhongxinRootDeptId(): int
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
$id = (int) Dept::where('name', '二中心')
->where('status', 1)
->whereNull('delete_time')
->order('id', 'asc')
->value('id');
$cached = $id > 0 ? $id : 0;
return $cached;
}
/**
* 诊单当前医助(管理员)是否归属「二中心」或其任意下级部门(admin_dept.dept_id 落在该子树内)。
* DeptLogic::getSelfAndDescendantIds 行为对齐于部门列表接口的树展开。
*/
private static function currentAssistantBelongsToErzhongxinDeptTree(int $assistantAdminId): bool
{
if ($assistantAdminId <= 0) {
return false;
}
$rootId = self::getErzhongxinRootDeptId();
if ($rootId <= 0) {
return false;
}
$treeIds = DeptLogic::getSelfAndDescendantIds($rootId);
if ($treeIds === []) {
return false;
}
$allowed = array_flip(array_map('intval', $treeIds));
$deptIds = AdminDept::where('admin_id', $assistantAdminId)->column('dept_id');
foreach ($deptIds as $did) {
if (isset($allowed[(int) $did])) {
return true;
}
}
return false;
}
/**
* 是否存在「由原医助改派到新医助」的指派记录(两端 admin 均大于 0)。有此记录则已发货自动释放医助不执行。
*/
private static function diagnosisHasFormalAssistantReassignInAssignLog(int $diagnosisId): bool
{
if ($diagnosisId <= 0) {
return false;
}
return DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->where('from_assistant_id', '>', 0)
->where('to_assistant_id', '>', 0)
->count() > 0;
}
private static function maybeClearDiagnosisAssistantWhenShippedPrescription(ExpressTracking $tracking): ?array
{
if ((string) $tracking->order_type !== 'prescription') {
@@ -368,14 +485,14 @@ class ExpressTrackingService
}
return self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_id' => (int) $tracking->id,
'tracking_id' => (int) $tracking->id,
'tracking_number' => (string) $tracking->tracking_number,
'source' => 'express_auto_update',
'source' => 'express_auto_update',
]);
} catch (\Throwable $e) {
Log::warning('maybeClearDiagnosisAssistantWhenShippedPrescription failed: ' . $e->getMessage(), [
'tracking_id' => (int) $tracking->id,
'order_id' => $orderId,
'order_id' => $orderId,
]);
return null;
@@ -528,6 +645,64 @@ class ExpressTrackingService
$log->save();
}
/**
* 按处方订单履约核对:已发货(5)/已签收(6) 时诊单仍挂在医助的,一律释放并写指派日志。
* 与快递 100 / 甘草来自哪个物流渠道无关,保障「已发货」后患者侧医助被清掉。
* 已对非二中心医助做过发货/签收释放且 assistant_id=0 的诊单(shipped_non_er_assistant_cleared_at>0)不再纳入本扫描,避免重复命中 creator 分支。
*
* @return array{scanned: int, cleared: int, lines: list<string>}
*/
public static function reconcileAssistantReleaseForShippedPrescriptionOrders(int $limit = 200): array
{
$dTable = (new Diagnosis())->getTable();
$rxTable = (new Prescription())->getTable();
$orders = PrescriptionOrder::alias('po')
->join($dTable . ' d', 'd.id = po.diagnosis_id')
->leftJoin($rxTable . ' rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL')
->whereNull('po.delete_time')
->whereNull('d.delete_time')
->whereRaw('(IFNULL(d.shipped_non_er_assistant_cleared_at, 0) = 0 OR d.assistant_id > 0)')
->whereIn('po.fulfillment_status', [5, 6])
->where('po.diagnosis_id', '>', 0)
->where(function ($w) {
$w->where('d.assistant_id', '>', 0)
->whereOr(function ($w2) {
$w2->where('po.creator_id', '>', 0)
->whereRaw('(rx.id IS NULL OR po.creator_id <> rx.creator_id)');
});
})
->order('po.id', 'asc')
->limit($limit)
->field('po.*')
->select();
$cleared = 0;
$lines = [];
foreach ($orders as $order) {
$sync = self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'source' => 'shipped_fulfillment_reconcile',
'tracking_number' => (string) ($order->tracking_number ?? ''),
]);
if (is_array($sync) && ($sync['action'] ?? '') === 'cleared') {
$cleared++;
$lines[] = sprintf(
'[履约核对·已发货/签收] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
return [
'scanned' => count($orders),
'cleared' => $cleared,
'lines' => $lines,
];
}
/**
* 批量自动更新(定时任务调用)
*
@@ -539,7 +714,7 @@ class ExpressTrackingService
* assistant_cleared: int,
* assistant_skipped_assign_log: int,
* assistant_lines: list<string>
* }
* } assistant_skipped_assign_log 保留兼容恒为 0(已发货/签收一律写指派日志并清空医助)
*/
public static function autoUpdateBatch(int $limit = 50): array
{
@@ -580,7 +755,6 @@ class ExpressTrackingService
$success = 0;
$failed = 0;
$assistantCleared = 0;
$assistantSkippedAssign = 0;
$assistantLines = [];
foreach ($list as $tracking) {
@@ -593,24 +767,13 @@ class ExpressTrackingService
if ($act === 'cleared') {
$assistantCleared++;
$assistantLines[] = sprintf(
'[移除医助] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
'[移除医助+指派日志] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
} elseif ($act === 'skipped_assign_log') {
$assistantSkippedAssign++;
$assistantLines[] = sprintf(
'[保留医助] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} catch (\Throwable $e) {
@@ -628,7 +791,7 @@ class ExpressTrackingService
'success' => $success,
'failed' => $failed,
'assistant_cleared' => $assistantCleared,
'assistant_skipped_assign_log' => $assistantSkippedAssign,
'assistant_skipped_assign_log' => 0,
'assistant_lines' => $assistantLines,
];
}
@@ -484,24 +484,13 @@ final class GancaoLogisticsRouteService
if ($act === 'cleared') {
$stats['assistant_cleared']++;
$stats['assistant_lines'][] = sprintf(
'[移除医助][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
'[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
} elseif ($act === 'skipped_assign_log') {
$stats['assistant_skipped_assign_log']++;
$stats['assistant_lines'][] = sprintf(
'[保留医助][甘草路由] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} else {
@@ -4,6 +4,7 @@ namespace app\common\service\storage\engine;
use Exception;
use Qcloud\Cos\Client;
use QCloud\COSSTS\Sts;
/**
* 腾讯云存储引擎 (COS)
@@ -113,4 +114,125 @@ class Qcloud extends Server
return $this->fileName;
}
/**
* @notes 获取 STS 临时凭证(用于浏览器直传)
* @param string $keyPrefix 资源前缀,如 uploads/video/20260508/
* @param int $maxSizeBytes 单文件大小上限(字节)
* @param int $durationSeconds 凭证有效期(秒)
* @return array {credentials, expiredTime, requestId}
* @throws Exception
*/
public function getStsCredentials(string $keyPrefix, int $maxSizeBytes, int $durationSeconds = 1800): array
{
$bucket = $this->config['bucket'];
// bucket 形如 likeadmin-1300000000appId 即末段
$appId = '';
if (strpos($bucket, '-') !== false) {
$parts = explode('-', $bucket);
$appId = end($parts);
}
if (!$appId || !ctype_digit($appId)) {
throw new Exception('腾讯云 COS bucket 名称不合法,无法解析 appId(期望形如 name-1300000000');
}
$shortBucket = substr($bucket, 0, strrpos($bucket, '-'));
$region = $this->config['region'];
$prefix = ltrim($keyPrefix, '/');
if ($prefix === '' || substr($prefix, -1) !== '/') {
$prefix = $prefix . '/';
}
$duration = max(900, min($durationSeconds, 7200));
// 自行构造 policy:对象级写动作收紧 + bucket 级 ListMultipartUploadscos-js-sdk-v5 续传探测必需)
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s*', $region, $appId, $bucket, $prefix);
$bucketArn = sprintf('qcs::cos:%s:uid/%s:%s/*', $region, $appId, $bucket);
$policy = [
'version' => '2.0',
'statement' => [
[
'effect' => 'allow',
'action' => [
'name/cos:PostObject',
'name/cos:PutObject',
'name/cos:InitiateMultipartUpload',
'name/cos:UploadPart',
'name/cos:CompleteMultipartUpload',
'name/cos:AbortMultipartUpload',
'name/cos:ListParts',
],
'resource' => [$objectArn],
'condition' => [
'numeric_less_than_equal' => [
'cos:content-length' => $maxSizeBytes,
],
],
],
[
// ListMultipartUploads 是 bucket 级操作,无法用对象级 ARN 命中
'effect' => 'allow',
'action' => ['name/cos:ListMultipartUploads'],
'resource' => [$bucketArn],
],
],
];
$config = [
'url' => 'https://sts.tencentcloudapi.com/',
'domain' => 'sts.tencentcloudapi.com',
'proxy' => '',
'secretId' => $this->config['access_key'],
'secretKey' => $this->config['secret_key'],
'bucket' => $bucket,
'region' => $region,
'durationSeconds' => $duration,
'policy' => $policy,
];
$sts = new Sts();
$result = $sts->getTempKeys($config);
if (!is_array($result) || empty($result['credentials'])) {
throw new Exception('获取 STS 临时凭证失败');
}
return [
'credentials' => [
'tmpSecretId' => $result['credentials']['tmpSecretId'] ?? '',
'tmpSecretKey' => $result['credentials']['tmpSecretKey'] ?? '',
'sessionToken' => $result['credentials']['sessionToken'] ?? '',
],
'expiredTime' => (int)($result['expiredTime'] ?? (time() + $duration)),
'startTime' => (int)($result['startTime'] ?? time()),
'bucket' => $bucket,
'shortBucket' => $shortBucket,
'region' => $region,
'appId' => $appId,
'host' => sprintf('https://%s.cos.%s.myqcloud.com', $bucket, $region),
];
}
/**
* @notes HEAD 校验对象是否真实存在并返回 size / contentType
* @param string $key
* @return array|false
*/
public function headObject(string $key)
{
try {
$result = $this->cosClient->headObject([
'Bucket' => $this->config['bucket'],
'Key' => $key,
]);
return [
'size' => (int)($result['ContentLength'] ?? 0),
'content_type' => (string)($result['ContentType'] ?? ''),
'etag' => trim((string)($result['ETag'] ?? ''), '"'),
];
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
}
+1
View File
@@ -29,6 +29,7 @@
"phpoffice/phpspreadsheet": "^1.22",
"qiniu/php-sdk": "7.4",
"qcloud/cos-sdk-v5": "^2.5",
"qcloud_sts/qcloud-sts-sdk": "^3.0",
"aliyuncs/oss-sdk-php": "^2.4",
"alibabacloud/client": "^1.5",
"rmccue/requests": "^2.0",
+56 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c8b18a5821cfd82f2e70cf67a7f40740",
"content-hash": "a5f0d3c9e4372fa66156ac28411cd86e",
"packages": [
{
"name": "adbario/php-dot-notation",
@@ -2541,6 +2541,58 @@
},
"time": "2023-08-16T02:26:48+00:00"
},
{
"name": "qcloud_sts/qcloud-sts-sdk",
"version": "3.0.12",
"source": {
"type": "git",
"url": "https://github.com/tencentyun/qcloud-cos-sts-php-sdk.git",
"reference": "16ebb03f1079ef71c272180fc015cc809ce8320b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tencentyun/qcloud-cos-sts-php-sdk/zipball/16ebb03f1079ef71c272180fc015cc809ce8320b",
"reference": "16ebb03f1079ef71c272180fc015cc809ce8320b",
"shasum": ""
},
"require": {
"ext-curl": "*",
"php": ">=5.3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"QCloud\\COSSTS\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "qcloudterminal",
"email": "qcloudterminal@gmail.com"
},
{
"name": "tuunalai",
"email": "550566181@qq.com"
}
],
"description": "PHP SDK for QCloud STS",
"homepage": "https://github.com/tencentyun/qcloud-cos-sts-sdk",
"keywords": [
"cos",
"php",
"qcloud",
"sts"
],
"support": {
"issues": "https://github.com/tencentyun/qcloud-cos-sts-php-sdk/issues",
"source": "https://github.com/tencentyun/qcloud-cos-sts-php-sdk/tree/3.0.12"
},
"time": "2023-12-20T06:42:36+00:00"
},
{
"name": "qiniu/php-sdk",
"version": "v7.4.0",
@@ -5132,12 +5184,12 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.0"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+2
View File
@@ -34,5 +34,7 @@ return [
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
// 迁移诊单图片到医生备注表
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
],
];
+1 -1
View File
@@ -126,7 +126,7 @@ return [
/*
* 数据隔离(按部门):全局可用,角色上的 data_scope 控制范围
* 1=全部 2=本部门及下级 3=仅本部门 4=仅本人
* root 管理员永远视为「全部」;多角色取最范围(数值最
* root 管理员永远视为「全部」;多角色取最严格范围(数值最大,与 DataScopeService 一致
*/
'data_scope' => [
// 顶层总开关:false 时所有列表不应用按部门的数据隔离(仍保留老的白名单规则)
@@ -1 +1 @@
import r from"./error-BDUyFX8i.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-tF_2qJHK.js";import"./element-plus-CGvZDWx9.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@element-plus/icons-vue-CGmK0q4e.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-BjeWxBI0.js";import"./index-CruYaYFe.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-Da2f2eD_.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-Rf8enzZx.js";import"./tslib-BDyQ-Jie.js";import"./zrender-C0YiHEZX.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
import r from"./error-BMS1qEWQ.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-tF_2qJHK.js";import"./element-plus-kM-bcufB.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-BjeWxBI0.js";import"./index-BIddbE7W.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -1 +1 @@
import o from"./error-BDUyFX8i.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-tF_2qJHK.js";import"./element-plus-CGvZDWx9.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@element-plus/icons-vue-CGmK0q4e.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-BjeWxBI0.js";import"./index-CruYaYFe.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-Da2f2eD_.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-Rf8enzZx.js";import"./tslib-BDyQ-Jie.js";import"./zrender-C0YiHEZX.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
import o from"./error-BMS1qEWQ.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-tF_2qJHK.js";import"./element-plus-kM-bcufB.js";import"./@vue/runtime-dom-CEiKlVMn.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-BmZ-20sQ.js";import"./@element-plus/icons-vue-BWczA3AF.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-BjeWxBI0.js";import"./index-BIddbE7W.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-CTWOnonq.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-QlbCbZWN.js";import"./@vueuse/shared-Bl-RnC0s.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-Bm7NQW9r.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};

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