Compare commits
33
Commits
9-28
..
tongji-10-12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb28d93cec | ||
|
|
70ddc1a70a | ||
|
|
fc91908cf9 | ||
|
|
b4acad8510 | ||
|
|
f015b26ae1 | ||
|
|
91aa1cb595 | ||
|
|
0349ec0fbd | ||
|
|
68b412495d | ||
|
|
55979da912 | ||
|
|
228d93ac3b | ||
|
|
4704b32807 | ||
|
|
73be0ecedf | ||
|
|
6ccef86dd0 | ||
|
|
e762a367da | ||
|
|
91f56931fd | ||
|
|
9185c6db04 | ||
|
|
5884e000ff | ||
|
|
4d0af7f3f4 | ||
|
|
27772bec61 | ||
|
|
5706e9a6a5 | ||
|
|
51d8fd409e | ||
|
|
88b94c2fc6 | ||
|
|
637c2f1ff2 | ||
|
|
19ea2dadeb | ||
|
|
4dac39c4c2 | ||
|
|
a20d3565af | ||
|
|
67c374d899 | ||
|
|
13948f5982 | ||
|
|
1c9b093e08 | ||
|
|
8bb4bd07ae | ||
|
|
fea33285e8 | ||
|
|
5543f45850 | ||
|
|
70be2fc70f |
@@ -0,0 +1,3 @@
|
|||||||
|
# The bundled GB18030 source files must retain their original bytes.
|
||||||
|
server/knowledge/tcm-ancient-books/*.txt -text -diff
|
||||||
|
server/knowledge/tcm-ancient-books/*.baiduyun.downloading* -text -diff
|
||||||
@@ -28,6 +28,9 @@ app/.test-tmp-stream/
|
|||||||
/.codex
|
/.codex
|
||||||
/.agents
|
/.agents
|
||||||
/server/.spool
|
/server/.spool
|
||||||
|
# Generated ancient-books search indexes; the source corpus is tracked.
|
||||||
|
/server/knowledge/tcm-ancient-books-index.json
|
||||||
|
/server/knowledge/tcm-ancient-books-index/
|
||||||
/server/.claude
|
/server/.claude
|
||||||
/.spool
|
/.spool
|
||||||
TUICallKit-Vue3/.env
|
TUICallKit-Vue3/.env
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const sourceFlag = process.argv.indexOf('--source')
|
||||||
|
const filename = sourceFlag >= 0 ? path.resolve(process.argv[sourceFlag + 1]) : path.join(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
|
||||||
|
const source = fs.readFileSync(filename, 'utf8')
|
||||||
|
const { descriptor, errors } = parse(source, { filename })
|
||||||
|
assert.deepEqual(errors, [], 'actual SFC parses')
|
||||||
|
const script = compileScript(descriptor, { id: 'daily-record-test' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'daily-record-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [], 'actual template compiles')
|
||||||
|
const calls = []
|
||||||
|
const pending = { window: null, notes: null, bloodEdit: null }
|
||||||
|
const api = new Proxy({}, { get(_target, name) {
|
||||||
|
return (...args) => {
|
||||||
|
calls.push({ name, args: JSON.parse(JSON.stringify(args)) })
|
||||||
|
if (name === 'diagnosisTrackingWindow') return pending.window ? pending.window(...args) : Promise.resolve({})
|
||||||
|
if (name === 'diagnosisTrackingNotes') return pending.notes ? pending.notes(...args) : Promise.resolve([])
|
||||||
|
if (name === 'bloodRecordEdit' && pending.bloodEdit) return pending.bloodEdit(...args)
|
||||||
|
return Promise.resolve(true)
|
||||||
|
}
|
||||||
|
} })
|
||||||
|
const thresholdSource = fs.readFileSync(path.join(__dirname, '../src/utils/blood-thresholds.ts'), 'utf8')
|
||||||
|
const thresholdExports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(thresholdSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports })
|
||||||
|
const moduleObject = { exports: {} }
|
||||||
|
const lifecycle = { mounted: [], unmounted: [] }
|
||||||
|
const runtime = { ...vue, onMounted: fn => lifecycle.mounted.push(fn), onUnmounted: fn => lifecycle.unmounted.push(fn) }
|
||||||
|
vm.runInNewContext(ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
|
||||||
|
exports: moduleObject.exports, module: moduleObject, console, Date, Map, window: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'vue') return runtime
|
||||||
|
if (name === '@/api/tcm') return api
|
||||||
|
if (name === '@/utils/perm') return { hasPermission: () => true }
|
||||||
|
if (name === '@/utils/blood-thresholds') return thresholdExports
|
||||||
|
if (name === '@/utils/feedback') return { default: { msgWarning: message => calls.push({ name: 'warning', message }) } }
|
||||||
|
if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} }
|
||||||
|
throw new Error(`Unexpected import: ${name}`)
|
||||||
|
}
|
||||||
|
}, { filename })
|
||||||
|
const props = vue.reactive({ diagnosisId: 7, patientId: 70, age: 50, readOnly: false, patientName: '合成测试' })
|
||||||
|
let exposed
|
||||||
|
const scope = vue.effectScope()
|
||||||
|
const state = scope.run(() => moduleObject.exports.default.setup(props, { expose(value) { exposed = value } }))
|
||||||
|
const date = '2026-09-20'
|
||||||
|
const blood = [
|
||||||
|
{ id: 103, diagnosis_id: 7, record_date: date, record_time: '19:30', fasting_blood_sugar: 7.1, record_time_estimated: 1, record_time_period: 'evening', record_time_text: '昨晚', followup_audio_task_id: 80, remark: '晚间原始行' },
|
||||||
|
{ id: 102, diagnosis_id: 7, record_date: date, record_time: '08:30', fasting_blood_sugar: 7.1, source: 1, remark: '早间原始行' },
|
||||||
|
{ id: 101, diagnosis_id: 7, record_date: date, record_time: '07:00', postprandial_blood_sugar: 8.4, remark: '独立原始行' }
|
||||||
|
]
|
||||||
|
const diet = [ { id: 201, record_date: date, breakfast_foods: '鸡蛋', breakfast_images: ['fixture-a.png'] }, { id: 202, record_date: date, lunch_foods: '豆腐', lunch_images: ['fixture-b.png'] } ]
|
||||||
|
const exercise = [ { id: 301, record_date: date, exercise_type: '散步', duration: 20, intensity: 1, images: ['fixture-c.png'] }, { id: 302, record_date: date, exercise_type: '散步', duration: 20, intensity: 1 } ]
|
||||||
|
function fixtures() {
|
||||||
|
state.bloodRecords.value = structuredClone(blood)
|
||||||
|
state.dietRecords.value = structuredClone(diet)
|
||||||
|
state.exerciseRecords.value = structuredClone(exercise)
|
||||||
|
}
|
||||||
|
function rowCount(map) { const rows = map.value.get(date); return Array.isArray(rows) ? rows.length : rows ? 1 : 0 }
|
||||||
|
function observe() {
|
||||||
|
fixtures()
|
||||||
|
state.handleCellClick('fasting', date)
|
||||||
|
return { bloodRows: rowCount(state.bloodByDate), dietRows: rowCount(state.dietByDate), exerciseRows: rowCount(state.exerciseByDate), dayDetailOpen: !!state.dayDetailVisible?.value, implicitBloodEditId: state.bloodForm.value.id || null, fastingCell: state.getCell('fasting', date).value }
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
for (const style of descriptor.styles) {
|
||||||
|
const result = await compileStyleAsync({ source: style.content, filename, id: 'daily-record-test', scoped: style.scoped, preprocessLang: style.lang })
|
||||||
|
assert.deepEqual(result.errors, [], 'actual styles compile')
|
||||||
|
}
|
||||||
|
const canonicalFlag = process.argv.indexOf('--canonical-fixture')
|
||||||
|
if (canonicalFlag >= 0) {
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(process.argv[canonicalFlag + 1], 'utf8'))
|
||||||
|
assert.equal(fixture.synthetic, true)
|
||||||
|
assert.equal(fixture.source, 'real-disposable-mysql-apply')
|
||||||
|
for (const alias of ['breakfast', 'lunch', 'dinner']) assert.equal(fixture.diet_columns.includes(alias), false)
|
||||||
|
const actual = { ...fixture.diet_model, record_date: new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date(Number(fixture.diet_model.record_date) * 1000)) }
|
||||||
|
state.dietRecords.value = [actual]
|
||||||
|
state.openDietRecord(actual, actual.record_date)
|
||||||
|
for (const meal of ['breakfast', 'lunch', 'dinner']) {
|
||||||
|
assert.equal(state.dietForm.value[`${meal}_foods`], actual[`${meal}_foods`], 'real adopted meal opens in existing edit input')
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(state.dietForm.value[`${meal}_images`])), actual[`${meal}_images`])
|
||||||
|
assert.equal(state.getCell(meal, actual.record_date).value, '✓')
|
||||||
|
}
|
||||||
|
state.dietForm.value.note = 'only synthetic note changed'
|
||||||
|
await state.submitDiet()
|
||||||
|
const payload = calls.findLast(x => x.name === 'dietRecordEdit').args[0]
|
||||||
|
for (const meal of ['breakfast', 'lunch', 'dinner']) {
|
||||||
|
assert.equal(payload[`${meal}_foods`], actual[`${meal}_foods`], 'submit preserves untouched canonical meals')
|
||||||
|
assert.deepEqual(payload[`${meal}_images`], actual[`${meal}_images`], 'submit preserves images')
|
||||||
|
assert.equal(meal in payload, false, 'no extraction alias leaks to existing edit API')
|
||||||
|
}
|
||||||
|
console.log('DAILY_RECORDS_CANONICAL_MYSQL PASS actual_model=1 edit_inputs=1 canonical_payload=1 preserved_images=1')
|
||||||
|
}
|
||||||
|
if (process.argv.includes('--observe')) { console.log(JSON.stringify(observe())); scope.stop(); return }
|
||||||
|
assert.deepEqual(observe(), { bloodRows: 3, dietRows: 2, exerciseRows: 2, dayDetailOpen: true, implicitBloodEditId: null, fastingCell: '7.1 / 7.1' })
|
||||||
|
assert.equal(state.dayBloodRecords.value.length, 3, 'all same-day blood rows present')
|
||||||
|
assert.equal(state.dayDietRecords.value.length, 2, 'all diet rows present')
|
||||||
|
assert.equal(state.dayExerciseRecords.value.length, 2, 'identical exercise rows remain separate')
|
||||||
|
assert.equal(state.recordTimeLabel(blood[0]), '19:30')
|
||||||
|
assert.equal(state.recordTimePeriodLabel(blood[0]), '晚上')
|
||||||
|
assert.equal(state.recordTimeLabel(diet[0]), '未记录具体时间', 'do not invent times for old diet rows')
|
||||||
|
assert.match(descriptor.template.content, /v-for="record in dayBloodRecords"/)
|
||||||
|
assert.match(descriptor.template.content, /v-for="record in dayDietRecords"/)
|
||||||
|
assert.match(descriptor.template.content, /v-for="record in dayExerciseRecords"/)
|
||||||
|
assert.match(descriptor.template.content, /估计时间/)
|
||||||
|
assert.match(descriptor.template.content, /record\.record_time_text/)
|
||||||
|
|
||||||
|
state.openBloodRecord(blood[0], date)
|
||||||
|
assert.equal(state.bloodForm.value.id, 103)
|
||||||
|
assert.equal(state.bloodForm.value.postprandial_blood_sugar, 0, 'no field from another row merged into edit')
|
||||||
|
state.bloodForm.value.remark = '只改备注'
|
||||||
|
await state.submitBlood()
|
||||||
|
let sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||||
|
assert.equal(sent.id, 103)
|
||||||
|
assert.equal(sent.record_time_estimated, 1, 'unmodified time retains estimate')
|
||||||
|
assert.equal(sent.record_time_text, '昨晚')
|
||||||
|
assert.equal(sent.followup_audio_task_id, 80, 'keep provenance')
|
||||||
|
state.openBloodRecord(blood[0], date)
|
||||||
|
state.bloodForm.value.record_time = '20:05'
|
||||||
|
await state.submitBlood()
|
||||||
|
sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||||
|
assert.equal(sent.record_time_estimated, 0, 'manual time correction clears estimate')
|
||||||
|
assert.equal(sent.record_time_period, '')
|
||||||
|
assert.equal(sent.record_time_text, '')
|
||||||
|
assert.equal(sent.followup_audio_task_id, 80)
|
||||||
|
state.openBloodRecord({ ...blood[0], record_time: '19:30:25' }, date)
|
||||||
|
state.bloodForm.value.record_time = '19:30'
|
||||||
|
await state.submitBlood()
|
||||||
|
sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||||
|
assert.equal(sent.record_time_estimated, 0, 'seconds correction must not retain estimated time')
|
||||||
|
assert.equal(state.recordTimeLabel({ record_time: '19:30:25' }), '19:30:25', 'precise original seconds remain visible')
|
||||||
|
state.openBloodRecord(undefined, date)
|
||||||
|
await state.submitBlood()
|
||||||
|
sent = calls.findLast(x => x.name === 'bloodRecordAdd').args[0]
|
||||||
|
assert.equal(sent.id, '')
|
||||||
|
assert.equal('record_time_estimated' in sent, false, 'legacy manual add does not require migration columns')
|
||||||
|
state.openDietRecord(diet[1], date)
|
||||||
|
assert.equal(state.dietForm.value.id, 202)
|
||||||
|
state.dietForm.value.lunch_images.push('fixture-new.png')
|
||||||
|
assert.equal(diet[1].lunch_images.length, 1, 'edit image arrays cloned')
|
||||||
|
await state.submitDiet()
|
||||||
|
assert.equal(calls.findLast(x => x.name === 'dietRecordEdit').args[0].id, 202)
|
||||||
|
state.openExerciseRecord(exercise[1], date)
|
||||||
|
await state.submitExercise()
|
||||||
|
assert.equal(calls.findLast(x => x.name === 'exerciseRecordEdit').args[0].id, 302)
|
||||||
|
fixtures()
|
||||||
|
props.readOnly = true
|
||||||
|
state.dayDetailVisible.value = false
|
||||||
|
state.handleCellClick('exercise', date)
|
||||||
|
assert.equal(state.dayDetailVisible.value, true, 'readonly viewers can inspect actual rows')
|
||||||
|
state.bloodDialogVisible.value = false
|
||||||
|
state.openBloodRecord(blood[0], date)
|
||||||
|
assert.equal(state.bloodDialogVisible.value, false, 'readonly cannot edit')
|
||||||
|
props.readOnly = false
|
||||||
|
|
||||||
|
const windows = []
|
||||||
|
pending.window = params => new Promise(resolve => windows.push({ params, resolve }))
|
||||||
|
const oldFetch = state.fetchTracking()
|
||||||
|
props.diagnosisId = 8
|
||||||
|
await vue.nextTick()
|
||||||
|
assert.equal(state.dayDetailVisible.value, false, 'patient switch closes prior details')
|
||||||
|
assert.equal(state.bloodRecords.value.length, 0, 'patient switch immediately clears old rows')
|
||||||
|
assert.equal(windows.length, 2)
|
||||||
|
assert.equal(windows[0].params.id, 7)
|
||||||
|
assert.equal(windows[1].params.id, 8)
|
||||||
|
windows[1].resolve({ blood_records: [{ id: 888, record_date: date }] })
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
windows[0].resolve({ blood_records: blood })
|
||||||
|
await oldFetch
|
||||||
|
assert.equal(state.bloodRecords.value[0].id, 888, 'late old-patient fetch cannot overwrite new data')
|
||||||
|
assert.deepEqual(calls.filter(x => x.name === 'diagnosisTrackingNotes').slice(-2).map(x => x.args[0].diagnosis_id), [8, 7], 'both sequential requests use their own bound identity')
|
||||||
|
|
||||||
|
let releaseEdit
|
||||||
|
pending.bloodEdit = () => new Promise(resolve => { releaseEdit = resolve })
|
||||||
|
pending.window = null
|
||||||
|
state.openBloodRecord({ ...blood[0], diagnosis_id: 8 }, date)
|
||||||
|
const oldSubmit = state.submitBlood()
|
||||||
|
props.diagnosisId = 9
|
||||||
|
await vue.nextTick()
|
||||||
|
state.bloodDialogVisible.value = true
|
||||||
|
releaseEdit(true)
|
||||||
|
await oldSubmit
|
||||||
|
assert.equal(state.bloodDialogVisible.value, true, 'late old-patient submit cannot close new dialog')
|
||||||
|
|
||||||
|
// Single-flight guard prevents duplicate submissions of the same manual form.
|
||||||
|
state.bloodSubmitting.value = true
|
||||||
|
const callCount = calls.length
|
||||||
|
await state.submitBlood()
|
||||||
|
assert.equal(calls.length, callCount)
|
||||||
|
state.bloodSubmitting.value = false
|
||||||
|
const unmountedWindows = []
|
||||||
|
pending.window = () => new Promise(resolve => unmountedWindows.push(resolve))
|
||||||
|
const unmountedFetch = state.fetchTracking()
|
||||||
|
for (const unmount of lifecycle.unmounted) unmount()
|
||||||
|
unmountedWindows[0]({ blood_records: blood })
|
||||||
|
await unmountedFetch
|
||||||
|
assert.notEqual(state.bloodRecords.value[0]?.id, 103, 'unmounted request cannot repopulate component')
|
||||||
|
scope.stop()
|
||||||
|
assert.equal(typeof exposed.refresh, 'function')
|
||||||
|
console.log('DailyMatrix actual SFC setup/template/style: 3 blood + 2 diet + 2 exercise rows, exact edit IDs, time metadata, readonly detail, stale isolation: PASS')
|
||||||
|
}
|
||||||
|
main().catch(error => { scope.stop(); console.error(error); process.exitCode = 1 })
|
||||||
@@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app'
|
|||||||
import useSettingStore from './stores/modules/setting'
|
import useSettingStore from './stores/modules/setting'
|
||||||
import useUserStore from './stores/modules/user'
|
import useUserStore from './stores/modules/user'
|
||||||
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
||||||
|
import RevisitDueToast from './components/revisit-due-toast/index.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
@@ -58,6 +59,8 @@ watch(
|
|||||||
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
||||||
<router-view />
|
<router-view />
|
||||||
<ChatNotifyToast v-if="showChatNotifyToast" />
|
<ChatNotifyToast v-if="showChatNotifyToast" />
|
||||||
|
<!-- 复诊到期弹窗(右下角):与会话通知并列挂载,复用同一条件避免未绑企微时无意义轮询 -->
|
||||||
|
<RevisitDueToast v-if="showChatNotifyToast" />
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export type FollowupKind = 'diagnosis' | 'blood' | 'diet' | 'exercise' | 'tracking_note'
|
||||||
|
export type FollowupValue = string | number | boolean | null | Array<string | number>
|
||||||
|
export type FollowupValues = Record<string, FollowupValue>
|
||||||
|
export interface FollowupField {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'number' | 'select' | 'multiselect' | 'date'
|
||||||
|
options?: Array<{ label: string; value: string | number }>
|
||||||
|
readonly?: boolean
|
||||||
|
}
|
||||||
|
export type FollowupFields = Record<FollowupKind, FollowupField[]>
|
||||||
|
export type FollowupAudioChannel = 0 | 1
|
||||||
|
export type FollowupChannelRoles = 'unconfirmed' | 'left_service' | 'right_service'
|
||||||
|
export interface FollowupTranscriptSegment {
|
||||||
|
id: string
|
||||||
|
start_ms: number
|
||||||
|
end_ms: number
|
||||||
|
text: string
|
||||||
|
channel?: FollowupAudioChannel
|
||||||
|
}
|
||||||
|
export interface FollowupCapabilities {
|
||||||
|
enabled: boolean
|
||||||
|
audio_verified: boolean
|
||||||
|
preview_only?: boolean
|
||||||
|
preview_ready?: boolean
|
||||||
|
manual_review_enabled?: boolean
|
||||||
|
manual_review_ready?: boolean
|
||||||
|
can_review?: boolean
|
||||||
|
test_scope_allowed?: boolean
|
||||||
|
can_upload: boolean
|
||||||
|
can_apply: boolean
|
||||||
|
can_daily: boolean
|
||||||
|
models: Array<{ value: string; label: string }>
|
||||||
|
limits: { max_bytes: number; max_seconds: number; chunk_bytes: number }
|
||||||
|
fields: FollowupFields
|
||||||
|
}
|
||||||
|
export interface FollowupReviewItem {
|
||||||
|
id: string
|
||||||
|
kind: FollowupKind
|
||||||
|
values: FollowupValues
|
||||||
|
record_date: string | null
|
||||||
|
record_time: string | null
|
||||||
|
time_period: string | null
|
||||||
|
time_estimated: boolean
|
||||||
|
time_text: string
|
||||||
|
date_text: string
|
||||||
|
evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment'; channel?: FollowupAudioChannel }>
|
||||||
|
needs_review: boolean
|
||||||
|
selected: boolean
|
||||||
|
target_id: number | null
|
||||||
|
current_values: FollowupValues
|
||||||
|
expected_hash: string
|
||||||
|
conflict: boolean
|
||||||
|
possible_duplicate: boolean
|
||||||
|
candidates?: Array<{
|
||||||
|
id: number
|
||||||
|
values: FollowupValues
|
||||||
|
record_time: string | null
|
||||||
|
time_estimated: boolean
|
||||||
|
time_period: string | null
|
||||||
|
delete_time?: number | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
export interface FollowupTaskSummary {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
file_name: string
|
||||||
|
recorded_at: string
|
||||||
|
status: string
|
||||||
|
stage: string
|
||||||
|
error_code: string
|
||||||
|
error_message: string
|
||||||
|
version: number
|
||||||
|
created_at: number | string
|
||||||
|
expires_at: number | string
|
||||||
|
can_retry: boolean
|
||||||
|
audio_available: boolean
|
||||||
|
preview_only?: boolean
|
||||||
|
transcript_available?: boolean
|
||||||
|
pipeline_progress?: { transcribed: number; total: number }
|
||||||
|
}
|
||||||
|
export interface FollowupTaskDetail extends FollowupTaskSummary {
|
||||||
|
summary: string
|
||||||
|
transcript: string
|
||||||
|
transcript_segments?: FollowupTranscriptSegment[]
|
||||||
|
channel_roles?: FollowupChannelRoles
|
||||||
|
uncertainties: unknown[]
|
||||||
|
items: FollowupReviewItem[]
|
||||||
|
fields?: FollowupFields
|
||||||
|
applied_items: unknown[]
|
||||||
|
review_refreshed?: boolean
|
||||||
|
}
|
||||||
|
interface Envelope<T> { code: number; msg?: string; data: T }
|
||||||
|
export class FollowupAudioError extends Error {
|
||||||
|
constructor(message: string, public readonly code = '') { super(message); this.name = 'FollowupAudioError' }
|
||||||
|
}
|
||||||
|
const options = { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||||
|
const base = '/tcm.followupAudio/'
|
||||||
|
function unwrap<T>(response: Envelope<T>): T {
|
||||||
|
if (response.code !== 1) {
|
||||||
|
const data = response.data
|
||||||
|
const code = data && typeof data === 'object' && 'code' in data ? String(data.code) : ''
|
||||||
|
throw new FollowupAudioError(response.msg || '回访录音请求失败,请刷新任务状态后确认', code)
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
async function get<T>(name: string, params: Record<string, number>, signal?: AbortSignal): Promise<T> {
|
||||||
|
return unwrap(await request.get<Envelope<T>>({ url: base + name, params, signal }, options))
|
||||||
|
}
|
||||||
|
async function post<T>(name: string, data: unknown, signal?: AbortSignal): Promise<T> {
|
||||||
|
return unwrap(await request.post<Envelope<T>>({ url: base + name, data, signal, timeout: 120000 }, options))
|
||||||
|
}
|
||||||
|
export const followupAudioCapabilities = (diagnosisId: number, signal?: AbortSignal) => get<FollowupCapabilities>('capabilities', { diagnosis_id: diagnosisId }, signal)
|
||||||
|
export const followupAudioLists = (diagnosisId: number, signal?: AbortSignal) => get<{ items: FollowupTaskSummary[] }>('lists', { diagnosis_id: diagnosisId }, signal)
|
||||||
|
export const followupAudioDetail = (id: number, signal?: AbortSignal) => get<FollowupTaskDetail>('detail', { id }, signal)
|
||||||
|
export const followupAudioUploadSession = (data: { diagnosis_id: number; file_name: string; total_bytes: number }, signal?: AbortSignal) => post<{ upload_id: string; chunk_bytes: number }>('uploadSession', data, signal)
|
||||||
|
export async function followupAudioUploadChunk(uploadId: string, index: number, file: Blob, signal?: AbortSignal) {
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('upload_id', uploadId)
|
||||||
|
data.append('index', String(index))
|
||||||
|
data.append('file', file, `chunk-${index}`)
|
||||||
|
return unwrap(await request.post<Envelope<{ index: number; received: boolean }>>({
|
||||||
|
url: base + 'uploadChunk', data, signal, timeout: 120000,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
}, options))
|
||||||
|
}
|
||||||
|
export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSignal) => post<{ upload_id: string; duration_seconds: number; sha256: string }>('uploadComplete', { upload_id: uploadId }, signal)
|
||||||
|
export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal)
|
||||||
|
export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<FollowupTaskDetail>('saveDraft', data, signal)
|
||||||
|
export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal)
|
||||||
|
export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal)
|
||||||
|
/** The existing request interceptor puts the token in a header, never in the audio URL. */
|
||||||
|
export async function followupAudioBlob(id: number, signal?: AbortSignal): Promise<Blob> {
|
||||||
|
const blob = await request.get<Blob>({ url: base + 'audio', params: { id }, responseType: 'blob', signal, timeout: 120000 }, options)
|
||||||
|
if (blob.type.includes('json')) {
|
||||||
|
const response: Envelope<unknown> = JSON.parse(await blob.text())
|
||||||
|
unwrap(response)
|
||||||
|
throw new FollowupAudioError('录音响应不是音频文件')
|
||||||
|
}
|
||||||
|
return blob
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/<action>
|
||||||
|
* 契约见 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md §3.3。
|
||||||
|
* 写接口后端 show=1,成功提示由 utils/request 统一弹出,调用方不要重复 ElMessage。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 弹窗条目(RevisitDueLogic::formatItem) */
|
||||||
|
export interface RevisitDueToastItem {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
assistant_id: number
|
||||||
|
patient_name: string
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
medication_days: number
|
||||||
|
/** 'YYYY-MM-DD' 或 '' */
|
||||||
|
due_date: string
|
||||||
|
overdue_days: number | null
|
||||||
|
/** overdue | missed(弹窗只含这两态) */
|
||||||
|
state: string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
missed_count: number
|
||||||
|
snooze_count: number
|
||||||
|
/** 0~3,3 = 居中模态 */
|
||||||
|
escalation_level: number
|
||||||
|
contact_count: number
|
||||||
|
promised_date: string
|
||||||
|
next_remind_at: number
|
||||||
|
next_remind_at_text: string
|
||||||
|
/** 下一次点「延迟」会顺延的秒数(按钮文案用) */
|
||||||
|
next_snooze_seconds: number
|
||||||
|
appointment_date: string
|
||||||
|
closed_state: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总计数(RevisitDueLogic::buildSummary) */
|
||||||
|
export interface RevisitDueToastSummary {
|
||||||
|
enabled: boolean
|
||||||
|
due_total: number
|
||||||
|
max_overdue_days: number
|
||||||
|
warning_total: number
|
||||||
|
promise_max_days: number
|
||||||
|
contact_max_per_cycle: number
|
||||||
|
work_hours: [string, string]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisitDuePendingResult {
|
||||||
|
items: RevisitDueToastItem[]
|
||||||
|
summary: RevisitDueToastSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写接口返回处理后的当前行(幂等:被他人处理时也返回当前行) */
|
||||||
|
export type RevisitDueWriteResult = RevisitDueToastItem
|
||||||
|
|
||||||
|
export interface RevisitDueSnoozeAllResult {
|
||||||
|
items: RevisitDueToastItem[]
|
||||||
|
snoozed: number
|
||||||
|
skipped: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询接口不走统一 code 处理:权限不足 / 开关关闭时不要每分钟弹一次错误提示 */
|
||||||
|
const POLL_OPTIONS = {
|
||||||
|
isTransformResponse: false,
|
||||||
|
ignoreCancelToken: true,
|
||||||
|
isOpenRetry: false,
|
||||||
|
retryCount: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗待办(60s 轮询):本人到期未挂号 / 过号且已到提醒时刻的项,每次返回全集。
|
||||||
|
* 非 code=1 一律 reject(调用方静默处理),避免轮询刷错误提示。
|
||||||
|
*/
|
||||||
|
export async function revisitDuePending(): Promise<RevisitDuePendingResult> {
|
||||||
|
const res = await request.get({ url: '/tcm.revisitDue/pending' }, POLL_OPTIONS)
|
||||||
|
if (res && typeof res === 'object' && Number(res.code) === 1) {
|
||||||
|
return res.data as RevisitDuePendingResult
|
||||||
|
}
|
||||||
|
return Promise.reject(res?.msg ?? 'revisit due pending failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅计数,不更新弹出时间 */
|
||||||
|
export function revisitDueSummary(): Promise<RevisitDueToastSummary> {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/summary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 延迟一次(幂等) */
|
||||||
|
export function revisitDueSnooze(params: { id: number }): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/snooze', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量延迟(仅 escalation_level<=1 的项) */
|
||||||
|
export function revisitDueSnoozeAll(params: {
|
||||||
|
ids: number[]
|
||||||
|
}): Promise<RevisitDueSnoozeAllResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/snoozeAll', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已联系:记录患者承诺复诊日 */
|
||||||
|
export function revisitDueContact(params: {
|
||||||
|
id: number
|
||||||
|
promised_date: string
|
||||||
|
remark?: string
|
||||||
|
}): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/contact', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人工关闭(reason lost|other,remark 必填 ≤200) */
|
||||||
|
export function revisitDueClose(params: {
|
||||||
|
id: number
|
||||||
|
reason: 'lost' | 'other'
|
||||||
|
remark: string
|
||||||
|
}): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/close', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图列表行(RevisitDueLists,按角色 data_scope 收窄) */
|
||||||
|
export interface RevisitDueListRow {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
patient_name: string
|
||||||
|
assistant_id: number
|
||||||
|
assistant_name: string
|
||||||
|
dept_name: string
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
medication_days: number
|
||||||
|
/** 14/21/28/30 或 0=其它 */
|
||||||
|
rule_days: number
|
||||||
|
anchor_date: string
|
||||||
|
due_date: string
|
||||||
|
overdue_days: number | null
|
||||||
|
days_left: number | null
|
||||||
|
state: string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
appointment_state: number
|
||||||
|
appointment_date: string
|
||||||
|
snooze_count: number
|
||||||
|
escalation_level: number
|
||||||
|
contact_count: number
|
||||||
|
promised_date: string
|
||||||
|
missed_count: number
|
||||||
|
closed_state: number
|
||||||
|
closed_state_text: string
|
||||||
|
handle_reason: string
|
||||||
|
handle_remark: string
|
||||||
|
first_shown_at_text: string
|
||||||
|
last_snoozed_at_text: string
|
||||||
|
computed_at_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图筛选参数(state:due|warning|booked|visited|contacted|no_days|pending|closed;days_bucket:14|21|28|30|other) */
|
||||||
|
export interface RevisitDueListsParams {
|
||||||
|
assistant_id?: number | ''
|
||||||
|
dept_id?: number | ''
|
||||||
|
state?: string
|
||||||
|
days_bucket?: string
|
||||||
|
min_snooze?: number | ''
|
||||||
|
keyword?: string
|
||||||
|
page_no?: number
|
||||||
|
page_size?: number
|
||||||
|
/** 1 = 导出(export-data 组件追加) */
|
||||||
|
export?: number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图:复诊到期跟进分页列表(lists/count/page_no/page_size/extend 包络,供 usePaging / export-data 消费) */
|
||||||
|
export function revisitDueLists(params: RevisitDueListsParams) {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事件流水行(RevisitDueLogic::logs) */
|
||||||
|
export interface RevisitDueLogRow {
|
||||||
|
id: number
|
||||||
|
/** 1 延迟 2 已联系 3 人工关闭 4 自动关闭-已挂号 5 自动关闭-已面诊 6 周期重置 7 重新打开 8 企微推送 9 医助变更 10 形态升级 11 首次弹出 */
|
||||||
|
event: number
|
||||||
|
event_text: string
|
||||||
|
snooze_count_after: number
|
||||||
|
level_after: number
|
||||||
|
overdue_days: number
|
||||||
|
/** payload JSON 解析后的对象(next_remind_at / promised_date / appointment_id / order_id / remark / from_assistant 等) */
|
||||||
|
payload_arr: Record<string, any>
|
||||||
|
operator_id: number
|
||||||
|
operator_name: string
|
||||||
|
create_time_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图:某条提醒的事件流水(按 due_id,时间倒序由后端决定) */
|
||||||
|
export function revisitDueLogs(params: { due_id: number }) {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/logs', params })
|
||||||
|
}
|
||||||
+50
-4
@@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
|||||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
/**
|
||||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
* 复诊接诊率口径:
|
||||||
|
* - slot(默认,后端 config project.revisit_rate.default_inherit_mode):继承按继承时诊次计入每列独立分母(visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数)
|
||||||
|
* - exclude:继承不计分母(旧口径,assigned_count / visit{N}_count / visit{N}_rate 与 2026-10 前一致)
|
||||||
|
*/
|
||||||
|
export type RevisitRateInheritMode = 'exclude' | 'slot'
|
||||||
|
|
||||||
|
/** 被指派明细行类型:new 新指派 / inherit 继承 / all 两者(默认) */
|
||||||
|
export type RevisitRateAssignKind = 'new' | 'inherit' | 'all'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊接诊率(按月):部门 → 医助分组 + 合计。
|
||||||
|
* 返回 inherit_mode、slots,行含 assigned_count(新指派)、inherit_count、assigned_total_count、
|
||||||
|
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(旧口径二诊率)
|
||||||
|
*/
|
||||||
|
export function revisitRateOverview(params: {
|
||||||
|
month?: string
|
||||||
|
dept_ids?: string
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,23 +232,29 @@ export function revisitRateDeptOptions() {
|
|||||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
/**
|
||||||
|
* 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。
|
||||||
|
* 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次)
|
||||||
|
*/
|
||||||
export function revisitRateAssignLines(params: {
|
export function revisitRateAssignLines(params: {
|
||||||
month?: string
|
month?: string
|
||||||
dept_ids?: string
|
dept_ids?: string
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
|
kind?: RevisitRateAssignKind
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */
|
||||||
export function revisitRateVisitOrderLines(params: {
|
export function revisitRateVisitOrderLines(params: {
|
||||||
month?: string
|
month?: string
|
||||||
slot: number
|
slot: number
|
||||||
dept_ids?: string
|
dept_ids?: string
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||||
}
|
}
|
||||||
@@ -253,3 +277,25 @@ export function assistantPerformanceOverview(params: {
|
|||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「我的复诊接诊率」卡片返回(RevisitRateLogic::assistantOverview,仅本人行) */
|
||||||
|
export interface AssistantRevisitRateResult {
|
||||||
|
month: string
|
||||||
|
inherit_mode: RevisitRateInheritMode
|
||||||
|
/** 诊次列范围(2~N) */
|
||||||
|
slots: number[]
|
||||||
|
/** false = 本人不属于二中心子树(复诊接诊率仅统计二中心医助) */
|
||||||
|
in_scope: boolean
|
||||||
|
/** 本人行:assigned_count / inherit_count / assigned_total_count / visit{N}_count|_den|_rate / visit_all_*;无数据为 null */
|
||||||
|
row: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致,
|
||||||
|
* 继承患者按继承时诊次从第 k+1 诊起计入分母)
|
||||||
|
*/
|
||||||
|
export function assistantPerformanceRevisitRate(params: {
|
||||||
|
month?: string
|
||||||
|
}): Promise<AssistantRevisitRateResult> {
|
||||||
|
return request.get({ url: '/stats.assistantPerformance/revisitRate', params })
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* 复诊节点(revisit_due)前端纯函数集合
|
||||||
|
*
|
||||||
|
* 数据契约与后端 `RevisitDueService::buildListPayload` 输出一致(方案 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md 契约 C):
|
||||||
|
* 所有日期为 'Y-m-d' 字符串或 '';无快照且无法计算时后端返回 state='none' 的对象,前端显示「—」。
|
||||||
|
*
|
||||||
|
* 本文件只做 state → 文案 / 颜色 class / el-tag type 的映射与日期、天数文案拼装,不含任何请求或响应式逻辑,
|
||||||
|
* 供 PC 列表、H5 卡片、只读页三处共用(RevisitNodeCell.vue + readonly.vue)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RevisitDueState =
|
||||||
|
| 'visited'
|
||||||
|
| 'booked'
|
||||||
|
| 'missed'
|
||||||
|
| 'contacted'
|
||||||
|
| 'overdue'
|
||||||
|
| 'warning'
|
||||||
|
| 'pending'
|
||||||
|
| 'no_days'
|
||||||
|
| 'none'
|
||||||
|
|
||||||
|
/** 列表行 `revisit_due` 对象(契约 C) */
|
||||||
|
export interface RevisitDue {
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
valid_order_count: number
|
||||||
|
medication_days: number
|
||||||
|
/** 0=缺失 1=订单 2=处方 */
|
||||||
|
days_src: 0 | 1 | 2
|
||||||
|
/** 14/21/28/30 或 0=其它 */
|
||||||
|
rule_days: number
|
||||||
|
/** 0=无 1=签收 2=发货 3=下单 */
|
||||||
|
anchor_src: 0 | 1 | 2 | 3
|
||||||
|
anchor_date: string
|
||||||
|
order_date: string
|
||||||
|
due_date: string
|
||||||
|
days_left: number | null
|
||||||
|
overdue_days: number | null
|
||||||
|
state: RevisitDueState | string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
/** 0=无 1=已约 3=已面诊 4=过号 */
|
||||||
|
appointment_state: number
|
||||||
|
appointment_date: string
|
||||||
|
snooze_count: number
|
||||||
|
escalation_level: number
|
||||||
|
promised_date: string
|
||||||
|
missed_count: number
|
||||||
|
first_touch_date: string
|
||||||
|
in_care_days: number | null
|
||||||
|
total_medication_days: number
|
||||||
|
closed_state: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「复诊状态」筛选下拉选项(请求参数 revisit_state;due = overdue ∪ missed) */
|
||||||
|
export const REVISIT_STATE_FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '到期未挂号 / 过号', value: 'due' },
|
||||||
|
{ label: '即将到期', value: 'warning' },
|
||||||
|
{ label: '已约待面诊', value: 'booked' },
|
||||||
|
{ label: '已面诊', value: 'visited' },
|
||||||
|
{ label: '已联系', value: 'contacted' },
|
||||||
|
{ label: '缺开药天数', value: 'no_days' },
|
||||||
|
{ label: '未到期', value: 'pending' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 「开药天数档」筛选下拉选项(请求参数 revisit_days_bucket) */
|
||||||
|
export const REVISIT_DAYS_BUCKET_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '14 天', value: '14' },
|
||||||
|
{ label: '21 天', value: '21' },
|
||||||
|
{ label: '28 天', value: '28' },
|
||||||
|
{ label: '30 天', value: '30' },
|
||||||
|
{ label: '其它天数', value: 'other' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** state → 默认文案(后端 state_text 缺失时兜底) */
|
||||||
|
const STATE_LABEL: Record<string, string> = {
|
||||||
|
visited: '已面诊',
|
||||||
|
booked: '已约',
|
||||||
|
missed: '过号',
|
||||||
|
contacted: '已联系',
|
||||||
|
overdue: '超期',
|
||||||
|
warning: '即将到期',
|
||||||
|
pending: '未到期',
|
||||||
|
no_days: '缺开药天数',
|
||||||
|
none: '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInt = (v: unknown, fallback = 0): number => {
|
||||||
|
if (v === null || v === undefined || v === '') return fallback
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNullableInt = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDateStr = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
const s = String(v).trim()
|
||||||
|
if (s === '' || s === '0000-00-00') return ''
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把列表行 / 详情里的 `revisit_due` 原始值规整为 RevisitDue;
|
||||||
|
* 字段缺失(旧接口)或非对象时返回 null,调用方据此不渲染。
|
||||||
|
*/
|
||||||
|
export function normalizeRevisitDue(raw: unknown): RevisitDue | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||||
|
const r = raw as Record<string, unknown>
|
||||||
|
const state = String(r.state ?? 'none').trim() || 'none'
|
||||||
|
const daysSrc = toInt(r.days_src)
|
||||||
|
const anchorSrc = toInt(r.anchor_src)
|
||||||
|
return {
|
||||||
|
current_slot: toInt(r.current_slot),
|
||||||
|
next_slot: toInt(r.next_slot),
|
||||||
|
valid_order_count: toInt(r.valid_order_count),
|
||||||
|
medication_days: toInt(r.medication_days),
|
||||||
|
days_src: (daysSrc === 1 || daysSrc === 2 ? daysSrc : 0) as 0 | 1 | 2,
|
||||||
|
rule_days: toInt(r.rule_days),
|
||||||
|
anchor_src: (anchorSrc >= 1 && anchorSrc <= 3 ? anchorSrc : 0) as 0 | 1 | 2 | 3,
|
||||||
|
anchor_date: toDateStr(r.anchor_date),
|
||||||
|
order_date: toDateStr(r.order_date),
|
||||||
|
due_date: toDateStr(r.due_date),
|
||||||
|
days_left: toNullableInt(r.days_left),
|
||||||
|
overdue_days: toNullableInt(r.overdue_days),
|
||||||
|
state,
|
||||||
|
state_text: String(r.state_text ?? '').trim(),
|
||||||
|
severe: toInt(r.severe) === 1 ? 1 : 0,
|
||||||
|
appointment_state: toInt(r.appointment_state),
|
||||||
|
appointment_date: toDateStr(r.appointment_date),
|
||||||
|
snooze_count: toInt(r.snooze_count),
|
||||||
|
escalation_level: toInt(r.escalation_level),
|
||||||
|
promised_date: toDateStr(r.promised_date),
|
||||||
|
missed_count: toInt(r.missed_count),
|
||||||
|
first_touch_date: toDateStr(r.first_touch_date),
|
||||||
|
in_care_days: toNullableInt(r.in_care_days),
|
||||||
|
total_medication_days: toInt(r.total_medication_days),
|
||||||
|
closed_state: toInt(r.closed_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否有可展示的复诊节点(state !== 'none') */
|
||||||
|
export function hasRevisitDue(due: RevisitDue | null | undefined): due is RevisitDue {
|
||||||
|
return !!due && due.state !== 'none' && due.state !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行级标红:到期未挂号 / 过号未面诊;人工关闭或已自动关闭(closed_state!==0)的行不标红,与「复诊到期」chip / 筛选口径一致 */
|
||||||
|
export function isRevisitDueHighlight(due: RevisitDue | null | undefined): boolean {
|
||||||
|
return (
|
||||||
|
hasRevisitDue(due) &&
|
||||||
|
toInt(due.closed_state) === 0 &&
|
||||||
|
(due.state === 'overdue' || due.state === 'missed')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深红:超期达到 severe_overdue_days(后端已算好 severe=1) */
|
||||||
|
export function isRevisitDueSevere(due: RevisitDue | null | undefined): boolean {
|
||||||
|
return isRevisitDueHighlight(due) && Number(due?.severe) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-table :row-class-name 片段:
|
||||||
|
* '' | 'row-revisit-overdue' | 'row-revisit-overdue row-revisit-overdue-deep'
|
||||||
|
*/
|
||||||
|
export function revisitRowClassName(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!isRevisitDueHighlight(due)) return ''
|
||||||
|
return isRevisitDueSevere(due) ? 'row-revisit-overdue row-revisit-overdue-deep' : 'row-revisit-overdue'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD' → 'MM-DD';非法/空值返回 '' */
|
||||||
|
export function formatRevisitMonthDay(date: string | null | undefined): string {
|
||||||
|
const s = toDateStr(date)
|
||||||
|
if (!s) return ''
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||||
|
if (m) return `${m[2]}-${m[3]}`
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用药起算锚点来源文案(行 2 末尾) */
|
||||||
|
export function revisitAnchorSrcText(src: number | null | undefined): string {
|
||||||
|
const n = toInt(src)
|
||||||
|
if (n === 1) return '签收起'
|
||||||
|
if (n === 2) return '发货起'
|
||||||
|
if (n === 3) return '下单起'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开药天数来源文案(tooltip 用) */
|
||||||
|
export function revisitDaysSrcText(src: number | null | undefined): string {
|
||||||
|
const n = toInt(src)
|
||||||
|
if (n === 1) return '订单'
|
||||||
|
if (n === 2) return '处方'
|
||||||
|
return '缺失'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否缺开药天数(days_src=0 或 medication_days<=0) */
|
||||||
|
export function isRevisitDaysMissing(due: RevisitDue | null | undefined): boolean {
|
||||||
|
if (!hasRevisitDue(due)) return false
|
||||||
|
return toInt(due.days_src) === 0 || toInt(due.medication_days) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「第 N 诊」文案 */
|
||||||
|
export function revisitSlotText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const slot = toInt(due.current_slot)
|
||||||
|
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「开药 X 天」或「缺开药天数」 */
|
||||||
|
export function revisitMedicationDaysText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
if (isRevisitDaysMissing(due)) return '缺开药天数'
|
||||||
|
return `开药 ${toInt(due.medication_days)} 天`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「药尽 MM-DD」;缺天数无 due_date 时返回 '' */
|
||||||
|
export function revisitDueDateText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const md = formatRevisitMonthDay(due.due_date)
|
||||||
|
return md ? `药尽 ${md}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** state 兜底文案(后端 state_text 为空时使用) */
|
||||||
|
export function revisitStateLabel(state: string | null | undefined): string {
|
||||||
|
const key = String(state ?? '').trim()
|
||||||
|
return STATE_LABEL[key] ?? (key || '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 chip 文案:优先后端 state_text;为空时按 state + 数字自行拼装,
|
||||||
|
* 保证旧接口 / 字段缺失时也可读。
|
||||||
|
*/
|
||||||
|
export function revisitStateChipText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
if (due.state_text) return due.state_text
|
||||||
|
const state = String(due.state)
|
||||||
|
switch (state) {
|
||||||
|
case 'overdue': {
|
||||||
|
const n = toInt(due.overdue_days)
|
||||||
|
return n <= 0 ? '今日应挂号' : `超期 ${n} 天`
|
||||||
|
}
|
||||||
|
case 'missed': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `过号 ${md}` : '过号'
|
||||||
|
}
|
||||||
|
case 'booked': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `已约 ${md}` : '已约'
|
||||||
|
}
|
||||||
|
case 'visited': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `已面诊 ${md}` : '已面诊'
|
||||||
|
}
|
||||||
|
case 'contacted': {
|
||||||
|
const md = formatRevisitMonthDay(due.promised_date)
|
||||||
|
return md ? `已联系·约 ${md}` : '已联系'
|
||||||
|
}
|
||||||
|
case 'warning':
|
||||||
|
case 'pending': {
|
||||||
|
const n = due.days_left
|
||||||
|
return n === null ? revisitStateLabel(state) : `剩 ${n} 天`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return revisitStateLabel(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 的 el-tag type */
|
||||||
|
export function revisitStateTagType(
|
||||||
|
state: string | null | undefined
|
||||||
|
): 'danger' | 'warning' | 'primary' | 'success' | 'info' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'danger'
|
||||||
|
case 'warning':
|
||||||
|
case 'no_days':
|
||||||
|
return 'warning'
|
||||||
|
case 'booked':
|
||||||
|
case 'contacted':
|
||||||
|
return 'primary'
|
||||||
|
case 'visited':
|
||||||
|
return 'success'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 的 el-tag effect:红为 dark;描边态(已联系 / 缺天数)为 plain;其余 light */
|
||||||
|
export function revisitStateTagEffect(state: string | null | undefined): 'dark' | 'light' | 'plain' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'dark'
|
||||||
|
case 'contacted':
|
||||||
|
case 'no_days':
|
||||||
|
return 'plain'
|
||||||
|
default:
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → 徽标颜色 class(复用列表 / 只读页既有的 unserved-* 色板):
|
||||||
|
* overdue/missed → unserved-red;warning/no_days → unserved-orange;
|
||||||
|
* visited → unserved-green;booked/contacted → unserved-blue;其余 → unserved-muted
|
||||||
|
*/
|
||||||
|
export function revisitStateColorClass(
|
||||||
|
state: string | null | undefined
|
||||||
|
): 'unserved-red' | 'unserved-orange' | 'unserved-green' | 'unserved-blue' | 'unserved-muted' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'unserved-red'
|
||||||
|
case 'warning':
|
||||||
|
case 'no_days':
|
||||||
|
return 'unserved-orange'
|
||||||
|
case 'visited':
|
||||||
|
return 'unserved-green'
|
||||||
|
case 'booked':
|
||||||
|
case 'contacted':
|
||||||
|
return 'unserved-blue'
|
||||||
|
default:
|
||||||
|
return 'unserved-muted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 附加 class(组件内样式钩子):rv-chip rv-chip--<state> [rv-chip--severe] */
|
||||||
|
export function revisitStateChipClass(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return 'rv-chip'
|
||||||
|
const cls = ['rv-chip', `rv-chip--${String(due.state)}`]
|
||||||
|
if (isRevisitDueSevere(due)) cls.push('rv-chip--severe')
|
||||||
|
return cls.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4:「已延迟 N 次」 */
|
||||||
|
export function revisitSnoozeText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const n = toInt(due.snooze_count)
|
||||||
|
return n > 0 ? `已延迟 ${n} 次` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4:「已联系 约 MM-DD」 */
|
||||||
|
export function revisitContactText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const md = formatRevisitMonthDay(due.promised_date)
|
||||||
|
return md ? `已联系 约 ${md}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4 合并文案:仅 snooze_count>0 或 promised_date 有值时非空 */
|
||||||
|
export function revisitFollowLineText(due: RevisitDue | null | undefined): string {
|
||||||
|
const parts = [revisitSnoozeText(due), revisitContactText(due)].filter((s) => s !== '')
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 2:「MM-DD 开药 · 开药 X 天 · 签收起」 */
|
||||||
|
export function revisitOrderLineText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
const md = formatRevisitMonthDay(due.order_date)
|
||||||
|
if (md) parts.push(`${md} 开药`)
|
||||||
|
parts.push(revisitMedicationDaysText(due))
|
||||||
|
if (!isRevisitDaysMissing(due)) {
|
||||||
|
const anchor = revisitAnchorSrcText(due.anchor_src)
|
||||||
|
if (anchor) parts.push(anchor)
|
||||||
|
}
|
||||||
|
return parts.filter((s) => s !== '').join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊次 tag tooltip 行 */
|
||||||
|
export function revisitTooltipLines(due: RevisitDue | null | undefined): string[] {
|
||||||
|
if (!hasRevisitDue(due)) return []
|
||||||
|
const lines: string[] = []
|
||||||
|
const offset = Math.max(0, toInt(due.current_slot) - toInt(due.valid_order_count))
|
||||||
|
lines.push(`有效订单 ${toInt(due.valid_order_count)} 笔 · 诊次偏移 +${offset}`)
|
||||||
|
if (toInt(due.next_slot) > 0) lines.push(`待复诊:第 ${toInt(due.next_slot)} 诊`)
|
||||||
|
const first = formatRevisitMonthDay(due.first_touch_date)
|
||||||
|
if (first) {
|
||||||
|
const care = due.in_care_days
|
||||||
|
lines.push(care === null ? `首次触达 ${first}` : `首次触达 ${first} · 在管 ${care} 天`)
|
||||||
|
}
|
||||||
|
if (toInt(due.total_medication_days) > 0) lines.push(`累计开药 ${toInt(due.total_medication_days)} 天`)
|
||||||
|
const anchorMd = formatRevisitMonthDay(due.anchor_date)
|
||||||
|
const anchorSrc = revisitAnchorSrcText(due.anchor_src)
|
||||||
|
if (anchorMd && anchorSrc) lines.push(`用药起算 ${anchorMd}(${anchorSrc.replace(/起$/, '')})`)
|
||||||
|
if (!isRevisitDaysMissing(due)) lines.push(`开药天数来源:${revisitDaysSrcText(due.days_src)}`)
|
||||||
|
if (toInt(due.missed_count) > 0) lines.push(`曾过号 ${toInt(due.missed_count)} 次`)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读页徽标文案:「第 N 诊 · 药尽 MM-DD · 超期 N 天」
|
||||||
|
* 无 due_date 时省略中段;尾段为状态 chip 文案。
|
||||||
|
*/
|
||||||
|
export function revisitBadgeText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const parts = [revisitSlotText(due), revisitDueDateText(due), revisitStateChipText(due)]
|
||||||
|
return parts.filter((s) => s !== '').join(' · ')
|
||||||
|
}
|
||||||
@@ -56,6 +56,70 @@
|
|||||||
/>
|
/>
|
||||||
<el-empty v-else description="暂无数据" />
|
<el-empty v-else description="暂无数据" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 我的复诊接诊率(方案 §10 D1-h):仅本人行,口径与「复诊接诊率」报表一致 -->
|
||||||
|
<el-card v-if="canViewRevisitRate" class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-left">
|
||||||
|
<span class="card-title">我的复诊接诊率</span>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="revisitMonth"
|
||||||
|
type="month"
|
||||||
|
value-format="YYYY-MM"
|
||||||
|
placeholder="选择月份"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled-date="disableFutureMonth"
|
||||||
|
size="small"
|
||||||
|
style="width: 130px"
|
||||||
|
@change="fetchRevisitRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="card-hint">{{ revisitMonthText }} · 与「复诊接诊率」报表同口径</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-loading="revisitLoading" class="revisit-rate-body">
|
||||||
|
<template v-if="revisitRow">
|
||||||
|
<div class="revisit-kpi-grid">
|
||||||
|
<div class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">新指派数</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ Number(revisitRow.assigned_count ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">当月新指派给我的患者</div>
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">继承数</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ Number(revisitRow.inherit_count ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">当月继承给我的患者</div>
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-card is-primary">
|
||||||
|
<div class="stats-kpi-label">复诊合计接诊率</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ formatRate(revisitRow.visit_all_rate) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">{{ visitAllFractionText }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="slot in revisitSlots" :key="slot" class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">{{ slotLabel(slot) }}接诊率</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ formatRate(slotMetric(slot, 'rate')) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">{{ slotFractionText(slot) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else :description="revisitEmptyText" :image-size="72" />
|
||||||
|
<div class="revisit-caliber">
|
||||||
|
口径:N 诊接诊率 = 当月 N 诊成交单数 ÷ N 诊分母(新指派数 + 起算诊次 ≤ N 的继承数),分母为 0 显示「—」;
|
||||||
|
<template v-if="revisitInheritMode === 'exclude'">当前为旧口径,继承患者不计入分母;</template>
|
||||||
|
<template v-else>继承患者按继承时诊次从第 k+1 诊起计入;</template>
|
||||||
|
复诊合计 = 各诊次成交单数之和 ÷ 被指派合计(新指派 ∪ 继承),可超过 100%。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,7 +127,12 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import vCharts from 'vue-echarts'
|
import vCharts from 'vue-echarts'
|
||||||
import { assistantPerformanceOverview } from '@/api/stats'
|
import {
|
||||||
|
assistantPerformanceOverview,
|
||||||
|
assistantPerformanceRevisitRate,
|
||||||
|
type AssistantRevisitRateResult
|
||||||
|
} from '@/api/stats'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dateRange = ref<string[]>([])
|
const dateRange = ref<string[]>([])
|
||||||
@@ -191,8 +260,78 @@ const handleReset = () => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 我的复诊接诊率(方案 §10 D1-h)────────────────────────
|
||||||
|
/** 权限点由 server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql 登记并自动授予已有个人业绩页的角色 */
|
||||||
|
const canViewRevisitRate = hasPermission(['stats.assistantPerformance/revisitRate'])
|
||||||
|
|
||||||
|
const initNow = new Date()
|
||||||
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const revisitMonth = ref<string>(currentMonth)
|
||||||
|
const revisitLoading = ref(false)
|
||||||
|
const revisitResult = ref<AssistantRevisitRateResult | null>(null)
|
||||||
|
|
||||||
|
const revisitRow = computed(() => revisitResult.value?.row ?? null)
|
||||||
|
const revisitInheritMode = computed(() => revisitResult.value?.inherit_mode ?? 'slot')
|
||||||
|
const revisitSlots = computed(() =>
|
||||||
|
(revisitResult.value?.slots ?? []).map((s) => Number(s)).filter((s) => s >= 2)
|
||||||
|
)
|
||||||
|
const revisitMonthText = computed(() => {
|
||||||
|
const [y, m] = (revisitResult.value?.month || revisitMonth.value).split('-')
|
||||||
|
return y && m ? `${y} 年 ${Number(m)} 月` : revisitMonth.value
|
||||||
|
})
|
||||||
|
/** row=null:非二中心医助(in_scope=false)与「当月无被指派患者」文案区分 */
|
||||||
|
const revisitEmptyText = computed(() => {
|
||||||
|
if (revisitResult.value && revisitResult.value.in_scope === false) {
|
||||||
|
return '暂无数据(仅二中心医助统计)'
|
||||||
|
}
|
||||||
|
return revisitMonth.value === currentMonth ? '本月暂无被指派患者' : '该月暂无被指派患者'
|
||||||
|
})
|
||||||
|
|
||||||
|
const disableFutureMonth = (date: Date) => {
|
||||||
|
const now = new Date()
|
||||||
|
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot >= 2 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||||
|
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||||
|
return `${slot}诊`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRate = (rate: unknown) =>
|
||||||
|
rate === null || rate === undefined || rate === '' ? '—' : `${Number(rate)}%`
|
||||||
|
|
||||||
|
/** 读取本人行的 visit{N}_count / visit{N}_den / visit{N}_rate */
|
||||||
|
const slotMetric = (slot: number, kind: 'count' | 'den' | 'rate') =>
|
||||||
|
revisitRow.value?.[`visit${slot}_${kind}`]
|
||||||
|
|
||||||
|
/** 「成交 N 单 / 分母 M」 */
|
||||||
|
const fractionText = (count: unknown, den: unknown) =>
|
||||||
|
`成交 ${Number(count ?? 0)} 单 / 分母 ${Number(den ?? 0)}`
|
||||||
|
|
||||||
|
const slotFractionText = (slot: number) =>
|
||||||
|
fractionText(slotMetric(slot, 'count'), slotMetric(slot, 'den'))
|
||||||
|
|
||||||
|
const visitAllFractionText = computed(() =>
|
||||||
|
fractionText(revisitRow.value?.visit_all_count, revisitRow.value?.visit_all_den)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchRevisitRate = async () => {
|
||||||
|
if (!canViewRevisitRate) return
|
||||||
|
revisitLoading.value = true
|
||||||
|
try {
|
||||||
|
revisitResult.value = await assistantPerformanceRevisitRate({ month: revisitMonth.value })
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 utils/request 统一弹出;失败时保留上一次结果
|
||||||
|
} finally {
|
||||||
|
revisitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
fetchRevisitRate()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -270,4 +409,57 @@ onMounted(() => {
|
|||||||
.stats-chart {
|
.stats-chart {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-rate-body {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f7f9ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card.is-primary {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #eef3ff);
|
||||||
|
border-color: #d9e4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card.is-primary .revisit-kpi-value {
|
||||||
|
color: #4a78ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-caliber {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<div class="log-hint">
|
<div class="log-hint">
|
||||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
|
||||||
|
<br />
|
||||||
|
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
|
||||||
|
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -137,7 +140,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="Number(row.assistant_id) > 0">
|
<template v-if="Number(row.assistant_id) > 0">
|
||||||
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
<div class="cell-sub">上月二诊接诊率 {{ formatRate(tierRate(row)) }}</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -145,6 +148,29 @@
|
|||||||
<el-table-column label="档位" width="100" align="center">
|
<el-table-column label="档位" width="100" align="center">
|
||||||
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="分档口径" min-width="150">
|
||||||
|
<template #header>
|
||||||
|
<span>分档口径</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="本批次分档所用口径;下方为两套口径的上月二诊接诊率(新口径 = 每列独立分母 / 旧口径 = 分母仅新指派)"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.action) === 1">
|
||||||
|
<el-tag :type="row.rate_version === 'v2' ? 'primary' : 'info'" size="small">
|
||||||
|
{{ row.rate_version_text || row.rate_version || 'v1' }}
|
||||||
|
</el-tag>
|
||||||
|
<div class="cell-sub">
|
||||||
|
新口径 {{ formatRate(row.visit2_rate) }} · 旧口径 {{ formatRate(row.visit2_rate_new_assign) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="cell-sub">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="轮次" width="70" align="center">
|
<el-table-column label="轮次" width="70" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
||||||
@@ -186,6 +212,7 @@
|
|||||||
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
const dateRange = ref<[string, string] | null>(null)
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
@@ -241,6 +268,14 @@ const handleReset = () => {
|
|||||||
const formatRate = (rate: number | null | undefined) =>
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
/** 「医助」列显示的接诊率 = 本批次分档所用口径的率(v1 旧口径取 visit2_rate_new_assign,历史行无该列时回落 visit2_rate) */
|
||||||
|
const tierRate = (row: any): number | null | undefined => {
|
||||||
|
if (row?.rate_version === 'v1' && row?.visit2_rate_new_assign !== null && row?.visit2_rate_new_assign !== undefined) {
|
||||||
|
return row.visit2_rate_new_assign
|
||||||
|
}
|
||||||
|
return row?.visit2_rate
|
||||||
|
}
|
||||||
|
|
||||||
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
||||||
|
|
||||||
const onSelectionChange = (rows: any[]) => {
|
const onSelectionChange = (rows: any[]) => {
|
||||||
@@ -315,5 +350,12 @@ onMounted(() => getLists())
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
<template>
|
||||||
|
<div class="revisit-due-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="due-filter-form">
|
||||||
|
<el-form-item label="医助">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.assistant_id"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[160px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in assistantOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="Number(item.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
placeholder="全部(二中心)"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
check-strictly
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
class="w-[200px]"
|
||||||
|
@change="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.state"
|
||||||
|
clearable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[170px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in stateOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开药天数">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.days_bucket"
|
||||||
|
clearable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[120px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in REVISIT_DAYS_BUCKET_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="延迟次数 ≥">
|
||||||
|
<el-input-number
|
||||||
|
v-model="minSnooze"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
:step="1"
|
||||||
|
step-strictly
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="不限"
|
||||||
|
class="!w-[120px]"
|
||||||
|
@change="onMinSnoozeChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="患者">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="患者姓名"
|
||||||
|
clearable
|
||||||
|
class="w-[160px]"
|
||||||
|
@keyup.enter="doSearch"
|
||||||
|
@clear="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<export-data
|
||||||
|
class="ml-2.5"
|
||||||
|
:fetch-fun="revisitDueLists"
|
||||||
|
:params="queryParams"
|
||||||
|
:page-size="pager.size"
|
||||||
|
export-hint="导出范围与当前筛选一致(医助 / 部门 / 状态 / 开药天数 / 延迟次数 / 患者)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="due-hint">
|
||||||
|
每位患者(诊单)一行,取最新有效业务订单为本轮周期:预计复诊日 = 用药起算日(签收 → 发货 → 下单)+ 开药天数;
|
||||||
|
到期仍无有效挂号 / 过号未面诊的行标红,持有医助登录后台会收到右下角弹窗。
|
||||||
|
「延迟次数」为医助在弹窗上点「延迟」的累计次数(越多说明越未跟进),「已联系」为填写患者承诺复诊日的次数,
|
||||||
|
「人工关闭」由医助填原因关闭、在此标记供主管复核。数据范围按角色数据权限收窄;状态按今天实时推导。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table
|
||||||
|
:data="pager.lists"
|
||||||
|
v-loading="pager.loading"
|
||||||
|
size="default"
|
||||||
|
stripe
|
||||||
|
row-key="id"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column label="患者" min-width="120" fixed="left">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ row.patient_name || '—' }}</div>
|
||||||
|
<div class="cell-sub">诊单 #{{ row.diagnosis_id }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="医助" min-width="100">
|
||||||
|
<template #default="{ row }">{{ assistantText(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="部门" min-width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.dept_name || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="诊次" width="110" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>诊次</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="患者当前已完成诊次 = 有效业务订单数 + 诊单诊次偏移,与复诊接诊率同口径;下一行为待复诊诊次"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ slotText(row) }}</div>
|
||||||
|
<div v-if="Number(row.next_slot) > 0" class="cell-sub">
|
||||||
|
待复诊第 {{ row.next_slot }} 诊
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开药天数" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.medication_days) > 0">
|
||||||
|
<span>{{ row.medication_days }} 天</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(row.rule_days) > 0"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
{{ row.rule_days }} 档
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-missing">缺开药天数</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="预计复诊" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ row.due_date || '—' }}</div>
|
||||||
|
<div v-if="row.anchor_date" class="cell-sub">
|
||||||
|
起算 {{ formatRevisitMonthDay(row.anchor_date) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="超期天数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['overdue-cell', overdueCell(row).cls]">
|
||||||
|
{{ overdueCell(row).text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="revisitStateTagType(row.state)"
|
||||||
|
:effect="revisitStateTagEffect(row.state)"
|
||||||
|
size="small"
|
||||||
|
:class="revisitStateChipClass(dueOf(row))"
|
||||||
|
>
|
||||||
|
{{ chipText(row) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(row.closed_state) === 4"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
人工关闭
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="延迟次数" width="100" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>延迟次数</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="医助在到期弹窗上点「延迟」的累计次数;1~2 次 L1、3~4 次 L2、≥5 次 L3(居中强制模态)。换医助后清零"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['snooze-cell', snoozeClass(row)]">
|
||||||
|
{{ Number(row.snooze_count ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="Number(row.escalation_level) > 0" class="cell-sub">
|
||||||
|
等级 L{{ row.escalation_level }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已联系" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.contact_count ?? 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="承诺日" width="100">
|
||||||
|
<template #default="{ row }">{{ row.promised_date || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过号" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.missed_count ?? 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关闭原因" min-width="170" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.closed_state) === 4">
|
||||||
|
<span>{{ handleReasonText(row.handle_reason) }}</span>
|
||||||
|
<span v-if="row.handle_remark" class="cell-sub">
|
||||||
|
:{{ row.handle_remark }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="Number(row.closed_state) > 0" class="cell-sub">
|
||||||
|
{{ row.closed_state_text || '—' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.revisitDue/logs']"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openLogs(row)"
|
||||||
|
>
|
||||||
|
流水
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="primary" @click="goDiagnosis(row)">去诊单</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="logVisible"
|
||||||
|
:title="logTitle"
|
||||||
|
direction="rtl"
|
||||||
|
size="540px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="logLoading" class="log-drawer-body">
|
||||||
|
<div v-if="logRow" class="log-summary">
|
||||||
|
<div class="log-summary-row">
|
||||||
|
<span>医助:{{ assistantText(logRow) }}</span>
|
||||||
|
<span>{{ slotText(logRow) }}</span>
|
||||||
|
<span v-if="logRow.due_date">预计复诊 {{ logRow.due_date }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-summary-row">
|
||||||
|
<el-tag
|
||||||
|
:type="revisitStateTagType(logRow.state)"
|
||||||
|
:effect="revisitStateTagEffect(logRow.state)"
|
||||||
|
size="small"
|
||||||
|
:class="revisitStateChipClass(dueOf(logRow))"
|
||||||
|
>
|
||||||
|
{{ chipText(logRow) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="cell-sub">{{ followCountText(logRow) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasShownMeta(logRow)" class="log-summary-row cell-sub">
|
||||||
|
<span v-if="logRow.first_shown_at_text">
|
||||||
|
首次弹出 {{ logRow.first_shown_at_text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="logRow.last_snoozed_at_text">
|
||||||
|
最近延迟 {{ logRow.last_snoozed_at_text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="logRow.computed_at_text">
|
||||||
|
重算于 {{ logRow.computed_at_text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-timeline v-if="logRows.length" class="mt-3">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="item in logRows"
|
||||||
|
:key="item.id"
|
||||||
|
:timestamp="item.create_time_text"
|
||||||
|
placement="top"
|
||||||
|
:type="eventTimelineType(item.event)"
|
||||||
|
:hollow="!isUserEvent(item.event)"
|
||||||
|
>
|
||||||
|
<div class="log-event">
|
||||||
|
<span class="log-event-title">
|
||||||
|
{{ item.event_text || eventLabel(item.event) }}
|
||||||
|
</span>
|
||||||
|
<span class="log-event-operator">{{ operatorText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-event-meta">
|
||||||
|
<span>延迟 {{ Number(item.snooze_count_after ?? 0) }} 次</span>
|
||||||
|
<span>等级 L{{ Number(item.level_after ?? 0) }}</span>
|
||||||
|
<span>{{ logOverdueText(item.overdue_days) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="payloadEntries(item).length" class="log-event-payload">
|
||||||
|
<span
|
||||||
|
v-for="p in payloadEntries(item)"
|
||||||
|
:key="p.key"
|
||||||
|
class="log-payload-chip"
|
||||||
|
>
|
||||||
|
{{ p.label }}:{{ p.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
<el-empty v-else-if="!logLoading" description="暂无流水" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="statsRevisitDue">
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
revisitDueLists,
|
||||||
|
revisitDueLogs,
|
||||||
|
type RevisitDueListRow,
|
||||||
|
type RevisitDueListsParams,
|
||||||
|
type RevisitDueLogRow
|
||||||
|
} from '@/api/revisitDue'
|
||||||
|
import { revisitRateDeptOptions } from '@/api/stats'
|
||||||
|
import { getAssistants } from '@/api/tcm'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import {
|
||||||
|
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||||
|
REVISIT_STATE_FILTER_OPTIONS,
|
||||||
|
formatRevisitMonthDay,
|
||||||
|
normalizeRevisitDue,
|
||||||
|
revisitRowClassName,
|
||||||
|
revisitStateChipClass,
|
||||||
|
revisitStateChipText,
|
||||||
|
revisitStateTagEffect,
|
||||||
|
revisitStateTagType,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
|
interface DeptTreeNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
pid: number
|
||||||
|
children?: DeptTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** 状态筛选:诊单列表的 7 个状态 + 主管视图专有「人工关闭」 */
|
||||||
|
const stateOptions = [...REVISIT_STATE_FILTER_OPTIONS, { label: '人工关闭', value: 'closed' }]
|
||||||
|
|
||||||
|
const assistantOptions = ref<Array<{ id: number; name: string }>>([])
|
||||||
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const queryParams = reactive<RevisitDueListsParams>({
|
||||||
|
assistant_id: '',
|
||||||
|
dept_id: '',
|
||||||
|
state: '',
|
||||||
|
days_bucket: '',
|
||||||
|
min_snooze: '',
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
/** el-input-number 清空时为 null/undefined,单独持有后再写回 queryParams 的 '' */
|
||||||
|
const minSnooze = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: revisitDueLists,
|
||||||
|
params: queryParams
|
||||||
|
})
|
||||||
|
|
||||||
|
const doSearch = () => resetPage()
|
||||||
|
|
||||||
|
const onMinSnoozeChange = (val: number | undefined | null) => {
|
||||||
|
queryParams.min_snooze = val === null || val === undefined ? '' : Number(val)
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.assistant_id = ''
|
||||||
|
queryParams.dept_id = ''
|
||||||
|
queryParams.state = ''
|
||||||
|
queryParams.days_bucket = ''
|
||||||
|
queryParams.min_snooze = ''
|
||||||
|
queryParams.keyword = ''
|
||||||
|
minSnooze.value = undefined
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 单元格 ──────────────────────────────────────────
|
||||||
|
/** 列表行 → 复诊节点对象(复用 utils/revisitDue 的 chip 映射;缺失字段按默认值补齐) */
|
||||||
|
const dueOf = (row: RevisitDueListRow | Record<string, any>): RevisitDue | null =>
|
||||||
|
normalizeRevisitDue(row)
|
||||||
|
|
||||||
|
const rowClassName = ({ row }: { row: RevisitDueListRow }) => revisitRowClassName(dueOf(row))
|
||||||
|
|
||||||
|
const assistantText = (row: RevisitDueListRow) =>
|
||||||
|
row.assistant_name || (Number(row.assistant_id) > 0 ? `#${row.assistant_id}` : '—')
|
||||||
|
|
||||||
|
const slotText = (row: RevisitDueListRow) => {
|
||||||
|
const slot = Number(row.current_slot ?? 0)
|
||||||
|
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipText = (row: RevisitDueListRow) =>
|
||||||
|
revisitStateChipText(dueOf(row)) || row.state_text || '—'
|
||||||
|
|
||||||
|
/** 抽屉摘要:「延迟 N 次 · 已联系 N 次 · 过号 N 次」 */
|
||||||
|
const followCountText = (row: RevisitDueListRow) => {
|
||||||
|
const snooze = Number(row.snooze_count ?? 0)
|
||||||
|
const contact = Number(row.contact_count ?? 0)
|
||||||
|
const missed = Number(row.missed_count ?? 0)
|
||||||
|
return `延迟 ${snooze} 次 · 已联系 ${contact} 次 · 过号 ${missed} 次`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasShownMeta = (row: RevisitDueListRow) =>
|
||||||
|
!!(row.first_shown_at_text || row.last_snoozed_at_text || row.computed_at_text)
|
||||||
|
|
||||||
|
const toNullableNumber = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超期天数列:到期/过号红字(深红 = severe),其它状态显示剩余天数或历史超期 */
|
||||||
|
const overdueCell = (row: RevisitDueListRow): { text: string; cls: string } => {
|
||||||
|
const od = toNullableNumber(row.overdue_days)
|
||||||
|
const dl = toNullableNumber(row.days_left)
|
||||||
|
const isDue = row.state === 'overdue' || row.state === 'missed'
|
||||||
|
if (isDue && od !== null && od >= 0) {
|
||||||
|
return {
|
||||||
|
text: od === 0 ? '今日到期' : `${od} 天`,
|
||||||
|
cls: Number(row.severe) === 1 ? 'is-severe' : 'is-overdue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (od !== null && od > 0) {
|
||||||
|
return { text: `${od} 天`, cls: 'is-muted' }
|
||||||
|
}
|
||||||
|
if (dl !== null && dl >= 0) {
|
||||||
|
return { text: `剩 ${dl} 天`, cls: 'is-muted' }
|
||||||
|
}
|
||||||
|
return { text: '—', cls: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const snoozeClass = (row: RevisitDueListRow) => {
|
||||||
|
const level = Number(row.escalation_level ?? 0)
|
||||||
|
if (level >= 3) return 'is-level-3'
|
||||||
|
if (level === 2) return 'is-level-2'
|
||||||
|
if (level === 1) return 'is-level-1'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReasonText = (reason: string | null | undefined) => {
|
||||||
|
const key = String(reason ?? '').trim()
|
||||||
|
if (key === 'lost') return '患者流失'
|
||||||
|
if (key === 'other') return '其它'
|
||||||
|
return key || '人工关闭'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 流水抽屉 ──────────────────────────────────────────
|
||||||
|
const logVisible = ref(false)
|
||||||
|
const logLoading = ref(false)
|
||||||
|
const logRow = ref<RevisitDueListRow | null>(null)
|
||||||
|
const logRows = ref<RevisitDueLogRow[]>([])
|
||||||
|
|
||||||
|
const logTitle = computed(() =>
|
||||||
|
logRow.value ? `${logRow.value.patient_name || '患者'} · 复诊到期流水` : '复诊到期流水'
|
||||||
|
)
|
||||||
|
|
||||||
|
const EVENT_LABEL: Record<number, string> = {
|
||||||
|
1: '延迟',
|
||||||
|
2: '已联系',
|
||||||
|
3: '人工关闭',
|
||||||
|
4: '自动关闭 · 已挂号',
|
||||||
|
5: '自动关闭 · 已面诊',
|
||||||
|
6: '周期重置(新订单)',
|
||||||
|
7: '重新打开(挂号取消 / 过号)',
|
||||||
|
8: '企微推送',
|
||||||
|
9: '医助变更',
|
||||||
|
10: '形态升级',
|
||||||
|
11: '首次弹出'
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventLabel = (event: number) => EVENT_LABEL[Number(event)] ?? `事件 ${event}`
|
||||||
|
|
||||||
|
/** 1 延迟 / 2 已联系 / 3 人工关闭 由医助操作,实心节点;其余系统事件空心 */
|
||||||
|
const isUserEvent = (event: number) => [1, 2, 3].includes(Number(event))
|
||||||
|
|
||||||
|
type TimelineType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
|
||||||
|
const eventTimelineType = (event: number): TimelineType => {
|
||||||
|
switch (Number(event)) {
|
||||||
|
case 1:
|
||||||
|
return 'warning'
|
||||||
|
case 2:
|
||||||
|
case 6:
|
||||||
|
return 'primary'
|
||||||
|
case 4:
|
||||||
|
case 5:
|
||||||
|
return 'success'
|
||||||
|
case 7:
|
||||||
|
case 10:
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operatorText = (item: RevisitDueLogRow) => {
|
||||||
|
if (item.operator_name) return item.operator_name
|
||||||
|
return Number(item.operator_id) > 0 ? `#${item.operator_id}` : '系统'
|
||||||
|
}
|
||||||
|
|
||||||
|
const logOverdueText = (days: number | null | undefined) => {
|
||||||
|
const n = toNullableNumber(days)
|
||||||
|
if (n === null) return ''
|
||||||
|
if (n > 0) return `超期 ${n} 天`
|
||||||
|
if (n === 0) return '到期当天'
|
||||||
|
return `未到期(剩 ${-n} 天)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD_LABEL: Record<string, string> = {
|
||||||
|
next_remind_at: '下次提醒',
|
||||||
|
next_remind_at_text: '下次提醒',
|
||||||
|
promised_date: '承诺日',
|
||||||
|
appointment_id: '挂号单',
|
||||||
|
appointment_date: '预约日',
|
||||||
|
order_id: '订单',
|
||||||
|
source_order_id: '周期订单',
|
||||||
|
remark: '备注',
|
||||||
|
reason: '原因',
|
||||||
|
from_assistant: '原医助',
|
||||||
|
from_assistant_id: '原医助',
|
||||||
|
to_assistant: '新医助',
|
||||||
|
to_assistant_id: '新医助',
|
||||||
|
interval: '间隔(秒)',
|
||||||
|
level: '等级',
|
||||||
|
channel: '渠道',
|
||||||
|
message: '说明'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPayloadValue = (key: string, value: unknown): string => {
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
// 秒级时间戳(next_remind_at 等)转本地时间
|
||||||
|
if (/_at$/.test(key) && typeof value === 'number' && value > 1e9) {
|
||||||
|
const d = new Date(value * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayloadEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload_arr → [{key,label,value}],有 *_text 版本时隐藏对应原始时间戳 */
|
||||||
|
const payloadEntries = (item: RevisitDueLogRow): PayloadEntry[] => {
|
||||||
|
const payload = item.payload_arr
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
|
||||||
|
const out: PayloadEntry[] = []
|
||||||
|
for (const [key, raw] of Object.entries(payload)) {
|
||||||
|
if (`${key}_text` in payload) continue
|
||||||
|
const value = formatPayloadValue(key, raw)
|
||||||
|
if (value === '') continue
|
||||||
|
out.push({ key, label: PAYLOAD_LABEL[key] ?? key, value })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLogs = async (row: RevisitDueListRow) => {
|
||||||
|
logRow.value = row
|
||||||
|
logVisible.value = true
|
||||||
|
logLoading.value = true
|
||||||
|
logRows.value = []
|
||||||
|
try {
|
||||||
|
// 契约「返回 list」:兼容直接返回数组或 { list } / { lists } 包络
|
||||||
|
const res: any = await revisitDueLogs({ due_id: Number(row.id) })
|
||||||
|
const list = Array.isArray(res) ? res : (res?.list ?? res?.lists ?? [])
|
||||||
|
logRows.value = Array.isArray(list) ? list : []
|
||||||
|
} finally {
|
||||||
|
logLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDiagnosis = (row: RevisitDueListRow) => {
|
||||||
|
const id = Number(row.diagnosis_id ?? 0)
|
||||||
|
if (id <= 0) return
|
||||||
|
router.push({ path: '/tcm/diagnosis', query: { id: String(id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 下拉数据 ──────────────────────────────────────────
|
||||||
|
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||||
|
const map = new Map<number, DeptTreeNode>()
|
||||||
|
for (const d of flat) {
|
||||||
|
map.set(Number(d.id), { id: Number(d.id), pid: Number(d.pid), name: d.name, children: [] })
|
||||||
|
}
|
||||||
|
const roots: DeptTreeNode[] = []
|
||||||
|
for (const d of flat) {
|
||||||
|
const node = map.get(Number(d.id))!
|
||||||
|
if (Number(d.pid) > 0 && map.has(Number(d.pid))) {
|
||||||
|
map.get(Number(d.pid))!.children!.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strip = (n: DeptTreeNode) => {
|
||||||
|
if (n.children?.length) {
|
||||||
|
n.children.forEach(strip)
|
||||||
|
} else {
|
||||||
|
delete n.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.forEach(strip)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
const [assistants, depts] = await Promise.allSettled([
|
||||||
|
getAssistants(),
|
||||||
|
revisitRateDeptOptions()
|
||||||
|
])
|
||||||
|
if (assistants.status === 'fulfilled') {
|
||||||
|
assistantOptions.value = Array.isArray(assistants.value) ? assistants.value : []
|
||||||
|
}
|
||||||
|
if (depts.status === 'fulfilled') {
|
||||||
|
deptTreeOptions.value = buildDeptTree(depts.value?.rows ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 下拉与列表并行加载,不阻塞首屏
|
||||||
|
void loadOptions()
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.revisit-due-page {
|
||||||
|
.due-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-main {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-missing {
|
||||||
|
color: #ea580c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-overdue {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-severe {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-muted {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-1 {
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-2 {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-3 {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行级标红:与诊单列表一致(utils/revisitDue.ts revisitRowClassName) */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue) {
|
||||||
|
border-left: 3px solid var(--el-color-danger);
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 chip 配色:与 RevisitNodeCell 一致 */
|
||||||
|
:deep(.rv-chip) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--overdue),
|
||||||
|
:deep(.rv-chip--missed) {
|
||||||
|
--el-tag-bg-color: #dc2626;
|
||||||
|
--el-tag-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--severe) {
|
||||||
|
font-weight: 700;
|
||||||
|
--el-tag-bg-color: #b91c1c;
|
||||||
|
--el-tag-border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--warning) {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||||
|
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--no_days) {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-border-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--visited) {
|
||||||
|
--el-tag-text-color: #16a34a;
|
||||||
|
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||||
|
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--booked) {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||||
|
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-drawer-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-operator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-payload {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-payload-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,12 +29,18 @@
|
|||||||
@change="loadData"
|
@change="loadData"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="口径">
|
||||||
|
<el-radio-group v-model="inheritMode" size="small" @change="loadData">
|
||||||
|
<el-radio-button value="exclude">原口径(继承不计分母)</el-radio-button>
|
||||||
|
<el-radio-button value="slot">新口径(继承按继承时诊次计入)</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
<el-popover placement="bottom-start" :width="560" trigger="hover">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<span class="rate-caliber-trigger">
|
<span class="rate-caliber-trigger">
|
||||||
<el-icon><InfoFilled /></el-icon>
|
<el-icon><InfoFilled /></el-icon>
|
||||||
@@ -43,16 +49,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="rate-caliber">
|
<div class="rate-caliber">
|
||||||
<p>
|
<p>
|
||||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
<b>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>不含勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。与业绩看板「被指派数」同口径。
|
||||||
|
</p>
|
||||||
|
<template v-if="isSlotMode">
|
||||||
|
<p>
|
||||||
|
<b>继承数</b>:当月勾选「继承」转接给该医助的诊单数(同一诊单多次继承取最早;同月既新指派又继承给同一医助只算新指派;历史「回退自动分配」行按继承处理;指派给自己的记录忽略)。<b>继承时诊次 k</b> = 继承时刻之前患者已完成的诊次(有效实单数 + 偏移),<b>起算诊次 = max(2, k+1)</b>:继承者只对第 k+1 诊及以后负责。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
<b>N 诊分母</b> = 新指派数 + 起算诊次 ≤ N 的继承数(每列独立);<b>N 诊接诊率</b> = N 诊单数 ÷ N 诊分母,分母为 0 显示「—」。<b>复诊合计</b>:单数 = 各诊次单数之和,分母 = 新指派 ∪ 继承(被指派合计),比率可超过 100%(人均复诊成交单数)。
|
||||||
|
</p>
|
||||||
|
<p class="rate-caliber-note">
|
||||||
|
继承分母依赖指派记录的「继承」标记(2026-06 起写入),之前的转接无法回溯;切换为「旧口径」可对照上线前数字。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>
|
||||||
|
<b>旧口径</b>:继承指派不计入分母,N 诊分母 = 新指派数(对所有 N 相同),新指派数 / N 诊单数 / N 诊接诊率与 2026-10 之前完全一致;继承数恒为 0。切换为「新口径」后继承按继承时诊次计入每列独立分母。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p>
|
||||||
|
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||||
@@ -81,7 +100,9 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="rate-card-header">
|
<div class="rate-card-header">
|
||||||
<span>部门 · 医助明细({{ monthText }})</span>
|
<span>部门 · 医助明细({{ monthText }})</span>
|
||||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
<span class="rate-card-hint">
|
||||||
|
点击「新指派数 / 继承数 / 被指派合计 / N诊单数 / 复诊合计单数」可查看具体诊单与订单;接诊率下方灰字为「单数 / 分母」;底栏合计按诊单去重
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table
|
<el-table
|
||||||
@@ -104,19 +125,88 @@
|
|||||||
<span v-else>{{ row.assistant_name }}</span>
|
<span v-else>{{ row.assistant_name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
<el-table-column
|
||||||
|
prop="assigned_count"
|
||||||
|
:label="isSlotMode ? '新指派数' : '当月被指派数'"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="当月非继承指派的「医助 × 诊单」去重数(剔除名下有拒收/退款订单的诊单),与业绩看板「被指派数」同口径"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link
|
<el-link
|
||||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||||
type="primary"
|
type="primary"
|
||||||
:underline="false"
|
:underline="false"
|
||||||
@click="openAssignDetail(row)"
|
@click="openAssignDetail(row, 'new')"
|
||||||
>
|
>
|
||||||
{{ row.assigned_count }}
|
{{ row.assigned_count }}
|
||||||
</el-link>
|
</el-link>
|
||||||
<span v-else>{{ row.assigned_count }}</span>
|
<span v-else>{{ row.assigned_count }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSlotMode"
|
||||||
|
prop="inherit_count"
|
||||||
|
label="继承数"
|
||||||
|
min-width="88"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>继承数</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="当月勾选「继承」转接给该医助的诊单数(按继承时诊次 k 从第 k+1 诊起计入分母;点击查看每条的继承时诊次与起算诊次)"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.inherit_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row, 'inherit')"
|
||||||
|
>
|
||||||
|
{{ row.inherit_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.inherit_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSlotMode"
|
||||||
|
prop="assigned_total_count"
|
||||||
|
label="被指派合计"
|
||||||
|
min-width="100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>被指派合计</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="新指派 ∪ 继承(按诊单去重),即「复诊合计接诊率」的分母">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.assigned_total_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row, 'all')"
|
||||||
|
>
|
||||||
|
{{ row.assigned_total_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.assigned_total_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<template v-for="slot in visitSlots" :key="slot">
|
<template v-for="slot in visitSlots" :key="slot">
|
||||||
<el-table-column
|
<el-table-column
|
||||||
:prop="`visit${slot}_count`"
|
:prop="`visit${slot}_count`"
|
||||||
@@ -149,12 +239,64 @@
|
|||||||
<el-table-column
|
<el-table-column
|
||||||
:prop="`visit${slot}_rate`"
|
:prop="`visit${slot}_rate`"
|
||||||
:label="`${slotLabel(slot)}接诊率`"
|
:label="`${slotLabel(slot)}接诊率`"
|
||||||
min-width="96"
|
min-width="104"
|
||||||
align="right"
|
align="right"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" :content="rateHeaderTip(slot)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row[`visit${slot}_rate`]) }}</span>
|
||||||
|
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, `visit${slot}`) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column prop="visit_all_count" label="复诊合计单数" min-width="110" align="right">
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计单数</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="当月内下单、诊次 ≥ 2 的订单数之和(= 各 N 诊单数之和)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(0, row)"
|
||||||
|
>
|
||||||
|
{{ row.visit_all_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="visit_all_rate" label="复诊合计接诊率" min-width="118" align="right">
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计接诊率</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="
|
||||||
|
isSlotMode
|
||||||
|
? '复诊合计单数 ÷ 被指派合计(新指派 ∪ 继承);可超过 100%,语义为人均复诊成交单数'
|
||||||
|
: '复诊合计单数 ÷ 新指派数(旧口径继承不计分母);可超过 100%'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span>
|
||||||
|
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -162,7 +304,7 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:title="detailTitle"
|
:title="detailTitle"
|
||||||
width="980px"
|
width="1080px"
|
||||||
top="6vh"
|
top="6vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
@@ -193,6 +335,33 @@
|
|||||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column v-if="isSlotMode" prop="assign_type" label="指派类型" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.assign_type === 'inherit' ? 'warning' : 'success'" size="small">
|
||||||
|
{{ row.assign_type_text || (row.assign_type === 'inherit' ? '继承' : '新指派') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="isSlotMode" prop="inherit_visit_slot" label="继承时诊次" width="100" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>继承时诊次</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="k:继承时刻之前患者已完成的诊次(有效实单数 + 偏移);新指派无此值">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.assign_type === 'inherit' ? `第 ${Number(row.inherit_visit_slot ?? 0)} 诊` : '—' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="entry_slot" label="起算诊次" width="96" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>起算诊次</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="从该诊次列起计入分母:继承 = max(2, k+1);新指派 = 2(二诊)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">{{ entrySlotText(row.entry_slot) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||||
</template>
|
</template>
|
||||||
@@ -210,6 +379,9 @@
|
|||||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column v-if="detailSlot === 0" prop="visit_slot" label="诊次" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ slotLabel(Number(row.visit_slot)) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -251,7 +423,9 @@ import {
|
|||||||
revisitRateAssignLines,
|
revisitRateAssignLines,
|
||||||
revisitRateDeptOptions,
|
revisitRateDeptOptions,
|
||||||
revisitRateOverview,
|
revisitRateOverview,
|
||||||
revisitRateVisitOrderLines
|
revisitRateVisitOrderLines,
|
||||||
|
type RevisitRateAssignKind,
|
||||||
|
type RevisitRateInheritMode
|
||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
@@ -263,7 +437,14 @@ const OrderDetailDrawer = defineAsyncComponent(
|
|||||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
interface MetricPack extends Record<string, any> {
|
interface MetricPack extends Record<string, any> {
|
||||||
|
/** 新指派数(语义不变) */
|
||||||
assigned_count: number
|
assigned_count: number
|
||||||
|
inherit_count?: number
|
||||||
|
assigned_total_count?: number
|
||||||
|
visit_all_count?: number
|
||||||
|
visit_all_den?: number
|
||||||
|
visit_all_rate?: number | null
|
||||||
|
visit2_rate_new_assign?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsRow extends MetricPack {
|
interface StatsRow extends MetricPack {
|
||||||
@@ -289,11 +470,14 @@ const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
|||||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||||
|
|
||||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
/** 接口动态返回的诊次分档(最少 2~4,按分子最大诊次 / 最大起算诊次扩展到五诊、六诊…) */
|
||||||
const visitSlots = ref<number[]>([2, 3, 4])
|
const visitSlots = ref<number[]>([2, 3, 4])
|
||||||
|
|
||||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
const slotLabel = (slot: number) => {
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot === 0) {
|
||||||
|
return '复诊合计'
|
||||||
|
}
|
||||||
if (slot >= 2 && slot <= 10) {
|
if (slot >= 2 && slot <= 10) {
|
||||||
return `${cnDigits[slot]}诊`
|
return `${cnDigits[slot]}诊`
|
||||||
}
|
}
|
||||||
@@ -307,6 +491,8 @@ const initNow = new Date()
|
|||||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
const month = ref<string>(currentMonth)
|
const month = ref<string>(currentMonth)
|
||||||
const deptId = ref<number | undefined>(undefined)
|
const deptId = ref<number | undefined>(undefined)
|
||||||
|
/** 口径:首次加载不传、取后端默认(config default_inherit_mode),之后以用户选择为准 */
|
||||||
|
const inheritMode = ref<RevisitRateInheritMode | ''>('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<StatsRow[]>([])
|
const rows = ref<StatsRow[]>([])
|
||||||
const total = ref<MetricPack | null>(null)
|
const total = ref<MetricPack | null>(null)
|
||||||
@@ -314,6 +500,8 @@ const total = ref<MetricPack | null>(null)
|
|||||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const isSlotMode = computed(() => inheritMode.value !== 'exclude')
|
||||||
|
|
||||||
const monthText = computed(() => {
|
const monthText = computed(() => {
|
||||||
const [y, m] = month.value.split('-')
|
const [y, m] = month.value.split('-')
|
||||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||||
@@ -322,9 +510,30 @@ const monthText = computed(() => {
|
|||||||
const formatRate = (rate: number | null | undefined) =>
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
/** 接诊率单元格下方灰字:单数 / 分母 */
|
||||||
|
const denText = (row: Record<string, any>, prefix: string) => {
|
||||||
|
const cnt = Number(row[`${prefix}_count`] ?? 0)
|
||||||
|
const den = row[`${prefix}_den`]
|
||||||
|
return `${cnt} / ${den === null || den === undefined ? '—' : den}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateHeaderTip = (slot: number) =>
|
||||||
|
isSlotMode.value
|
||||||
|
? `${slotLabel(slot)}单数 ÷ ${slotLabel(slot)}分母;分母 = 当月新指派数 + 起算诊次 ≤ ${slot} 的继承数;分母为 0 显示「—」`
|
||||||
|
: `${slotLabel(slot)}单数 ÷ 当月新指派数(旧口径:继承不计分母);分母为 0 显示「—」`
|
||||||
|
|
||||||
|
const entrySlotText = (entry: unknown) => {
|
||||||
|
const n = Number(entry ?? 0)
|
||||||
|
if (n <= 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return n > 50 ? `${n}(仅计合计)` : slotLabel(n)
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = () => ({
|
const baseQuery = () => ({
|
||||||
month: month.value,
|
month: month.value,
|
||||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||||
|
inherit_mode: (inheritMode.value || undefined) as RevisitRateInheritMode | undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
interface SummaryCard {
|
interface SummaryCard {
|
||||||
@@ -341,11 +550,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
|||||||
const cards: SummaryCard[] = [
|
const cards: SummaryCard[] = [
|
||||||
{
|
{
|
||||||
key: 'assigned',
|
key: 'assigned',
|
||||||
label: '当月被指派总数',
|
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
|
||||||
value: t ? String(t.assigned_count) : '—',
|
value: t ? String(t.assigned_count) : '—',
|
||||||
sub: '',
|
sub: t && isSlotMode.value ? `继承 ${t.inherit_count ?? 0} · 被指派合计 ${t.assigned_total_count ?? 0}` : '',
|
||||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||||
onClick: () => openAssignDetail(null)
|
onClick: () => openAssignDetail(null, 'new')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for (const slot of visitSlots.value) {
|
for (const slot of visitSlots.value) {
|
||||||
@@ -355,11 +564,19 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
|||||||
key: `visit${slot}`,
|
key: `visit${slot}`,
|
||||||
label: `当月${slotLabel(slot)}接诊率`,
|
label: `当月${slotLabel(slot)}接诊率`,
|
||||||
value: t ? formatRate(rate) : '—',
|
value: t ? formatRate(rate) : '—',
|
||||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
|
||||||
clickable: !!(canViewOrderLines && cnt > 0),
|
clickable: !!(canViewOrderLines && cnt > 0),
|
||||||
onClick: () => openOrderDetail(slot, null)
|
onClick: () => openOrderDetail(slot, null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
cards.push({
|
||||||
|
key: 'visit_all',
|
||||||
|
label: '当月复诊合计接诊率',
|
||||||
|
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||||
|
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 被指派合计 ${t.visit_all_den ?? '—'}` : '',
|
||||||
|
clickable: !!(canViewOrderLines && t && Number(t.visit_all_count) > 0),
|
||||||
|
onClick: () => openOrderDetail(0, null)
|
||||||
|
})
|
||||||
return cards
|
return cards
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,15 +591,16 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const prop = String(col.property || '')
|
const prop = String(col.property || '')
|
||||||
if (prop === 'assigned_count') {
|
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_count') {
|
||||||
return String(t.assigned_count)
|
return String(t[prop] ?? 0)
|
||||||
|
}
|
||||||
|
const m = /^visit(\d+|all)_(count|den|rate)$/.exec(prop)
|
||||||
|
if (m) {
|
||||||
|
if (m[2] === 'rate') {
|
||||||
|
return `${formatRate(t[prop])}(${denText(t, `visit${m[1]}`)})`
|
||||||
}
|
}
|
||||||
if (/^visit\d+_count$/.test(prop)) {
|
|
||||||
return String(t[prop] ?? '')
|
return String(t[prop] ?? '')
|
||||||
}
|
}
|
||||||
if (/^visit\d+_rate$/.test(prop)) {
|
|
||||||
return formatRate(t[prop])
|
|
||||||
}
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -404,6 +622,9 @@ const loadData = async () => {
|
|||||||
if (res?.month) {
|
if (res?.month) {
|
||||||
month.value = res.month
|
month.value = res.month
|
||||||
}
|
}
|
||||||
|
if (res?.inherit_mode === 'slot' || res?.inherit_mode === 'exclude') {
|
||||||
|
inheritMode.value = res.inherit_mode
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -449,6 +670,8 @@ const detailType = ref<'assign' | 'orders'>('assign')
|
|||||||
const detailTitle = ref('')
|
const detailTitle = ref('')
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailRows = ref<any[]>([])
|
const detailRows = ref<any[]>([])
|
||||||
|
/** 订单明细的诊次(0 = 复诊合计,表格多显示「诊次」列) */
|
||||||
|
const detailSlot = ref(2)
|
||||||
|
|
||||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||||
@@ -461,13 +684,19 @@ const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: numb
|
|||||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAssignDetail = async (row: StatsRow | null) => {
|
const assignKindLabel: Record<RevisitRateAssignKind, string> = {
|
||||||
|
new: '新指派明细',
|
||||||
|
inherit: '继承明细',
|
||||||
|
all: '被指派合计明细'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKind = 'all') => {
|
||||||
if (!canViewAssignLines) {
|
if (!canViewAssignLines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scope = rowScope(row)
|
const scope = rowScope(row)
|
||||||
detailType.value = 'assign'
|
detailType.value = 'assign'
|
||||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailRows.value = []
|
detailRows.value = []
|
||||||
@@ -475,7 +704,8 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
|||||||
const res = await revisitRateAssignLines({
|
const res = await revisitRateAssignLines({
|
||||||
...baseQuery(),
|
...baseQuery(),
|
||||||
assistant_id: scope.assistant_id,
|
assistant_id: scope.assistant_id,
|
||||||
dept_id: scope.dept_id
|
dept_id: scope.dept_id,
|
||||||
|
kind
|
||||||
})
|
})
|
||||||
detailRows.value = res?.rows ?? []
|
detailRows.value = res?.rows ?? []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -483,12 +713,14 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** slot=0 表示全部诊次 ≥ 2(复诊合计单数) */
|
||||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||||
if (!canViewOrderLines) {
|
if (!canViewOrderLines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scope = rowScope(row)
|
const scope = rowScope(row)
|
||||||
detailType.value = 'orders'
|
detailType.value = 'orders'
|
||||||
|
detailSlot.value = slot
|
||||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
@@ -529,6 +761,7 @@ const openPatientDetail = (diagnosisId: number) => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
month.value = currentMonth
|
month.value = currentMonth
|
||||||
deptId.value = undefined
|
deptId.value = undefined
|
||||||
|
inheritMode.value = ''
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +856,22 @@ onMounted(() => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
.rate-cell-main {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-cell-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rate-detail-footer {
|
.rate-detail-footer {
|
||||||
@@ -640,5 +889,10 @@ onMounted(() => {
|
|||||||
p + p {
|
p + p {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate-caliber-note {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,9 +12,21 @@
|
|||||||
{{ assistantLogCellText(row, 'to') }}
|
{{ assistantLogCellText(row, 'to') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="继承" width="72" align="center">
|
<el-table-column label="继承" width="118" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>继承</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
content="继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"
|
||||||
|
>
|
||||||
|
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
|
<template v-if="Number(row.is_inherit) === 1">
|
||||||
|
<el-tag type="success" size="small">是</el-tag>
|
||||||
|
<div v-if="inheritSlotText(row)" class="assign-log-inherit-slot">{{ inheritSlotText(row) }}</div>
|
||||||
|
</template>
|
||||||
<span v-else class="text-gray-400">否</span>
|
<span v-else class="text-gray-400">否</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -85,6 +97,20 @@ function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 对应日志表 inherit_visit_slot / inherit_visit_slot_calc_time:后端已拼好「继承时第 k 诊」文案,缺失时按快照列兜底 */
|
||||||
|
function inheritSlotText(row: Record<string, unknown>): string {
|
||||||
|
const text = row.inherit_visit_slot_text
|
||||||
|
if (text != null && String(text).trim() !== '') {
|
||||||
|
return String(text)
|
||||||
|
}
|
||||||
|
const calcTime = Number(row.inherit_visit_slot_calc_time)
|
||||||
|
if (!Number.isFinite(calcTime) || calcTime <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const k = Number(row.inherit_visit_slot)
|
||||||
|
return Number.isFinite(k) && k > 0 ? `继承时第 ${k} 诊` : '继承时未成交'
|
||||||
|
}
|
||||||
|
|
||||||
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
||||||
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
||||||
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
||||||
@@ -132,4 +158,11 @@ defineExpose({ refresh: load })
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assign-log-inherit-slot {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,7 +68,11 @@
|
|||||||
'is-high': getCell(row.key, date).isHigh,
|
'is-high': getCell(row.key, date).isHigh,
|
||||||
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
|
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
|
||||||
}"
|
}"
|
||||||
|
:role="isCellClickable(row.key, date) ? 'button' : undefined"
|
||||||
|
:tabindex="isCellClickable(row.key, date) ? 0 : undefined"
|
||||||
@click="handleCellClick(row.key, date)"
|
@click="handleCellClick(row.key, date)"
|
||||||
|
@keydown.enter="handleCellClick(row.key, date)"
|
||||||
|
@keydown.space.prevent="handleCellClick(row.key, date)"
|
||||||
>
|
>
|
||||||
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
|
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
|
||||||
<el-tooltip placement="top" effect="light" :show-after="150">
|
<el-tooltip placement="top" effect="light" :show-after="150">
|
||||||
@@ -90,6 +94,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="getCell(row.key, date).value">
|
<template v-else-if="getCell(row.key, date).value">
|
||||||
<span>{{ getCell(row.key, date).value }}</span>
|
<span>{{ getCell(row.key, date).value }}</span>
|
||||||
|
<span v-if="getDayRecordCount(row.key, date) > 1" class="daily-matrix__record-count">
|
||||||
|
{{ getDayRecordCount(row.key, date) }}条
|
||||||
|
</span>
|
||||||
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
v-if="getCell(row.key, date).isPatientSelf"
|
v-if="getCell(row.key, date).isPatientSelf"
|
||||||
@@ -100,7 +107,10 @@
|
|||||||
<span class="daily-matrix__cell-patient">自录</span>
|
<span class="daily-matrix__cell-patient">自录</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>—</template>
|
<template v-else>
|
||||||
|
<span v-if="getDayRecordCount(row.key, date) > 0" class="daily-matrix__record-count">查看{{ getDayRecordCount(row.key, date) }}条</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -109,6 +119,7 @@
|
|||||||
|
|
||||||
<div class="daily-matrix__chart">
|
<div class="daily-matrix__chart">
|
||||||
<div class="daily-matrix__section-title">血糖趋势</div>
|
<div class="daily-matrix__section-title">血糖趋势</div>
|
||||||
|
<p class="daily-matrix__hint">按日展示最新有效血糖值;点击表格查看当天全部原始记录。</p>
|
||||||
<v-charts
|
<v-charts
|
||||||
v-if="bloodTrendHasData"
|
v-if="bloodTrendHasData"
|
||||||
class="daily-matrix__chart-canvas"
|
class="daily-matrix__chart-canvas"
|
||||||
@@ -128,6 +139,84 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="dayDetailVisible" :title="`${detailDate} 日常记录明细`" size="min(640px, 100vw)">
|
||||||
|
<p class="daily-matrix__hint">每张卡片是一条独立记录;相同数值也不会合并。请在对应记录上点击编辑。</p>
|
||||||
|
<section class="daily-matrix__detail-section">
|
||||||
|
<div class="daily-matrix__detail-heading">
|
||||||
|
<h3>血糖血压 · {{ dayBloodRecords.length }}条</h3>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(undefined, detailDate)">新增一条</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dayBloodRecords.length" description="当天暂无血糖血压记录" :image-size="48" />
|
||||||
|
<article v-for="record in dayBloodRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||||
|
<header class="daily-matrix__detail-heading">
|
||||||
|
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(record, detailDate)">编辑此条</el-button>
|
||||||
|
</header>
|
||||||
|
<div class="daily-matrix__detail-tags">
|
||||||
|
<el-tag v-if="Number(record.record_time_estimated) === 1" type="warning" size="small">估计时间</el-tag>
|
||||||
|
<el-tag v-if="record.record_time_period" type="info" size="small">时段:{{ recordTimePeriodLabel(record) }}</el-tag>
|
||||||
|
<el-tag v-if="record.followup_audio_task_id" size="small">随访录音</el-tag>
|
||||||
|
<span v-if="Number(record.source) === 1" class="daily-matrix__cell-patient">患者自录</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="record.record_time_text" class="daily-matrix__time-evidence">时间原文:{{ record.record_time_text }}</p>
|
||||||
|
<dl class="daily-matrix__detail-fields">
|
||||||
|
<template v-for="field in bloodDetailFields(record)" :key="field.label">
|
||||||
|
<dt>{{ field.label }}</dt><dd>{{ field.value }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section class="daily-matrix__detail-section">
|
||||||
|
<div class="daily-matrix__detail-heading">
|
||||||
|
<h3>饮食 · {{ dayDietRecords.length }}条</h3>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(undefined, detailDate)">新增一条</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dayDietRecords.length" description="当天暂无饮食记录" :image-size="48" />
|
||||||
|
<article v-for="record in dayDietRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||||
|
<header class="daily-matrix__detail-heading">
|
||||||
|
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(record, detailDate)">编辑此条</el-button>
|
||||||
|
</header>
|
||||||
|
<dl class="daily-matrix__detail-fields">
|
||||||
|
<template v-for="meal in mealFields" :key="meal.key">
|
||||||
|
<dt>{{ meal.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
{{ record[`${meal.key}_foods`] || record[meal.key] || '—' }}
|
||||||
|
<div v-if="record[`${meal.key}_images`]?.length" class="daily-matrix__detail-images">
|
||||||
|
<el-image v-for="(url, index) in record[`${meal.key}_images`]" :key="`${url}-${index}`"
|
||||||
|
:src="url" :preview-src-list="record[`${meal.key}_images`]" :initial-index="index" preview-teleported fit="cover" />
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
|
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section class="daily-matrix__detail-section">
|
||||||
|
<div class="daily-matrix__detail-heading">
|
||||||
|
<h3>运动 · {{ dayExerciseRecords.length }}条</h3>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(undefined, detailDate)">新增一条</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dayExerciseRecords.length" description="当天暂无运动记录" :image-size="48" />
|
||||||
|
<article v-for="record in dayExerciseRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||||
|
<header class="daily-matrix__detail-heading">
|
||||||
|
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(record, detailDate)">编辑此条</el-button>
|
||||||
|
</header>
|
||||||
|
<dl class="daily-matrix__detail-fields">
|
||||||
|
<dt>运动</dt><dd>{{ record.exercise_type || '—' }}</dd>
|
||||||
|
<dt>时长</dt><dd>{{ record.duration ?? '—' }} 分钟</dd>
|
||||||
|
<dt>强度</dt><dd>{{ record.intensity_text || intensityLabel(record.intensity) }}</dd>
|
||||||
|
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div v-if="record.images?.length" class="daily-matrix__detail-images">
|
||||||
|
<el-image v-for="(url, index) in record.images" :key="`${url}-${index}`" :src="url"
|
||||||
|
:preview-src-list="record.images" :initial-index="index" preview-teleported fit="cover" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
||||||
<el-form :model="bloodForm" label-width="120px">
|
<el-form :model="bloodForm" label-width="120px">
|
||||||
<el-form-item label="记录日期" required>
|
<el-form-item label="记录日期" required>
|
||||||
@@ -148,6 +237,12 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="Number(bloodForm.record_time_estimated) === 1" label="时间说明">
|
||||||
|
<div class="daily-matrix__time-evidence">
|
||||||
|
估计时间 · {{ recordTimePeriodLabel(bloodForm) }};原文:{{ bloodForm.record_time_text || '未提供' }}。
|
||||||
|
手动修改记录时间后会清除估计标记;只改数值则保留来源。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="空腹血糖">
|
<el-form-item label="空腹血糖">
|
||||||
<el-input-number v-model="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
<el-input-number v-model="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
||||||
<span class="ml-2">mmol/L</span>
|
<span class="ml-2">mmol/L</span>
|
||||||
@@ -353,6 +448,10 @@ interface BloodRecord {
|
|||||||
patient_id?: number
|
patient_id?: number
|
||||||
record_date?: string
|
record_date?: string
|
||||||
record_time?: string
|
record_time?: string
|
||||||
|
record_time_estimated?: number | string
|
||||||
|
record_time_period?: string
|
||||||
|
record_time_text?: string
|
||||||
|
followup_audio_task_id?: number | null
|
||||||
record_date_ts?: number
|
record_date_ts?: number
|
||||||
fasting_blood_sugar?: any
|
fasting_blood_sugar?: any
|
||||||
postprandial_blood_sugar?: any
|
postprandial_blood_sugar?: any
|
||||||
@@ -364,12 +463,11 @@ interface BloodRecord {
|
|||||||
remark?: string
|
remark?: string
|
||||||
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
||||||
source?: number
|
source?: number
|
||||||
/** 合并后该日是否含有「患者自录」记录 */
|
|
||||||
has_patient_self?: boolean
|
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DietRecord {
|
interface DietRecord {
|
||||||
|
record_time?: string
|
||||||
id?: number | string
|
id?: number | string
|
||||||
diagnosis_id?: number
|
diagnosis_id?: number
|
||||||
patient_id?: number
|
patient_id?: number
|
||||||
@@ -388,6 +486,7 @@ interface DietRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ExerciseRecord {
|
interface ExerciseRecord {
|
||||||
|
record_time?: string
|
||||||
id?: number | string
|
id?: number | string
|
||||||
diagnosis_id?: number
|
diagnosis_id?: number
|
||||||
patient_id?: number
|
patient_id?: number
|
||||||
@@ -437,6 +536,9 @@ const bloodDialogVisible = ref(false)
|
|||||||
const dietDialogVisible = ref(false)
|
const dietDialogVisible = ref(false)
|
||||||
const exerciseDialogVisible = ref(false)
|
const exerciseDialogVisible = ref(false)
|
||||||
const trackingDialogVisible = ref(false)
|
const trackingDialogVisible = ref(false)
|
||||||
|
const dayDetailVisible = ref(false)
|
||||||
|
const detailDate = ref('')
|
||||||
|
const originalBloodTime = ref('')
|
||||||
const bloodSubmitting = ref(false)
|
const bloodSubmitting = ref(false)
|
||||||
const dietSubmitting = ref(false)
|
const dietSubmitting = ref(false)
|
||||||
const exerciseSubmitting = ref(false)
|
const exerciseSubmitting = ref(false)
|
||||||
@@ -455,7 +557,7 @@ const bloodDialogTitle = computed(() => (bloodForm.value.id ? '编辑血糖血
|
|||||||
const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录'))
|
const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录'))
|
||||||
const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录'))
|
const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录'))
|
||||||
const todayDate = computed(() => formatDate(new Date()))
|
const todayDate = computed(() => formatDate(new Date()))
|
||||||
const trackingCanSubmit = computed(() => !readOnly.value && trackingForm.value.note_date === todayDate.value)
|
const trackingCanSubmit = computed(() => canEditDailyRecord.value && trackingForm.value.note_date === todayDate.value)
|
||||||
const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注'))
|
const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注'))
|
||||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||||
const tableWrapWidth = ref(0)
|
const tableWrapWidth = ref(0)
|
||||||
@@ -583,70 +685,63 @@ const hasPatientSelfRecord = computed(() => {
|
|||||||
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
const bloodByDate = computed(() => {
|
// Group actual rows, never a synthetic record bearing the first row's id.
|
||||||
const map = new Map<string, BloodRecord>()
|
function groupRecordsByDate<T extends { record_date?: string }>(records: T[]): Map<string, T[]> {
|
||||||
for (const record of bloodRecords.value) {
|
const map = new Map<string, T[]>()
|
||||||
|
for (const record of records) {
|
||||||
const date = String(record.record_date || '')
|
const date = String(record.record_date || '')
|
||||||
if (!date) continue
|
if (!date) continue
|
||||||
if (!map.has(date)) {
|
const rows = map.get(date) || []
|
||||||
map.set(date, {
|
rows.push(record)
|
||||||
id: record.id,
|
map.set(date, rows)
|
||||||
diagnosis_id: record.diagnosis_id,
|
|
||||||
patient_id: record.patient_id,
|
|
||||||
record_date: date,
|
|
||||||
record_time: record.record_time || '',
|
|
||||||
record_date_ts: record.record_date_ts,
|
|
||||||
has_patient_self: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const merged = map.get(date)!
|
return map
|
||||||
const fields: Array<keyof BloodRecord> = [
|
}
|
||||||
'fasting_blood_sugar',
|
|
||||||
'postprandial_blood_sugar',
|
const bloodByDate = computed(() => groupRecordsByDate(bloodRecords.value))
|
||||||
'other_blood_sugar',
|
const dietByDate = computed(() => groupRecordsByDate(dietRecords.value))
|
||||||
'systolic_pressure',
|
const exerciseByDate = computed(() => groupRecordsByDate(exerciseRecords.value))
|
||||||
'diastolic_pressure',
|
const dayBloodRecords = computed(() => bloodByDate.value.get(detailDate.value) || [])
|
||||||
'western_medicine',
|
const dayDietRecords = computed(() => dietByDate.value.get(detailDate.value) || [])
|
||||||
'insulin',
|
const dayExerciseRecords = computed(() => exerciseByDate.value.get(detailDate.value) || [])
|
||||||
'remark'
|
const mealFields = [{ key: 'breakfast', label: '早餐' }, { key: 'lunch', label: '午餐' }, { key: 'dinner', label: '晚餐' }]
|
||||||
|
|
||||||
|
function recordTimeLabel(record: { record_time?: string }) {
|
||||||
|
return record.record_time ? String(record.record_time) : '未记录具体时间'
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordTimePeriodLabel(record: BloodRecord) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
dawn: '凌晨', early_morning: '清晨', morning: '上午', noon: '中午',
|
||||||
|
afternoon: '下午', evening: '晚上', night: '夜间', bedtime: '睡前'
|
||||||
|
}
|
||||||
|
const period = String(record.record_time_period || '')
|
||||||
|
return labels[period] || period || '未提供时段'
|
||||||
|
}
|
||||||
|
|
||||||
|
function intensityLabel(value?: number) {
|
||||||
|
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function bloodDetailFields(record: BloodRecord) {
|
||||||
|
const show = (value: unknown) => value == null || value === '' ? '—' : String(value)
|
||||||
|
return [
|
||||||
|
{ label: '空腹血糖', value: `${show(record.fasting_blood_sugar)} mmol/L` },
|
||||||
|
{ label: '餐后2h血糖', value: `${show(record.postprandial_blood_sugar)} mmol/L` },
|
||||||
|
{ label: '其他血糖', value: `${show(record.other_blood_sugar)} mmol/L` },
|
||||||
|
{ label: '血压', value: record.systolic_pressure || record.diastolic_pressure ? `${formatBp(record)} mmHg` : '—' },
|
||||||
|
{ label: '西药', value: show(record.western_medicine) },
|
||||||
|
{ label: '胰岛素', value: show(record.insulin) },
|
||||||
|
{ label: '备注', value: show(record.remark) }
|
||||||
]
|
]
|
||||||
for (const field of fields) {
|
|
||||||
if (
|
|
||||||
(merged[field] === undefined || merged[field] === null || merged[field] === '') &&
|
|
||||||
record[field] !== undefined &&
|
|
||||||
record[field] !== null &&
|
|
||||||
record[field] !== ''
|
|
||||||
) {
|
|
||||||
;(merged as any)[field] = record[field]
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 当天有任意 source=1 的记录则标记为患者自录
|
|
||||||
if (Number(record.source) === 1) {
|
|
||||||
merged.has_patient_self = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const dietByDate = computed(() => {
|
function getDayRecordCount(metric: string, date: string) {
|
||||||
const map = new Map<string, DietRecord>()
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) return dietByDate.value.get(date)?.length || 0
|
||||||
for (const record of dietRecords.value) {
|
if (metric === 'exercise') return exerciseByDate.value.get(date)?.length || 0
|
||||||
const date = String(record.record_date || '')
|
if (metric === 'tracking') return 0
|
||||||
if (!date || map.has(date)) continue
|
return bloodByDate.value.get(date)?.length || 0
|
||||||
map.set(date, record)
|
|
||||||
}
|
}
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const exerciseByDate = computed(() => {
|
|
||||||
const map = new Map<string, ExerciseRecord>()
|
|
||||||
for (const record of exerciseRecords.value) {
|
|
||||||
const date = String(record.record_date || '')
|
|
||||||
if (!date || map.has(date)) continue
|
|
||||||
map.set(date, record)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const trackingByDate = computed(() => {
|
const trackingByDate = computed(() => {
|
||||||
const map = new Map<string, TrackingNoteRecord>()
|
const map = new Map<string, TrackingNoteRecord>()
|
||||||
@@ -663,9 +758,10 @@ const chartDates = computed(() => [...visibleDates.value].reverse())
|
|||||||
const bloodTrendSeries = computed(() => {
|
const bloodTrendSeries = computed(() => {
|
||||||
return chartDates.value.reduce(
|
return chartDates.value.reduce(
|
||||||
(acc, date) => {
|
(acc, date) => {
|
||||||
const blood = bloodByDate.value.get(date)
|
const records = bloodByDate.value.get(date) || []
|
||||||
acc.fasting.push(parseTrendNumber(blood?.fasting_blood_sugar))
|
const latestValue = (field: keyof BloodRecord) => records.map((record) => parseTrendNumber(record[field])).find((value) => value !== null) ?? null
|
||||||
acc.postprandial.push(parseTrendNumber(blood?.postprandial_blood_sugar))
|
acc.fasting.push(latestValue('fasting_blood_sugar'))
|
||||||
|
acc.postprandial.push(latestValue('postprandial_blood_sugar'))
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -754,89 +850,49 @@ function parseTrendNumber(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
|
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
|
||||||
const blood = bloodByDate.value.get(date)
|
const blood = bloodByDate.value.get(date) || []
|
||||||
const diet = dietByDate.value.get(date)
|
const diet = dietByDate.value.get(date) || []
|
||||||
const exercise = exerciseByDate.value.get(date)
|
const exercise = exerciseByDate.value.get(date) || []
|
||||||
const tracking = trackingByDate.value.get(date)
|
const tracking = trackingByDate.value.get(date)
|
||||||
const isPatientSelf = !!blood?.has_patient_self
|
const bloodFields: Record<string, keyof BloodRecord> = {
|
||||||
switch (metric) {
|
fasting: 'fasting_blood_sugar', postprandial: 'postprandial_blood_sugar',
|
||||||
case 'fasting':
|
other: 'other_blood_sugar', western: 'western_medicine', insulin: 'insulin'
|
||||||
return {
|
|
||||||
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
|
||||||
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
|
|
||||||
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '',
|
|
||||||
isPatientSelf
|
|
||||||
}
|
}
|
||||||
case 'postprandial':
|
const field = bloodFields[metric]
|
||||||
return {
|
if (field || metric === 'bp') {
|
||||||
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
const rows = blood.filter((record) => metric === 'bp'
|
||||||
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
|
? !!(record.systolic_pressure || record.diastolic_pressure)
|
||||||
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '',
|
: record[field] != null && record[field] !== '')
|
||||||
isPatientSelf
|
const isHigh = (record: BloodRecord) => {
|
||||||
|
if (metric === 'fasting') return isHighFastingBloodSugar(record.fasting_blood_sugar, props.age)
|
||||||
|
if (metric === 'postprandial') return isHighPostprandialBloodSugar(record.postprandial_blood_sugar, props.age)
|
||||||
|
if (metric === 'other') return isHighOtherBloodSugar(record.other_blood_sugar, props.age)
|
||||||
|
return metric === 'bp' && isHighBloodPressure(record)
|
||||||
}
|
}
|
||||||
case 'other':
|
|
||||||
return {
|
return {
|
||||||
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
value: rows.map((record) => metric === 'bp' ? formatBp(record) : String(record[field])).join(' / '),
|
||||||
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
|
isHigh: rows.some(isHigh), hasRecord: rows.length > 0,
|
||||||
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== '',
|
isPatientSelf: rows.some((record) => Number(record.source) === 1)
|
||||||
isPatientSelf
|
|
||||||
}
|
}
|
||||||
case 'bp':
|
|
||||||
return {
|
|
||||||
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
|
||||||
isHigh: isHighBloodPressure(blood),
|
|
||||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure),
|
|
||||||
isPatientSelf
|
|
||||||
}
|
}
|
||||||
case 'western':
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
||||||
return {
|
const count = diet.filter((record) => record[metric] || record[`${metric}_foods`] || record[`${metric}_images`]?.length).length
|
||||||
value: blood?.western_medicine || '',
|
return { value: count ? '✓' : '', isHigh: false, hasRecord: count > 0 }
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!blood?.western_medicine
|
|
||||||
}
|
}
|
||||||
case 'insulin':
|
if (metric === 'exercise') {
|
||||||
return {
|
return { value: exercise.map((record) => record.duration ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
|
||||||
value: blood?.insulin || '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!blood?.insulin
|
|
||||||
}
|
}
|
||||||
case 'breakfast':
|
if (metric === 'tracking') {
|
||||||
return {
|
return { value: tracking?.content ? summarizeTracking(tracking.content) : '', isHigh: false, hasRecord: !!tracking?.content }
|
||||||
value: diet?.breakfast || diet?.breakfast_foods ? '✓' : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!(diet?.breakfast || diet?.breakfast_foods)
|
|
||||||
}
|
}
|
||||||
case 'lunch':
|
|
||||||
return {
|
|
||||||
value: diet?.lunch || diet?.lunch_foods ? '✓' : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!(diet?.lunch || diet?.lunch_foods)
|
|
||||||
}
|
|
||||||
case 'dinner':
|
|
||||||
return {
|
|
||||||
value: diet?.dinner || diet?.dinner_foods ? '✓' : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!(diet?.dinner || diet?.dinner_foods)
|
|
||||||
}
|
|
||||||
case 'exercise':
|
|
||||||
return {
|
|
||||||
value: exercise?.duration ? `${exercise.duration}min` : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!exercise?.id
|
|
||||||
}
|
|
||||||
case 'tracking':
|
|
||||||
return {
|
|
||||||
value: tracking?.content ? summarizeTracking(tracking.content) : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!tracking?.content
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return { value: '', isHigh: false, hasRecord: false }
|
return { value: '', isHigh: false, hasRecord: false }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTracking() {
|
async function fetchTracking() {
|
||||||
if (!diagnosisId.value) {
|
const seq = ++requestSeq
|
||||||
|
const requestDiagnosisId = diagnosisId.value
|
||||||
|
if (!requestDiagnosisId) {
|
||||||
|
loading.value = false
|
||||||
bloodRecords.value = []
|
bloodRecords.value = []
|
||||||
dietRecords.value = []
|
dietRecords.value = []
|
||||||
exerciseRecords.value = []
|
exerciseRecords.value = []
|
||||||
@@ -845,23 +901,23 @@ async function fetchTracking() {
|
|||||||
}
|
}
|
||||||
const { start, end } = currentRange.value
|
const { start, end } = currentRange.value
|
||||||
if (rangeMode.value === 'custom' && (!start || !end)) return
|
if (rangeMode.value === 'custom' && (!start || !end)) return
|
||||||
const seq = ++requestSeq
|
const requestDates = [...visibleDates.value]
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data: any = await diagnosisTrackingWindow({
|
const data: any = await diagnosisTrackingWindow({
|
||||||
id: diagnosisId.value,
|
id: requestDiagnosisId,
|
||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end
|
end_date: end
|
||||||
})
|
})
|
||||||
const noteData: any = await diagnosisTrackingNotes({
|
const noteData: any = await diagnosisTrackingNotes({
|
||||||
diagnosis_id: diagnosisId.value
|
diagnosis_id: requestDiagnosisId
|
||||||
})
|
})
|
||||||
if (seq !== requestSeq) return
|
if (seq !== requestSeq || requestDiagnosisId !== diagnosisId.value) return
|
||||||
bloodRecords.value = Array.isArray(data?.blood_records) ? data.blood_records : []
|
bloodRecords.value = Array.isArray(data?.blood_records) ? data.blood_records : []
|
||||||
dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : []
|
dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : []
|
||||||
exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : []
|
exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : []
|
||||||
trackingNotes.value = Array.isArray(noteData)
|
trackingNotes.value = Array.isArray(noteData)
|
||||||
? noteData.filter((item: any) => visibleDates.value.includes(String(item?.note_date || '')))
|
? noteData.filter((item: any) => requestDates.includes(String(item?.note_date || '')))
|
||||||
: []
|
: []
|
||||||
} catch {
|
} catch {
|
||||||
if (seq === requestSeq) {
|
if (seq === requestSeq) {
|
||||||
@@ -890,76 +946,83 @@ function handleCustomRangeChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordTimeKey(value: unknown) {
|
||||||
|
const time = String(value || '')
|
||||||
|
return /^\d{2}:\d{2}$/.test(time) ? `${time}:00` : time
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBloodRecord(source: BloodRecord | undefined, date: string) {
|
||||||
|
if (!canEditDailyRecord.value) return
|
||||||
|
bloodForm.value = { ...createBloodForm(date), ...source }
|
||||||
|
originalBloodTime.value = recordTimeKey(source?.record_time)
|
||||||
|
bloodDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDietRecord(source: DietRecord | undefined, date: string) {
|
||||||
|
if (!canEditDailyRecord.value) return
|
||||||
|
dietForm.value = {
|
||||||
|
...createDietForm(date), ...source,
|
||||||
|
breakfast_images: [...(source?.breakfast_images || [])],
|
||||||
|
lunch_images: [...(source?.lunch_images || [])], dinner_images: [...(source?.dinner_images || [])]
|
||||||
|
}
|
||||||
|
dietDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExerciseRecord(source: ExerciseRecord | undefined, date: string) {
|
||||||
|
if (!canEditDailyRecord.value) return
|
||||||
|
exerciseForm.value = { ...createExerciseForm(date), ...source, images: [...(source?.images || [])] }
|
||||||
|
exerciseDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function openQuickAdd(kind: DialogKind) {
|
function openQuickAdd(kind: DialogKind) {
|
||||||
if (!canEditDailyRecord.value) return
|
if (!canEditDailyRecord.value) return
|
||||||
const date = visibleDates.value[0] || formatDate(new Date())
|
const date = visibleDates.value[0] || formatDate(new Date())
|
||||||
if (kind === 'blood') {
|
if (kind === 'blood') return openBloodRecord(undefined, date)
|
||||||
bloodForm.value = createBloodForm(date)
|
if (kind === 'diet') return openDietRecord(undefined, date)
|
||||||
bloodDialogVisible.value = true
|
if (kind === 'exercise') return openExerciseRecord(undefined, date)
|
||||||
return
|
handleCellClick('tracking', todayDate.value)
|
||||||
}
|
|
||||||
if (kind === 'diet') {
|
|
||||||
dietForm.value = createDietForm(date)
|
|
||||||
dietDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (kind === 'tracking') {
|
|
||||||
const today = todayDate.value
|
|
||||||
const source = trackingByDate.value.get(today)
|
|
||||||
trackingForm.value = {
|
|
||||||
note_date: today,
|
|
||||||
existing_content: String(source?.content || ''),
|
|
||||||
content: ''
|
|
||||||
}
|
|
||||||
trackingDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exerciseForm.value = createExerciseForm(date)
|
|
||||||
exerciseDialogVisible.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCellClick(metric: string, date: string) {
|
function handleCellClick(metric: string, date: string) {
|
||||||
if (!canEditDailyRecord.value) return
|
if (!isCellClickable(metric, date)) return
|
||||||
if (['fasting', 'postprandial', 'other', 'bp', 'western', 'insulin'].includes(metric)) {
|
|
||||||
const source = bloodByDate.value.get(date)
|
|
||||||
bloodForm.value = source ? { ...createBloodForm(date), ...source } : createBloodForm(date)
|
|
||||||
bloodDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
|
||||||
const source = dietByDate.value.get(date)
|
|
||||||
dietForm.value = source ? { ...createDietForm(date), ...source } : createDietForm(date)
|
|
||||||
dietDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (metric === 'tracking') {
|
if (metric === 'tracking') {
|
||||||
const source = trackingByDate.value.get(date)
|
const source = trackingByDate.value.get(date)
|
||||||
if (!source && date !== todayDate.value) return
|
trackingForm.value = { note_date: date, existing_content: String(source?.content || ''), content: '' }
|
||||||
trackingForm.value = {
|
|
||||||
note_date: date,
|
|
||||||
existing_content: String(source?.content || ''),
|
|
||||||
content: ''
|
|
||||||
}
|
|
||||||
trackingDialogVisible.value = true
|
trackingDialogVisible.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const source = exerciseByDate.value.get(date)
|
const count = (bloodByDate.value.get(date)?.length || 0) + (dietByDate.value.get(date)?.length || 0) + (exerciseByDate.value.get(date)?.length || 0)
|
||||||
exerciseForm.value = source ? { ...createExerciseForm(date), ...source } : createExerciseForm(date)
|
if (count) {
|
||||||
exerciseDialogVisible.value = true
|
detailDate.value = date
|
||||||
|
dayDetailVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) return openDietRecord(undefined, date)
|
||||||
|
if (metric === 'exercise') return openExerciseRecord(undefined, date)
|
||||||
|
openBloodRecord(undefined, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBlood() {
|
async function submitBlood() {
|
||||||
|
if (!canEditDailyRecord.value || bloodSubmitting.value) return
|
||||||
if (!bloodForm.value.record_date) {
|
if (!bloodForm.value.record_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
|
const payload = { ...bloodForm.value }
|
||||||
|
if ('record_time_estimated' in payload && recordTimeKey(payload.record_time) !== originalBloodTime.value) {
|
||||||
|
payload.record_time_estimated = 0
|
||||||
|
payload.record_time_period = ''
|
||||||
|
payload.record_time_text = ''
|
||||||
|
}
|
||||||
bloodSubmitting.value = true
|
bloodSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (bloodForm.value.id) {
|
if (payload.id) {
|
||||||
await bloodRecordEdit(bloodForm.value)
|
await bloodRecordEdit(payload)
|
||||||
} else {
|
} else {
|
||||||
await bloodRecordAdd(bloodForm.value)
|
await bloodRecordAdd(payload)
|
||||||
}
|
}
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
bloodDialogVisible.value = false
|
bloodDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -968,17 +1031,21 @@ async function submitBlood() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitDiet() {
|
async function submitDiet() {
|
||||||
|
if (!canEditDailyRecord.value || dietSubmitting.value) return
|
||||||
if (!dietForm.value.record_date) {
|
if (!dietForm.value.record_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
|
const payload = { ...dietForm.value }
|
||||||
dietSubmitting.value = true
|
dietSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (dietForm.value.id) {
|
if (payload.id) {
|
||||||
await dietRecordEdit(dietForm.value)
|
await dietRecordEdit(payload)
|
||||||
} else {
|
} else {
|
||||||
await dietRecordAdd(dietForm.value)
|
await dietRecordAdd(payload)
|
||||||
}
|
}
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
dietDialogVisible.value = false
|
dietDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -987,6 +1054,7 @@ async function submitDiet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitExercise() {
|
async function submitExercise() {
|
||||||
|
if (!canEditDailyRecord.value || exerciseSubmitting.value) return
|
||||||
if (!exerciseForm.value.record_date) {
|
if (!exerciseForm.value.record_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
@@ -999,13 +1067,16 @@ async function submitExercise() {
|
|||||||
feedback.msgWarning('请输入运动时长')
|
feedback.msgWarning('请输入运动时长')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
|
const payload = { ...exerciseForm.value }
|
||||||
exerciseSubmitting.value = true
|
exerciseSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (exerciseForm.value.id) {
|
if (payload.id) {
|
||||||
await exerciseRecordEdit(exerciseForm.value)
|
await exerciseRecordEdit(payload)
|
||||||
} else {
|
} else {
|
||||||
await exerciseRecordAdd(exerciseForm.value)
|
await exerciseRecordAdd(payload)
|
||||||
}
|
}
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
exerciseDialogVisible.value = false
|
exerciseDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1014,6 +1085,7 @@ async function submitExercise() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTracking() {
|
async function submitTracking() {
|
||||||
|
if (trackingSubmitting.value) return
|
||||||
if (!trackingForm.value.note_date) {
|
if (!trackingForm.value.note_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
@@ -1027,12 +1099,14 @@ async function submitTracking() {
|
|||||||
feedback.msgWarning('请输入跟踪备注')
|
feedback.msgWarning('请输入跟踪备注')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
trackingSubmitting.value = true
|
trackingSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await diagnosisAddTrackingNote({
|
await diagnosisAddTrackingNote({
|
||||||
diagnosis_id: diagnosisId.value,
|
diagnosis_id: targetDiagnosisId,
|
||||||
tracking_content: content
|
tracking_content: content
|
||||||
})
|
})
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
trackingDialogVisible.value = false
|
trackingDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1057,17 +1131,23 @@ function getTrackingPreview(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCellClickable(metric: string, date: string) {
|
function isCellClickable(metric: string, date: string) {
|
||||||
if (!canEditDailyRecord.value) return false
|
if (metric === 'tracking') return !!getTrackingContent(date) || (canEditDailyRecord.value && date === todayDate.value)
|
||||||
if (metric !== 'tracking') return true
|
return canEditDailyRecord.value || getDayRecordCount(metric, date) > 0
|
||||||
return date === todayDate.value || !!getTrackingContent(date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.diagnosisId,
|
() => props.diagnosisId,
|
||||||
(value) => {
|
() => {
|
||||||
if (value) {
|
dayDetailVisible.value = false
|
||||||
fetchTracking()
|
bloodDialogVisible.value = false
|
||||||
}
|
dietDialogVisible.value = false
|
||||||
|
exerciseDialogVisible.value = false
|
||||||
|
trackingDialogVisible.value = false
|
||||||
|
bloodRecords.value = []
|
||||||
|
dietRecords.value = []
|
||||||
|
exerciseRecords.value = []
|
||||||
|
trackingNotes.value = []
|
||||||
|
void fetchTracking()
|
||||||
void nextTick(updateTableWrapWidth)
|
void nextTick(updateTableWrapWidth)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1081,6 +1161,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
requestSeq++
|
||||||
window.removeEventListener('resize', updateTableWrapWidth)
|
window.removeEventListener('resize', updateTableWrapWidth)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1116,6 +1197,29 @@ defineExpose({ refresh: fetchTracking })
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__hint, &__time-evidence {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__record-count { color: var(--el-color-primary); font-size: 11px; white-space: nowrap; }
|
||||||
|
&__detail-section + &__detail-section { margin-top: 24px; }
|
||||||
|
&__detail-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
&__detail-heading h3 { margin: 0 0 8px; }
|
||||||
|
&__detail-heading small { color: var(--el-text-color-secondary); font-weight: normal; }
|
||||||
|
&__detail-record { border: 1px solid var(--el-border-color); border-radius: 8px; padding: 12px; margin-bottom: 12px; }
|
||||||
|
&__detail-tags { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
|
||||||
|
&__detail-fields {
|
||||||
|
display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; margin: 12px 0 0;
|
||||||
|
dt { color: var(--el-text-color-secondary); }
|
||||||
|
dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
}
|
||||||
|
&__detail-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
&__detail-images .el-image { width: 64px; height: 64px; border-radius: 4px; }
|
||||||
|
|
||||||
&__chart {
|
&__chart {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="readonly" class="readonly-value">{{ fieldText(modelValue, field) }}</span>
|
||||||
|
<el-input-number v-else-if="field.type === 'number'" :model-value="numberValue" :disabled="disabled" :controls="false" @update:model-value="(value: number | undefined | null) => emit('update:modelValue', value ?? null)" />
|
||||||
|
<el-select v-else-if="field.type === 'select' || field.type === 'multiselect'" :model-value="selectValue" :multiple="field.type === 'multiselect'" :disabled="disabled" clearable filterable @update:model-value="(value: FollowupValue) => emit('update:modelValue', value)">
|
||||||
|
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker v-else-if="field.type === 'date'" :model-value="textValue" type="date" value-format="YYYY-MM-DD" :disabled="disabled" @update:model-value="(value: string | null) => emit('update:modelValue', value || null)" />
|
||||||
|
<el-input v-else :model-value="textValue" type="textarea" :autosize="{ minRows: 1, maxRows: 5 }" :disabled="disabled" @update:model-value="(value: FollowupValue) => emit('update:modelValue', value)" />
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { FollowupField, FollowupValue } from '@/api/followupAudio'
|
||||||
|
import { fieldText } from './followupAudioState'
|
||||||
|
const props = defineProps<{ field: FollowupField; modelValue?: FollowupValue; disabled?: boolean; readonly?: boolean }>()
|
||||||
|
const emit = defineEmits<{ (event: 'update:modelValue', value: FollowupValue): void }>()
|
||||||
|
const numberValue = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '' ? undefined : Number(props.modelValue))
|
||||||
|
const textValue = computed(() => props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue))
|
||||||
|
const selectValue = computed(() => {
|
||||||
|
const canonical = (value: string | number) => props.field.options?.find(option => String(option.value) === String(value))?.value ?? value
|
||||||
|
if (props.field.type === 'multiselect') return Array.isArray(props.modelValue) ? props.modelValue.map(canonical) : []
|
||||||
|
return typeof props.modelValue === 'string' || typeof props.modelValue === 'number' ? canonical(props.modelValue) : ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.readonly-value { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
<template>
|
||||||
|
<section v-if="featureEnabled" class="followup-panel" aria-label="回访录音">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<div class="toolbar-title">
|
||||||
|
<el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button>
|
||||||
|
<h3 v-else>回访录音 <span>{{ tasks.length }}</span></h3>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button link :aria-expanded="helpVisible" @click="helpVisible = !helpVisible">使用说明</el-button>
|
||||||
|
<el-button :loading="loading" :disabled="busy" @click="refresh">刷新</el-button>
|
||||||
|
<el-button v-if="!selectedId && !viewOnly && capabilities.can_upload" type="primary" :disabled="!canUpload || busy || createUncertain" @click="openUpload">上传录音</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="helpVisible" class="help-box">
|
||||||
|
<p v-if="previewOnly">{{ capabilities.preview_only ? '当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供确认写入。' : '这是历史测试任务,不写入病例;新上传的真实录音可按正常业务流程审核后确认入单。' }}</p>
|
||||||
|
<p v-else>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
|
||||||
|
<p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p>
|
||||||
|
<p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="previewOnly" type="warning" :closable="false" :title="capabilities.preview_only ? '仅测试识别与提炼,不写入病例' : '历史测试任务,仅供查看和审阅'" description="此任务保留测试用途。新业务录音请新建任务,人工核对后确认入单。" show-icon />
|
||||||
|
<el-alert v-if="!audioReady" type="warning" :closable="false" :title="capabilities.preview_only ? '测试预览尚未就绪,暂不可上传或重试。已有任务可查看。' : '当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。'" />
|
||||||
|
<el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" />
|
||||||
|
<el-alert v-if="basicDirty" type="warning" :closable="false" :title="previewOnly ? '病历有未保存修改,测试预览不会覆盖这些修改。' : '病历有未保存修改,请先保存病历,再写入录音建议。'" />
|
||||||
|
<el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" />
|
||||||
|
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||||
|
<div v-if="!selectedId" class="task-list">
|
||||||
|
<el-table :data="tasks" max-height="440" size="small" table-layout="fixed" empty-text="暂无录音,上传后可提炼回访资料" aria-label="录音任务列表">
|
||||||
|
<el-table-column label="录音" min-width="210">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button class="filename-button" link type="primary" :disabled="busy" @click="openTask(row.id)">{{ row.file_name }}</el-button>
|
||||||
|
<div class="task-time">{{ row.recorded_at }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="145">
|
||||||
|
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupTaskStatus(row) }}</el-tag><div v-if="row.transcript_available && (row.status === 'failed' || row.status === 'needs_reconciliation')" class="task-time">转写已保留</div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="65" align="right">
|
||||||
|
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!detail" class="detail-loading" role="status">正在加载录音任务…</div>
|
||||||
|
<section v-if="selectedId && detail" class="task-detail" aria-label="录音任务详情">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div class="recording-title"><h3>{{ detail.file_name }}</h3><span>通话时间 {{ detail.recorded_at }}</span></div>
|
||||||
|
<el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ followupProgressText(detail) }}</p>
|
||||||
|
<el-alert v-if="detail.error_message || detail.status === 'failed'" :type="detail.status === 'failed' ? 'error' : 'warning'" :closable="false" :title="detail.error_message || '任务处理失败,不能写入;请先核对失败原因。'" />
|
||||||
|
<el-alert v-if="retainedTranscriptNotice(detail)" type="warning" :closable="false" :title="retainedTranscriptNotice(detail)" />
|
||||||
|
<el-alert v-if="detail.status === 'needs_reconciliation'" type="error" :closable="false" title="上游结果待核对,禁止重发。请管理员先确认上游任务或账单。" />
|
||||||
|
<el-alert v-if="detail.status === 'expired'" type="warning" :closable="false" title="录音和转录已过保留期,不能继续写入;已采纳的业务记录保留。" />
|
||||||
|
<el-button v-if="detail.can_retry && detail.status === 'failed' && !viewOnly" class="retry-button" :disabled="!canUpload || busy" @click="retryTask">重试已确认失败的任务</el-button>
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情</div>
|
||||||
|
<p>{{ detail.summary || '暂无回访摘要' }}</p>
|
||||||
|
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
|
||||||
|
</div>
|
||||||
|
<section v-if="hasStereo" class="channel-role-box" aria-label="声道角色人工标注">
|
||||||
|
<div v-if="savedChannelRoles !== 'unconfirmed' && !editingRoles && !roleDirty" class="role-summary"><span>{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认</span><el-button v-if="canEditReview" link type="primary" @click="editingRoles = true">修改</el-button></div>
|
||||||
|
<label v-else>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || (busy && mutation !== 'save')" @update:model-value="setChannelRoles"><el-option label="角色待确认" value="unconfirmed" /><el-option label="左客服端 / 右患者或家属端" value="left_service" /><el-option label="右客服端 / 左患者或家属端" value="right_service" /></el-select></label>
|
||||||
|
<p v-if="savedChannelRoles === 'unconfirmed' || editingRoles || roleDirty" class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
|
||||||
|
<p v-if="savedChannelRoles !== 'unconfirmed' && !roleDirty" class="muted">当前角色已确认,无需再次选择;只有左右标错时才需修改。</p>
|
||||||
|
<p v-if="roleDirty" class="review-error" role="status">角色标注正在等待保存;保存后请逐项重新核对。</p>
|
||||||
|
</section>
|
||||||
|
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || uploading || (mutation !== '' && mutation !== 'save')" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" :save-state="saveState" :persisted-revision="persistedRevision" @saved-next="saveDraft(true)" @seek="loadDialogue">
|
||||||
|
<template #player>
|
||||||
|
<div class="audio-player">
|
||||||
|
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
|
||||||
|
<span v-else class="muted">录音不可播放或已清理</span>
|
||||||
|
<audio v-if="audioUrl" :key="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
|
||||||
|
</div>
|
||||||
|
<div v-if="hasStereo && detail.audio_available" class="channel-playback" aria-label="声道回听">
|
||||||
|
<el-button size="small" :type="playbackChannel === 'original' ? 'primary' : 'default'" :aria-pressed="playbackChannel === 'original'" :disabled="audioLoading || channelLoading" @click="playChannel('original')">完整对话</el-button>
|
||||||
|
<el-button v-for="channel in ([0, 1] as const)" :key="channel" size="small" :type="playbackChannel === channel ? 'primary' : 'default'" :aria-pressed="playbackChannel === channel" :disabled="audioLoading || channelLoading" @click="playChannel(channel)">{{ channelPlaybackLabel(channel, savedChannelRoles) }}</el-button>
|
||||||
|
<span class="muted" role="status">{{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:双方对话' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="playbackError" class="review-error" role="alert">{{ playbackError }}</p>
|
||||||
|
<p v-if="detail.audio_available" class="muted">回听对话默认从该区间前 3 秒开始,连续播放双方声音;可随时切换为只听一方。</p>
|
||||||
|
</template>
|
||||||
|
</FollowupAudioReview>
|
||||||
|
<el-collapse v-model="detailSections" class="original-collapse">
|
||||||
|
<el-collapse-item title="完整对话转写" name="original">
|
||||||
|
<div v-if="transcriptSegments.length" class="transcript-segments">
|
||||||
|
<p class="muted">双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。</p>
|
||||||
|
<section v-for="window in conversationWindows" :key="window.id" class="transcript-segment">
|
||||||
|
<div class="segment-heading"><span>{{ audioTimestamp(window.start_ms) }}–{{ audioTimestamp(window.end_ms) }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(window.start_ms)">回听双方对话</el-button></div>
|
||||||
|
<div class="dialogue-sides">
|
||||||
|
<div v-for="segment in window.segments" :key="segment.id" class="dialogue-side">
|
||||||
|
<strong>{{ followupChannelLabel(segment.channel, savedChannelRoles) }}</strong>
|
||||||
|
<pre>{{ segment.text || '(该区间无转写文本,不代表没有说话)' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<pre v-else class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
<div v-if="detail.status === 'review'" class="review-actions">
|
||||||
|
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ saveStatusText }}</span></div>
|
||||||
|
<div v-if="canEditReview" class="action-buttons">
|
||||||
|
<el-button link :disabled="busy" :loading="mutation === 'save'" @click="saveDraft()">{{ saveState === 'error' || saveState === 'conflict' ? '重试保存草稿' : '立即保存草稿' }}</el-button>
|
||||||
|
<el-button v-if="canApplyReview" type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button>
|
||||||
|
</div>
|
||||||
|
<p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.status === 'applied'" class="applied-note">{{ previewOnly ? '该任务已有历史采纳记录;当前为测试预览,不允许再次写入。' : '本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。' }}</p>
|
||||||
|
</section>
|
||||||
|
<el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload">
|
||||||
|
<el-form label-position="top" class="upload-form">
|
||||||
|
<el-form-item label="录音文件">
|
||||||
|
<input ref="fileInput" type="file" accept="audio/mpeg,audio/mp4,audio/wav,audio/amr,.mp3,.m4a,.wav,.amr" :disabled="!canUpload || busy || createUncertain" aria-label="录音文件" @change="chooseFile" />
|
||||||
|
<small>支持 MP3 / M4A / WAV / AMR,最多 {{ Math.floor(capabilities.limits.max_bytes / 1024 / 1024) }} MB / {{ Math.floor(capabilities.limits.max_seconds / 60) }} 分钟。</small>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="实际通话时间(北京时间)">
|
||||||
|
<el-date-picker v-model="recordedAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="用于识别昨天、前天等相对日期" :disabled="!canUpload || busy || createUncertain" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="本次录音的声道角色">
|
||||||
|
<el-select v-model="uploadChannelRoles" aria-label="本次录音的声道角色" :disabled="!canUpload || busy || createUncertain">
|
||||||
|
<el-option label="暂不确定 / 单声道" value="unconfirmed" />
|
||||||
|
<el-option label="左客服 / 右患者或家属" value="left_service" />
|
||||||
|
<el-option label="右客服 / 左患者或家属" value="right_service" />
|
||||||
|
</el-select>
|
||||||
|
<small>已知角色会在提炼前提供给模型;不确定请勿猜测。单声道不按左右区分身份。</small>
|
||||||
|
</el-form-item>
|
||||||
|
<el-collapse class="advanced-settings">
|
||||||
|
<el-collapse-item title="高级设置 · 分析模型" name="model">
|
||||||
|
<el-form-item label="分析模型">
|
||||||
|
<el-select v-model="modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||||
|
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
|
||||||
|
<el-progress v-if="uploading" :percentage="uploadProgress" />
|
||||||
|
<p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p>
|
||||||
|
<p class="muted">{{ previewOnly ? '本次仅测试识别与提炼,可保存审核草稿,不允许写入病历。' : '分析仅生成建议,人工确认后才会写入。' }}</p>
|
||||||
|
<template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate,
|
||||||
|
followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft,
|
||||||
|
followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession,
|
||||||
|
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
|
||||||
|
} from '@/api/followupAudio'
|
||||||
|
import FollowupAudioReview from './FollowupAudioReview.vue'
|
||||||
|
import { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||||
|
import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback'
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
|
||||||
|
basicDirty?: boolean; beforeApply?: () => boolean
|
||||||
|
}>(), { viewOnly: false, active: true, basicDirty: false })
|
||||||
|
const emit = defineEmits<{ (event: 'applied', diagnosisId: number): void }>()
|
||||||
|
const tasks = ref<FollowupTaskSummary[]>([])
|
||||||
|
const detail = ref<FollowupTaskDetail | null>(null)
|
||||||
|
const reviewItems = ref<FollowupReviewItem[]>([])
|
||||||
|
const selectedId = ref(0)
|
||||||
|
const snapshot = ref('[]')
|
||||||
|
const channelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||||
|
const roleSnapshot = ref<FollowupChannelRoles>('unconfirmed')
|
||||||
|
const editingRoles = ref(false)
|
||||||
|
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'conflict'>('idle')
|
||||||
|
const persistedRevision = ref(0)
|
||||||
|
const committedDirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value)
|
||||||
|
const saveStatusText = computed(() => saveState.value === 'saving' ? '正在自动保存…'
|
||||||
|
: saveState.value === 'error' ? '保存失败,修改仍保留;请重试保存'
|
||||||
|
: saveState.value === 'conflict' ? '自动保存已暂停;请重新核对后重试保存'
|
||||||
|
: committedDirty.value ? '等待自动保存…'
|
||||||
|
: reviewRef.value?.hasUnsavedChanges() ? '当前单项尚未完成核对'
|
||||||
|
: saveState.value === 'saved' ? '审核草稿已自动保存' : '草稿已同步')
|
||||||
|
const roleDirty = computed(() => channelRoles.value !== roleSnapshot.value)
|
||||||
|
const savedChannelRoles = computed(() => normalizedChannelRoles(detail.value?.channel_roles))
|
||||||
|
const transcriptSegments = computed(() => [...(detail.value?.transcript_segments || [])].sort((a, b) => a.start_ms - b.start_ms || (a.channel ?? -1) - (b.channel ?? -1)))
|
||||||
|
const conversationWindows = computed(() => dialogueWindows(transcriptSegments.value))
|
||||||
|
const hasStereo = computed(() => hasStereoTranscript(transcriptSegments.value))
|
||||||
|
const reviewRef = ref<{ hasUnsavedChanges: () => boolean } | null>(null)
|
||||||
|
const dirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges())
|
||||||
|
const uploadVisible = ref(false)
|
||||||
|
const helpVisible = ref(false)
|
||||||
|
const detailSections = ref<string[]>([])
|
||||||
|
const file = ref<File | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const recordedAt = ref('')
|
||||||
|
const uploadChannelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||||
|
const modelKey = ref(props.capabilities.models[0]?.value || '')
|
||||||
|
const loading = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const uploadStage = ref('')
|
||||||
|
const createUncertain = ref(false)
|
||||||
|
const mutation = ref<'save' | 'apply' | 'retry' | ''>('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const audioUrl = ref('')
|
||||||
|
const audioElement = ref<HTMLAudioElement | null>(null)
|
||||||
|
const audioLoading = ref(false)
|
||||||
|
const playbackChannel = ref<FollowupPlaybackChannel>('original')
|
||||||
|
const channelLoading = ref(false)
|
||||||
|
const playbackError = ref('')
|
||||||
|
const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||||
|
const busy = computed(() => uploading.value || !!mutation.value)
|
||||||
|
const featureEnabled = computed(() => followupFeatureEnabled(props.capabilities))
|
||||||
|
const previewOnly = computed(() => !!props.capabilities.preview_only || !!detail.value?.preview_only)
|
||||||
|
const audioReady = computed(() => followupAudioReady(props.capabilities))
|
||||||
|
const canUpload = computed(() => !props.viewOnly && featureEnabled.value && audioReady.value && props.capabilities.can_upload && props.capabilities.models.length > 0)
|
||||||
|
const canEditReview = computed(() => !props.viewOnly && followupCanReview(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
|
||||||
|
const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
|
||||||
|
const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length)
|
||||||
|
const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0))
|
||||||
|
const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历'
|
||||||
|
: saveState.value === 'conflict' ? '记录版本已变化,请重新核对后重试保存草稿'
|
||||||
|
: saveState.value === 'error' ? '草稿保存失败,请重试保存成功后再确认写入'
|
||||||
|
: saveState.value === 'saving' || committedDirty.value ? '审核草稿尚未保存完成,请稍候再确认写入'
|
||||||
|
: roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议'
|
||||||
|
: hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议'
|
||||||
|
: changedReviewTarget(reviewItems.value, detail.value?.items || [])
|
||||||
|
? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳'
|
||||||
|
: reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '')
|
||||||
|
const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value)
|
||||||
|
const scope = createFollowupScope()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let hydrating = false
|
||||||
|
let savePromise: Promise<void> | null = null
|
||||||
|
let pollRunning = false
|
||||||
|
let audioRequest = 0
|
||||||
|
let playbackRequest = 0
|
||||||
|
let playback: ReturnType<typeof createFollowupAudioPlayback> | null = null
|
||||||
|
let playbackElement: HTMLAudioElement | null = null
|
||||||
|
let lastDiagnosis = 0
|
||||||
|
function clearTimer() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
|
||||||
|
function releaseAudio() {
|
||||||
|
audioRequest++; playbackRequest++
|
||||||
|
playback?.dispose(); playback = null; playbackElement = null
|
||||||
|
audioElement.value?.pause()
|
||||||
|
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
|
||||||
|
audioUrl.value = ''; audioLoading.value = false; channelLoading.value = false; playbackChannel.value = 'original'; playbackError.value = ''
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
scope.invalidate(); clearTimer(); clearAutosave(); releaseAudio()
|
||||||
|
savePromise = null
|
||||||
|
if (saveState.value === 'saving') { saveState.value = 'error'; errorMessage.value = '保存结果尚未确认;修改仍保留,请刷新核对版本后重试保存。' }
|
||||||
|
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
||||||
|
}
|
||||||
|
function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) {
|
||||||
|
// Completed tasks use the server's confirmed snapshot, never an unsaved local draft.
|
||||||
|
preserve = preserve && fresh.status === 'review'
|
||||||
|
const keepRoles = preserveRoles && roleDirty.value && fresh.status === 'review'
|
||||||
|
if (!fresh.audio_available) releaseAudio()
|
||||||
|
if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) {
|
||||||
|
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
|
||||||
|
}
|
||||||
|
hydrating = true
|
||||||
|
try {
|
||||||
|
detail.value = fresh
|
||||||
|
const nextItems = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
|
||||||
|
if (JSON.stringify(nextItems) !== JSON.stringify(reviewItems.value)) reviewItems.value = nextItems
|
||||||
|
snapshot.value = JSON.stringify(fresh.items)
|
||||||
|
roleSnapshot.value = normalizedChannelRoles(fresh.channel_roles)
|
||||||
|
if (!keepRoles) channelRoles.value = roleSnapshot.value
|
||||||
|
} finally { hydrating = false }
|
||||||
|
}
|
||||||
|
function resetRoles() { hydrating = true; channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed'; hydrating = false; editingRoles.value = false; saveState.value = 'idle'; persistedRevision.value = 0 }
|
||||||
|
function setChannelRoles(value: unknown) {
|
||||||
|
if (!canEditReview.value || (busy.value && mutation.value !== 'save') || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return
|
||||||
|
channelRoles.value = value as FollowupChannelRoles
|
||||||
|
}
|
||||||
|
async function readDetail(id: number, preserve = false) {
|
||||||
|
const ticket = scope.capture()
|
||||||
|
const fresh = await followupAudioDetail(id, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
|
||||||
|
preserve = preserve || (!!detail.value && dirty.value)
|
||||||
|
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
|
||||||
|
installDetail(fresh, preserve)
|
||||||
|
if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
|
||||||
|
if (preserve && fresh.status === 'review') { clearAutosave(); saveState.value = 'conflict'; errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。自动保存已暂停,请重新核对后重试保存草稿。' }
|
||||||
|
else if (preserve && newlyApplied) errorMessage.value = '任务已由其他人确认,已显示实际写入结果;本页未保存的修改未写入。'
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return
|
||||||
|
const ticket = scope.capture()
|
||||||
|
pollRunning = true; loading.value = true; clearTimer()
|
||||||
|
try {
|
||||||
|
const response = await followupAudioLists(props.diagnosisId, ticket.signal)
|
||||||
|
if (!scope.current(ticket)) return
|
||||||
|
tasks.value = response.items
|
||||||
|
const updated = tasks.value.find(task => task.id === selectedId.value)
|
||||||
|
if (updated && (!detail.value || updated.version > detail.value.version || (updated.version >= detail.value.version && updated.status !== detail.value.status) || updated.status === 'running')) {
|
||||||
|
await readDetail(updated.id, dirty.value)
|
||||||
|
}
|
||||||
|
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||||
|
finally {
|
||||||
|
if (scope.current(ticket)) {
|
||||||
|
pollRunning = false; loading.value = false
|
||||||
|
if (effectiveActive.value) timer = setTimeout(() => { void refresh() }, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function confirmLeave(): Promise<boolean> {
|
||||||
|
if (busy.value) { ElMessage.warning('正在上传或提交审核,请等待本次操作结束'); return false }
|
||||||
|
if (!dirty.value) return true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('审核修改尚未保存。关闭诊单或切换录音将丢失这些修改,是否继续?', '保留审核修改', { type: 'warning', confirmButtonText: '继续离开', cancelButtonText: '返回保存' })
|
||||||
|
return true
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
async function openTask(id: number) {
|
||||||
|
if (!featureEnabled.value || id === selectedId.value || busy.value) return
|
||||||
|
const previous = scope.capture()
|
||||||
|
if (!await confirmLeave() || !scope.current(previous)) return
|
||||||
|
stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = []
|
||||||
|
const ticket = scope.capture()
|
||||||
|
try { await readDetail(id) } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||||
|
if (scope.current(ticket)) await refresh()
|
||||||
|
}
|
||||||
|
async function backToList() {
|
||||||
|
const ticket = scope.capture()
|
||||||
|
if (!await confirmLeave() || !scope.current(ticket)) return
|
||||||
|
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []; errorMessage.value = ''
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
function openUpload() {
|
||||||
|
if (!canUpload.value || busy.value || createUncertain.value) return
|
||||||
|
if (!file.value) uploadChannelRoles.value = 'unconfirmed'
|
||||||
|
uploadVisible.value = true
|
||||||
|
}
|
||||||
|
function closeUpload(done?: () => void) { if (uploading.value) return; uploadVisible.value = false; done?.() }
|
||||||
|
function statusType(status: string) {
|
||||||
|
if (status === 'failed' || status === 'needs_reconciliation') return 'danger'
|
||||||
|
if (status === 'applied') return 'success'
|
||||||
|
if (status === 'review') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
function chooseFile(event: Event) {
|
||||||
|
const nextFile = (event.target as HTMLInputElement).files?.[0] || null
|
||||||
|
// Cancelling the picker or selecting the first file must not discard a role chosen earlier.
|
||||||
|
if (!nextFile) return
|
||||||
|
if (file.value && nextFile !== file.value) {
|
||||||
|
uploadChannelRoles.value = 'unconfirmed'
|
||||||
|
ElMessage.warning('录音已更换,请重新核对本次录音的声道角色')
|
||||||
|
}
|
||||||
|
file.value = nextFile; uploadProgress.value = 0; uploadStage.value = ''
|
||||||
|
}
|
||||||
|
async function submitUpload() {
|
||||||
|
if (busy.value || createUncertain.value) return
|
||||||
|
const problem = uploadIssue(file.value, recordedAt.value, modelKey.value, props.capabilities, props.viewOnly)
|
||||||
|
if (problem) { ElMessage.warning(problem); return }
|
||||||
|
const recording = file.value
|
||||||
|
if (!recording) return
|
||||||
|
const ticket = scope.capture()
|
||||||
|
uploading.value = true; uploadProgress.value = 0; errorMessage.value = ''; clearTimer()
|
||||||
|
let creating = false
|
||||||
|
try {
|
||||||
|
uploadStage.value = '正在创建私有上传会话'
|
||||||
|
const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || !canUpload.value) return
|
||||||
|
uploadStage.value = '正在上传录音分片'
|
||||||
|
const finished = await uploadChunks(recording, session.chunk_bytes,
|
||||||
|
(index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal),
|
||||||
|
() => scope.current(ticket) && canUpload.value, percent => { uploadProgress.value = percent })
|
||||||
|
if (!finished) return
|
||||||
|
uploadStage.value = '正在校验录音时长和完整性'
|
||||||
|
await followupAudioUploadComplete(session.upload_id, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || !canUpload.value) return
|
||||||
|
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
|
||||||
|
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value, channel_roles: uploadChannelRoles.value }, ticket.signal)
|
||||||
|
if (!scope.current(ticket)) return
|
||||||
|
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
|
||||||
|
uploadStage.value = result.reused
|
||||||
|
? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型'
|
||||||
|
: props.capabilities.preview_only ? '测试任务已创建,后台将识别与提炼;此任务不允许写入业务记录' : '任务已创建,后台将进行分析;审核前不会写入业务记录'
|
||||||
|
if (result.reused) ElMessage.warning(uploadStage.value)
|
||||||
|
await openTask(result.task_id)
|
||||||
|
} catch (error) {
|
||||||
|
if (scope.current(ticket)) { errorMessage.value = followupError(error); createUncertain.value = creating && !(error instanceof FollowupAudioError); uploadStage.value = '本次操作已停止,未自动重试' }
|
||||||
|
} finally { if (scope.current(ticket)) { uploading.value = false; await refresh() } }
|
||||||
|
}
|
||||||
|
async function handleReviewError(error: unknown) {
|
||||||
|
const ticket = scope.capture()
|
||||||
|
errorMessage.value = followupError(error)
|
||||||
|
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
|
||||||
|
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
|
||||||
|
clearAutosave(); saveState.value = 'conflict'
|
||||||
|
try { await readDetail(selectedId.value, true) } catch { if (scope.current(ticket)) errorMessage.value += ';刷新失败,当前编辑仍已保留,请稍后刷新。' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearAutosave() { if (autosaveTimer !== undefined) clearTimeout(autosaveTimer); autosaveTimer = undefined }
|
||||||
|
function scheduleAutosave() {
|
||||||
|
clearAutosave()
|
||||||
|
if (hydrating || !effectiveActive.value || !canEditReview.value || !committedDirty.value || ['error', 'conflict'].includes(saveState.value)) return
|
||||||
|
if (mutation.value === 'save') return
|
||||||
|
saveState.value = 'pending'
|
||||||
|
autosaveTimer = setTimeout(() => { autosaveTimer = undefined; void saveDraft(true) }, 650)
|
||||||
|
}
|
||||||
|
// Merge only changes made after the request began. Server-owned evidence and hashes always win.
|
||||||
|
function mergeSaveResponse(sent: FollowupReviewItem[], saved: FollowupReviewItem[], local: FollowupReviewItem[]) {
|
||||||
|
const before = new Map(sent.map(item => [item.id, item]))
|
||||||
|
const latest = new Map(local.map(item => [item.id, item]))
|
||||||
|
return cloneReviewItems(saved).map(item => {
|
||||||
|
const previous = before.get(item.id), edit = latest.get(item.id)
|
||||||
|
if (!previous || !edit) return item
|
||||||
|
const serverRequiresReview = (item.needs_review && !previous.needs_review) || (previous.selected && !item.selected)
|
||||||
|
for (const key of ['values', 'record_date', 'record_time', 'time_period', 'target_id', 'selected', 'needs_review'] as const) {
|
||||||
|
if (serverRequiresReview && (key === 'selected' || key === 'needs_review')) continue
|
||||||
|
if (JSON.stringify(previous[key]) !== JSON.stringify(edit[key])) Object.assign(item, { [key]: edit[key] })
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function saveDraft(automatic = false): Promise<void> {
|
||||||
|
if (savePromise) return savePromise
|
||||||
|
if (!canEditReview.value || !detail.value || busy.value || !effectiveActive.value) return
|
||||||
|
if (!automatic && reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||||
|
if (automatic && (!committedDirty.value || ['error', 'conflict'].includes(saveState.value))) return
|
||||||
|
clearAutosave()
|
||||||
|
const ticket = scope.capture(), taskId = detail.value.id, diagnosisId = props.diagnosisId
|
||||||
|
const current = () => scope.current(ticket) && selectedId.value === taskId && props.diagnosisId === diagnosisId
|
||||||
|
const changedRoles = roleDirty.value, sentRoles = channelRoles.value
|
||||||
|
const sentItems = cloneReviewItems(reviewItems.value), version = detail.value.version
|
||||||
|
mutation.value = 'save'; saveState.value = 'saving'; errorMessage.value = ''; clearTimer()
|
||||||
|
const operation = (async () => {
|
||||||
|
try {
|
||||||
|
const saved = await followupAudioSaveDraft({ id: taskId, version, items: sentItems, ...(changedRoles && hasStereo.value ? { channel_roles: sentRoles } : {}) }, ticket.signal)
|
||||||
|
if (!current()) return
|
||||||
|
if (detail.value && saved.version < detail.value.version) {
|
||||||
|
saveState.value = 'conflict'; errorMessage.value = '服务端已有更新版本;自动保存已暂停,请核对当前记录后重试保存。'; return
|
||||||
|
}
|
||||||
|
if (saved.review_refreshed) {
|
||||||
|
const latestRoles = channelRoles.value
|
||||||
|
installDetail(saved, true, false)
|
||||||
|
if (latestRoles !== sentRoles) { hydrating = true; channelRoles.value = latestRoles; hydrating = false }
|
||||||
|
saveState.value = 'conflict'
|
||||||
|
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消。自动保存已暂停,请重新核对后重试保存草稿。'
|
||||||
|
} else {
|
||||||
|
const latestRoles = channelRoles.value
|
||||||
|
const merged = changedRoles ? rebaseReviewItems(reviewItems.value, saved.items) : mergeSaveResponse(sentItems, saved.items, reviewItems.value)
|
||||||
|
hydrating = true
|
||||||
|
try {
|
||||||
|
detail.value = saved; snapshot.value = JSON.stringify(saved.items)
|
||||||
|
roleSnapshot.value = normalizedChannelRoles(saved.channel_roles)
|
||||||
|
channelRoles.value = latestRoles === sentRoles ? roleSnapshot.value : latestRoles
|
||||||
|
if (JSON.stringify(merged) !== JSON.stringify(reviewItems.value)) reviewItems.value = merged
|
||||||
|
} finally { hydrating = false }
|
||||||
|
editingRoles.value = roleDirty.value
|
||||||
|
saveState.value = committedDirty.value ? 'pending' : 'saved'
|
||||||
|
if (!committedDirty.value) persistedRevision.value++
|
||||||
|
if (!automatic) ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) {
|
||||||
|
saveState.value = 'error'
|
||||||
|
await handleReviewError(error)
|
||||||
|
if (current() && !['conflict'].includes(saveState.value)) errorMessage.value += ';自动保存已暂停,修改仍保留,请重试保存草稿。'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current()) {
|
||||||
|
mutation.value = ''; savePromise = null
|
||||||
|
scheduleAutosave()
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
savePromise = operation
|
||||||
|
return operation
|
||||||
|
}
|
||||||
|
async function applySelected() {
|
||||||
|
if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||||
|
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||||
|
if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return }
|
||||||
|
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||||
|
const ticket = scope.capture()
|
||||||
|
try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return }
|
||||||
|
if (!scope.current(ticket) || !canApplyReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
|
||||||
|
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||||
|
const diagnosisId = props.diagnosisId
|
||||||
|
mutation.value = 'apply'; errorMessage.value = ''; clearTimer()
|
||||||
|
try {
|
||||||
|
await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || !canApplyReview.value) return
|
||||||
|
detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value)
|
||||||
|
ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId)
|
||||||
|
await readDetail(selectedId.value)
|
||||||
|
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||||
|
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||||
|
}
|
||||||
|
async function retryTask() {
|
||||||
|
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
|
||||||
|
const ticket = scope.capture()
|
||||||
|
try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return }
|
||||||
|
if (!scope.current(ticket) || !detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value) return
|
||||||
|
mutation.value = 'retry'; clearTimer()
|
||||||
|
try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) }
|
||||||
|
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||||
|
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||||
|
}
|
||||||
|
async function loadDialogue(milliseconds: number) { await loadAudio(dialoguePlaybackStart(milliseconds)) }
|
||||||
|
async function reloadAudio() { releaseAudio(); await loadAudio() }
|
||||||
|
async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) }
|
||||||
|
async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) {
|
||||||
|
if (!featureEnabled.value || !detail.value?.audio_available) return
|
||||||
|
const operation = ++playbackRequest
|
||||||
|
playbackError.value = ''
|
||||||
|
if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) {
|
||||||
|
releaseAudio(); playbackError.value = '未取得可验证的双声道片段,无法隔离声道;请使用原音播放。'; return
|
||||||
|
}
|
||||||
|
const ticket = scope.capture(); const id = detail.value.id
|
||||||
|
if (!audioUrl.value) {
|
||||||
|
const requestId = ++audioRequest; audioLoading.value = true
|
||||||
|
try {
|
||||||
|
const blob = await followupAudioBlob(id, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || requestId !== audioRequest || id !== selectedId.value) return
|
||||||
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
|
} catch (error) { if (scope.current(ticket) && requestId === audioRequest) errorMessage.value = followupError(error) }
|
||||||
|
finally { if (scope.current(ticket) && requestId === audioRequest) audioLoading.value = false }
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value || !audioUrl.value || !audioElement.value) return
|
||||||
|
const element = audioElement.value
|
||||||
|
if (playbackElement !== element) { playback?.dispose(); playback = createFollowupAudioPlayback(element); playbackElement = element }
|
||||||
|
const routing = playback
|
||||||
|
channelLoading.value = true
|
||||||
|
try {
|
||||||
|
if (!routing || !await routing.select(channel ?? 'original')) return
|
||||||
|
if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value) return
|
||||||
|
playbackChannel.value = channel ?? 'original'
|
||||||
|
if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return
|
||||||
|
element.currentTime = Math.max(0, milliseconds / 1000)
|
||||||
|
try { await element.play() } catch { if (scope.current(ticket) && operation === playbackRequest) playbackError.value = '播放未启动,请使用播放器控件继续。' }
|
||||||
|
} catch (error) {
|
||||||
|
if (scope.current(ticket) && operation === playbackRequest) { playbackChannel.value = 'original'; playbackError.value = followupError(error) }
|
||||||
|
} finally { if (scope.current(ticket) && operation === playbackRequest) channelLoading.value = false }
|
||||||
|
}
|
||||||
|
function formatExpiry(value: number | string) {
|
||||||
|
if (!value) return '由保留策略控制'
|
||||||
|
const date = typeof value === 'number' || /^\d+$/.test(value) ? new Date(Number(value) * 1000) : new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||||
|
}
|
||||||
|
function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' }
|
||||||
|
watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.preview_only], ([id, active]) => {
|
||||||
|
stop()
|
||||||
|
if (id !== lastDiagnosis) {
|
||||||
|
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||||
|
tasks.value = []; file.value = null; recordedAt.value = ''; uploadChannelRoles.value = 'unconfirmed'; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||||
|
}
|
||||||
|
if (active) { void refresh(); scheduleAutosave() }
|
||||||
|
}, { immediate: true })
|
||||||
|
watch([() => JSON.stringify(reviewItems.value), channelRoles], () => { if (!hydrating) scheduleAutosave() }, { flush: 'sync' })
|
||||||
|
watch(() => props.capabilities.models, models => {
|
||||||
|
if (!models.some(model => model.value === modelKey.value)) modelKey.value = models[0]?.value || ''
|
||||||
|
})
|
||||||
|
onMounted(() => document.addEventListener('visibilitychange', onVisibility))
|
||||||
|
onBeforeUnmount(() => { stop(); document.removeEventListener('visibilitychange', onVisibility) })
|
||||||
|
defineExpose({ confirmLeave, refresh, hasUnsavedChanges: () => dirty.value })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.followup-panel { display: grid; gap: 12px; min-width: 0; color: var(--el-text-color-primary); font-size: 14px; }
|
||||||
|
.panel-toolbar, .toolbar-actions, .detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||||
|
.toolbar-actions { justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||||
|
.toolbar-title h3 span { margin-left: 5px; font-weight: 400; color: var(--el-text-color-secondary); }
|
||||||
|
.help-box { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 14px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.7; }
|
||||||
|
.help-box p { margin: 3px 0; }
|
||||||
|
.task-list, .task-detail { min-width: 0; max-width: 100%; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; overflow: hidden; }
|
||||||
|
.task-list { padding: 4px 12px; }
|
||||||
|
.task-list :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||||
|
.task-list :deep(.el-table .cell) { line-height: 1.5; }
|
||||||
|
.filename-button { max-width: 100%; height: auto; text-align: left; white-space: normal; word-break: break-word; line-height: 1.6; }
|
||||||
|
.filename-button :deep(span) { overflow-wrap: anywhere; }
|
||||||
|
.task-time, .recording-title > span { display: block; color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
|
||||||
|
.task-detail { padding: 16px; display: grid; gap: 14px; overflow: visible; }
|
||||||
|
.detail-heading { align-items: flex-start; }
|
||||||
|
.recording-title { min-width: 0; }
|
||||||
|
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
|
||||||
|
.detail-heading :deep(.el-tag) { flex-shrink: 0; }
|
||||||
|
.detail-loading { padding: 32px; text-align: center; color: var(--el-text-color-secondary); }
|
||||||
|
.processing-state, .applied-note { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.retry-button { justify-self: start; }
|
||||||
|
.summary-box { padding-bottom: 12px; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.summary-box > p { margin: 0 0 10px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.summary-counts { display: flex; gap: 20px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.summary-counts strong { color: var(--el-text-color-primary); font-weight: 600; }
|
||||||
|
.channel-role-box { display: grid; gap: 6px; font-size: 12px; }
|
||||||
|
.role-summary { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.channel-role-box label { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.channel-role-box :deep(.el-select) { width: 260px; max-width: 100%; }
|
||||||
|
.channel-playback { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||||
|
.channel-playback :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
.transcript-segments { max-height: 320px; overflow-y: auto; margin-top: 12px; }
|
||||||
|
.transcript-segment { padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.segment-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.transcript-segment pre { margin: 5px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||||
|
.dialogue-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 10px; margin-top: 8px; }
|
||||||
|
.dialogue-side { padding: 10px; background: var(--el-fill-color-light); border-radius: 6px; min-width: 0; }
|
||||||
|
.dialogue-side strong { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.original-collapse { --el-collapse-border-color: #e2e8f0; border-top: 0; }
|
||||||
|
.original-collapse :deep(.el-collapse-item__header) { height: 38px; font-size: 13px; color: var(--el-text-color-regular); }
|
||||||
|
.original-collapse :deep(.el-collapse-item__content) { padding-bottom: 10px; }
|
||||||
|
.audio-player { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.audio-player audio { width: 100%; max-width: 440px; height: 36px; }
|
||||||
|
.transcript-text { max-height: 280px; overflow-y: auto; margin: 12px 0 0; padding: 12px; background: #f8fafc; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||||
|
.review-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: center; padding-top: 2px; }
|
||||||
|
.selection-summary { display: grid; gap: 3px; font-size: 13px; }
|
||||||
|
.selection-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.action-buttons { display: flex; gap: 8px; }
|
||||||
|
.action-buttons :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
.review-error { color: var(--el-color-danger); width: 100%; margin: 0; font-size: 12px; }
|
||||||
|
.muted, .upload-stage { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; margin: 0; }
|
||||||
|
.upload-stage { margin: 10px 0; color: var(--el-text-color-regular); }
|
||||||
|
.upload-form { min-width: 0; }
|
||||||
|
.upload-form input { max-width: 100%; }
|
||||||
|
.upload-form small { display: block; color: var(--el-text-color-secondary); line-height: 1.5; margin-top: 6px; }
|
||||||
|
.upload-form :deep(.el-date-editor), .upload-form :deep(.el-select) { width: 100%; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.panel-toolbar { align-items: flex-start; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.toolbar-actions { gap: 6px; }
|
||||||
|
.task-list { padding: 0 4px; }
|
||||||
|
.task-detail { padding: 12px; gap: 12px; }
|
||||||
|
.detail-heading { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.review-actions { align-items: flex-start; }
|
||||||
|
.action-buttons { margin-left: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<section class="followup-review" aria-label="回访录音人工审核">
|
||||||
|
<el-tabs v-model="group" class="review-tabs" aria-label="提炼结果分类">
|
||||||
|
<el-tab-pane :label="`全部 ${counts.all}`" name="all" />
|
||||||
|
<el-tab-pane :label="`诊单资料 ${counts.diagnosis}`" name="diagnosis" />
|
||||||
|
<el-tab-pane :label="`日常记录 ${counts.daily}`" name="daily" />
|
||||||
|
<el-tab-pane :label="`待核实 ${counts.pending + (uncertainties?.length || 0)}`" name="pending" />
|
||||||
|
</el-tabs>
|
||||||
|
<div class="review-caption"><span>{{ applied ? '审核结果' : '提炼结果' }} · {{ visibleItems.length }} 项</span><span v-if="!applied">跨分类已选 {{ counts.selected }} 项</span></div>
|
||||||
|
<p v-if="!canDaily && counts.daily" class="permission-note">日常记录仅供查看,当前账号没有编辑权限。</p>
|
||||||
|
<div class="review-table">
|
||||||
|
<el-table :data="visibleItems" max-height="360" row-key="id" size="small" table-layout="fixed" :empty-text="items.length ? '此分类暂无内容' : '暂无结构化结果,可查看录音原文'" aria-label="回访提炼结果">
|
||||||
|
<el-table-column v-if="!applied" label="采纳" width="50" align="center">
|
||||||
|
<template #default="{ row }"><el-checkbox :model-value="row.selected" :disabled="disabled || (!row.selected && selectionBlocked(row))" :aria-label="`采纳${itemTitle(row)}`" @update:model-value="(value: boolean | string | number) => toggleSelection(row, !!value)" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="字段 / 内容" min-width="210">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-for="field in itemFields(row).slice(0, 2)" :key="field.key" class="result-line"><span>{{ field.label }}:</span>{{ fieldText(row.values[field.key], field) }}</div>
|
||||||
|
<span v-if="!itemFields(row).length" class="muted">暂无有效字段</span>
|
||||||
|
<div class="item-meta">{{ itemKindLabel(row) }}<span v-if="itemFields(row).length > 2"> · 共 {{ itemFields(row).length }} 个字段</span><span v-if="row.target_id"> · 更新已有记录</span></div>
|
||||||
|
<div v-if="row.kind !== 'diagnosis'" class="item-meta mobile-event-time">{{ row.record_date || '日期待核实' }} · {{ reviewTimeValue(row.record_time) || row.time_period || '时间待核实' }}{{ row.time_estimated ? '(推定)' : '' }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="日期 / 时段" width="130">
|
||||||
|
<template #default="{ row }"><span>{{ row.kind === 'diagnosis' ? '—' : row.record_date || '待补充日期' }}</span><div v-if="row.kind !== 'diagnosis'" class="item-meta">{{ reviewTimeValue(row.record_time) || row.time_period || '未提及时间' }}{{ row.time_estimated ? '(推定)' : '' }}</div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }"><el-tag size="small" :type="itemStatus(row).type">{{ itemStatus(row).label }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="60" align="right" fixed="right">
|
||||||
|
<template #default="{ row }"><el-button link type="primary" @click="openReview(row.id)">{{ applied || disabled ? '查看' : '核对' }}</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<section v-if="group === 'pending' && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
|
||||||
|
<strong>原文中还需补充的信息</strong>
|
||||||
|
<ul><li v-for="(value, index) in uncertainties" :key="index">{{ uncertaintyText(value) }}</li></ul>
|
||||||
|
</section>
|
||||||
|
<section ref="reviewWorkspace" class="review-workspace" tabindex="-1" aria-label="同页核对与回听">
|
||||||
|
<div class="review-player"><slot name="player" /></div>
|
||||||
|
<p v-if="navigationNotice" class="editor-notice" role="status">{{ navigationNotice }}</p>
|
||||||
|
<div v-if="editor" class="inline-editor-heading"><strong>{{ applied || disabled ? '查看审核结果' : '核对提炼内容' }} · {{ editorPosition }} / {{ items.length }}</strong><el-button link @click="cancelReview()">{{ editorDirty ? '取消本项修改' : '收起核对' }}</el-button></div>
|
||||||
|
<section v-if="editor" class="review-editor">
|
||||||
|
<div class="editor-heading"><strong>{{ followupKindLabels[editor.kind] }}</strong><el-tag size="small" :type="itemStatus(editor).type">{{ itemStatus(editor).label }}</el-tag></div>
|
||||||
|
<p v-if="editorNotice" class="editor-notice" role="status">{{ editorNotice }}</p>
|
||||||
|
<div class="evidence">
|
||||||
|
<div class="evidence-title">录音原话</div>
|
||||||
|
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><div v-if="quote.channel === 0 || quote.channel === 1" class="muted">{{ followupChannelLabel(quote.channel, channelRoles) }}</div><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
|
||||||
|
<p v-if="editor.evidence.some(quote => quote.position_type === 'segment')" class="muted">回听位置为转写片段范围,不是原话的逐字时间戳;默认回听双方对话,请结合原始录音核对。</p>
|
||||||
|
<p v-if="!editor.evidence.length" class="muted">未提供原话证据,请人工核实。</p>
|
||||||
|
<p v-if="editor.date_text || editor.time_text" class="muted">时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="editor.conflict || editor.possible_duplicate || editor.time_estimated" class="review-flags"><span v-if="editor.conflict">与已有值冲突</span><span v-if="editor.possible_duplicate">可能存在重复记录</span><span v-if="editor.time_estimated">时间由时段推定</span></div>
|
||||||
|
<template v-if="editor.kind !== 'diagnosis'">
|
||||||
|
<div v-if="disabled || applied" class="readonly-event"><span>{{ editor.record_date || '未记录日期' }} · {{ reviewTimeValue(editor.record_time) || editor.time_period || '未记录时间' }}</span><span>{{ editor.target_id ? `更新记录 #${editor.target_id}` : '新增独立记录' }}</span></div>
|
||||||
|
<div v-else class="event-time">
|
||||||
|
<label class="target-field">写入目标<el-select :model-value="editor.target_id || 0" @update:model-value="(value: number) => chooseTarget(value)"><el-option label="新增独立记录(不覆盖已有行)" :value="0" /><el-option v-for="candidate in editorCandidates" :key="candidate.id" :value="candidate.id" :disabled="!!candidate.delete_time" :label="candidateLabel(editor, candidate)" /></el-select></label>
|
||||||
|
<label>记录日期<el-date-picker :model-value="editor.record_date" type="date" value-format="YYYY-MM-DD" placeholder="请核实日期" @update:model-value="(value: string | null) => patchEditor({ record_date: value || null, target_id: null })" /></label>
|
||||||
|
<label>记录时间<el-time-picker :model-value="reviewTimeValue(editor.record_time)" value-format="HH:mm" format="HH:mm" placeholder="未提及可留空" @update:model-value="(value: string | null) => patchEditor({ record_time: value || null })" /></label>
|
||||||
|
<label>原始时段<el-input :model-value="editor.time_period || ''" placeholder="如早晨、餐后" @update:model-value="(value: string) => patchEditor({ time_period: value || null })" /></label>
|
||||||
|
</div>
|
||||||
|
<p v-if="targetChanged && !applied" class="target-hint" role="status">日期或目标已更改。保存本项后将自动同步草稿并刷新目标当前值,请依据新值重新核对。</p>
|
||||||
|
</template>
|
||||||
|
<div class="field-grid field-grid--header"><span>字段</span><span>{{ applied ? '写入前值' : targetChanged ? '旧目标值(待刷新)' : '当前值' }}</span><span>{{ applied || disabled ? '审核值' : '拟采纳值' }}</span></div>
|
||||||
|
<div v-for="field in itemFields(editor)" :key="field.key" class="field-grid">
|
||||||
|
<div class="field-label">{{ field.label }}<small v-if="field.readonly">只读</small></div>
|
||||||
|
<div class="current-value">{{ fieldText(editor.current_values[field.key], field) }}</div>
|
||||||
|
<div class="suggested-value"><FollowupAudioField :field="field" :model-value="editor.values[field.key]" :readonly="disabled || applied || field.readonly" @update:model-value="value => setValue(field.key, value)" /><el-button v-if="!disabled && !applied" link type="danger" :disabled="Object.keys(editor.values).length <= 1" @click="removeField(field.key)">移除</el-button></div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!disabled && !applied" class="muted">仅可更正或移除本项提议字段,不采纳整项请取消勾选;新增内容请返回原诊单编辑人工补录。</p>
|
||||||
|
<el-checkbox v-if="!disabled && !applied" :model-value="!editor.needs_review" class="review-confirm" @update:model-value="(value: boolean | string | number) => confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容</el-checkbox>
|
||||||
|
<p v-if="editorIssue && !disabled && !applied" class="review-error" role="status">{{ editorIssue }}</p>
|
||||||
|
</section>
|
||||||
|
<div v-if="editor && !disabled && !applied" class="editor-footer">
|
||||||
|
<span class="draft-hint" role="status">{{ saveState === 'saving' || saveState === 'pending' ? '审核草稿同步中,不写入病例' : saveState === 'error' || saveState === 'conflict' ? '草稿同步需处理,请查看下方提示' : '核对结果自动保存为草稿,最终确认后才写入病例' }}</span>
|
||||||
|
<el-button @click="skipAndNext">暂不采纳并下一项</el-button>
|
||||||
|
<el-button :disabled="!editorDirty" @click="saveReview(false)">保存本项修改</el-button>
|
||||||
|
<el-button type="primary" :disabled="!!editorIssue || targetChanged" @click="saveReview(true, true)">{{ nextReviewId ? '采纳并下一项' : '完成核对并选中' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import type { FollowupAudioChannel, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
|
||||||
|
import FollowupAudioField from './FollowupAudioField.vue'
|
||||||
|
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupChannelLabel, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
|
||||||
|
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[]; channelRoles?: FollowupChannelRoles; saveState?: string; persistedRevision?: number }>()
|
||||||
|
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number, channel?: FollowupAudioChannel): void; (event: 'saved-next'): void }>()
|
||||||
|
type Group = 'all' | 'diagnosis' | 'daily' | 'pending'
|
||||||
|
type Editable = Partial<Pick<FollowupReviewItem, 'values' | 'record_date' | 'record_time' | 'time_period' | 'selected' | 'target_id' | 'needs_review'>>
|
||||||
|
const group = ref<Group>('all')
|
||||||
|
const editor = ref<FollowupReviewItem | null>(null)
|
||||||
|
const editorSource = ref('')
|
||||||
|
const editorNotice = ref('')
|
||||||
|
const navigationNotice = ref('')
|
||||||
|
const reviewWorkspace = ref<HTMLElement | null>(null)
|
||||||
|
const editorPosition = computed(() => props.items.findIndex(item => item.id === editor.value?.id) + 1)
|
||||||
|
const nextReviewId = computed(() => {
|
||||||
|
const index = visibleItems.value.findIndex(item => item.id === editor.value?.id)
|
||||||
|
return index < 0 ? null : visibleItems.value.slice(index + 1).find(item => !item.selected && !selectionBlocked(item))?.id || null
|
||||||
|
})
|
||||||
|
const counts = computed(() => reviewCounts(props.items, props.fields, props.canDaily))
|
||||||
|
const visibleItems = computed(() => props.items.filter(item => group.value === 'all' || (group.value === 'diagnosis' && item.kind === 'diagnosis') || (group.value === 'daily' && item.kind !== 'diagnosis') || (group.value === 'pending' && reviewPending(item, props.fields, props.canDaily))))
|
||||||
|
const editorDirty = computed(() => !!editor.value && JSON.stringify(editor.value) !== editorSource.value)
|
||||||
|
const targetChanged = computed(() => {
|
||||||
|
if (!editor.value || !editorSource.value) return false
|
||||||
|
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||||
|
return editor.value.target_id !== source.target_id || editor.value.record_date !== source.record_date
|
||||||
|
})
|
||||||
|
const editorCandidates = computed(() => {
|
||||||
|
if (!editor.value || !editorSource.value) return []
|
||||||
|
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||||
|
return editor.value.record_date === source.record_date ? editor.value.candidates || [] : []
|
||||||
|
})
|
||||||
|
const editorIssue = computed(() => editor.value ? reviewFieldIssue({ ...editor.value, selected: true }, props.fields) || reviewIssue({ ...editor.value, selected: true }, props.canDaily) : '')
|
||||||
|
function patch(id: string, update: Editable) { emit('update:items', props.items.map(item => item.id === id ? { ...item, ...update } : item)) }
|
||||||
|
function itemFields(item: FollowupReviewItem): FollowupField[] { return Object.keys(item.values).map(key => (props.fields[item.kind] || []).find(field => field.key === key) || { key, label: key, type: 'text', readonly: true }) }
|
||||||
|
function itemKindLabel(item: FollowupReviewItem) { return followupKindLabels[item.kind] }
|
||||||
|
function itemTitle(item: FollowupReviewItem) { return itemFields(item).map(field => field.label).join('、') || followupKindLabels[item.kind] }
|
||||||
|
function selectionBlocked(item: FollowupReviewItem) { return props.disabled || !!props.applied || !hasEditableReviewFields(item, props.fields) || (item.kind !== 'diagnosis' && !props.canDaily) || !!reviewFieldIssue({ ...item, selected: true }, props.fields) }
|
||||||
|
function toggleSelection(item: FollowupReviewItem, selected: boolean) {
|
||||||
|
if (props.disabled || props.applied) return
|
||||||
|
if (!selected) { patch(item.id, { selected: false }); return }
|
||||||
|
if (selectionBlocked(item)) return
|
||||||
|
if (reviewPending(item, props.fields, props.canDaily)) { openReview(item.id); return }
|
||||||
|
patch(item.id, { selected: true })
|
||||||
|
}
|
||||||
|
function itemStatus(item: FollowupReviewItem): { label: string; type: 'success' | 'info' | 'warning' } {
|
||||||
|
if (props.applied) return { label: item.selected ? '已写入' : '未采纳', type: item.selected ? 'success' : 'info' }
|
||||||
|
if (!hasEditableReviewFields(item, props.fields)) return { label: '只读字段', type: 'info' }
|
||||||
|
if (reviewPending(item, props.fields, props.canDaily)) return { label: '待核实', type: 'warning' }
|
||||||
|
return { label: item.selected ? '已选择' : '已核对', type: item.selected ? 'success' : 'info' }
|
||||||
|
}
|
||||||
|
function openReview(id: string) {
|
||||||
|
if (editor.value?.id === id && editorDirty.value && !props.applied) {
|
||||||
|
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editor.value && editor.value.id !== id && editorDirty.value && !props.applied) {
|
||||||
|
editorNotice.value = '当前项有未保存修改,请先保存或取消本项修改,再核对其他项。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = props.items.find(candidate => candidate.id === id)
|
||||||
|
if (!item) return
|
||||||
|
editor.value = cloneReviewItems([item])[0]; editorSource.value = JSON.stringify(editor.value); editorNotice.value = ''; navigationNotice.value = ''
|
||||||
|
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||||
|
}
|
||||||
|
function cancelReview(done?: () => void) { editor.value = null; editorSource.value = ''; editorNotice.value = ''; done?.() }
|
||||||
|
function patchEditor(update: Editable) {
|
||||||
|
if (!editor.value || props.disabled || props.applied) return
|
||||||
|
// Detail/snapshot items are editable drafts, not the server-owned original proposal.
|
||||||
|
if (update.values && Object.keys(update.values).some(key => !Object.prototype.hasOwnProperty.call(editor.value?.values, key))) return
|
||||||
|
editor.value = { ...editor.value, ...update, selected: false, needs_review: true }
|
||||||
|
}
|
||||||
|
function setValue(key: string, value: FollowupValue) {
|
||||||
|
if (!editor.value || !Object.prototype.hasOwnProperty.call(editor.value.values, key)
|
||||||
|
|| !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
|
||||||
|
patchEditor({ values: { ...editor.value.values, [key]: value } })
|
||||||
|
}
|
||||||
|
function removeField(key: string) { if (!editor.value || Object.keys(editor.value.values).length <= 1) return; const values = { ...editor.value.values }; delete values[key]; patchEditor({ values }) }
|
||||||
|
function chooseTarget(id: number) {
|
||||||
|
if (!editor.value || (id && !editorCandidates.value.some(candidate => candidate.id === id && !candidate.delete_time))) return
|
||||||
|
patchEditor({ target_id: id || null })
|
||||||
|
}
|
||||||
|
function confirmReview(confirmed: boolean) {
|
||||||
|
if (!editor.value || props.disabled || props.applied) return
|
||||||
|
editor.value = { ...editor.value, needs_review: !confirmed, selected: confirmed ? editor.value.selected : false }
|
||||||
|
}
|
||||||
|
function saveReview(select: boolean, advance = false) {
|
||||||
|
if (!editor.value || props.disabled || props.applied || (select && (editorIssue.value || targetChanged.value))) return
|
||||||
|
const item = editor.value
|
||||||
|
const nextId = advance ? nextReviewId.value : null
|
||||||
|
patch(item.id, { values: { ...item.values }, record_date: item.record_date, record_time: item.record_time, time_period: item.time_period, target_id: item.target_id, selected: select || (!editorIssue.value && !targetChanged.value && item.selected), needs_review: targetChanged.value || item.needs_review })
|
||||||
|
cancelReview()
|
||||||
|
emit('saved-next')
|
||||||
|
if (nextId) openReview(nextId)
|
||||||
|
else if (advance) navigationNotice.value = '当前列表已核对至末项;可切换分类继续,或确认写入已选内容。'
|
||||||
|
}
|
||||||
|
function skipAndNext() {
|
||||||
|
if (!editor.value || props.disabled || props.applied) return
|
||||||
|
if (editorDirty.value) { editorNotice.value = '当前项有未保存修改,请先保存或取消修改后再跳过。'; return }
|
||||||
|
const nextId = nextReviewId.value
|
||||||
|
patch(editor.value.id, { selected: false })
|
||||||
|
cancelReview(); emit('saved-next')
|
||||||
|
if (nextId) openReview(nextId)
|
||||||
|
else navigationNotice.value = '本项暂不采纳,未写入病例;可切换分类继续核对。'
|
||||||
|
}
|
||||||
|
function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<FollowupReviewItem['candidates']>[number]) {
|
||||||
|
const preview = (props.fields[item.kind] || []).filter(field => Object.prototype.hasOwnProperty.call(candidate.values, field.key)).slice(0, 2).map(field => `${field.label} ${fieldText(candidate.values[field.key], field)}`).join(',')
|
||||||
|
return `#${candidate.id} · ${candidate.record_time || candidate.time_period || '时间未记录'}${candidate.time_estimated ? '(推定)' : ''}${preview ? ` · ${preview}` : ''}`
|
||||||
|
}
|
||||||
|
function seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
|
||||||
|
if (!evidencePlaybackLabel(quote) || typeof quote.start_ms !== 'number') return
|
||||||
|
emit('seek', quote.start_ms)
|
||||||
|
}
|
||||||
|
function uncertaintyText(value: unknown) { return typeof value === 'string' ? value : JSON.stringify(value) }
|
||||||
|
watch(() => props.items.find(item => item.id === editor.value?.id), fresh => {
|
||||||
|
if (!editor.value) return
|
||||||
|
if (!fresh) { cancelReview(); return }
|
||||||
|
const current = JSON.stringify(fresh)
|
||||||
|
if (current === editorSource.value) return
|
||||||
|
const edited = editor.value
|
||||||
|
editor.value = editorDirty.value && !props.applied ? { ...cloneReviewItems([fresh])[0], values: { ...edited.values }, record_date: edited.record_date, record_time: edited.record_time, time_period: edited.time_period, target_id: edited.target_id, selected: false, needs_review: true } : cloneReviewItems([fresh])[0]
|
||||||
|
editorSource.value = current
|
||||||
|
editorNotice.value = '任务数据已更新,当前编辑已保留;请重新核对后选择。'
|
||||||
|
}, { deep: true })
|
||||||
|
watch(() => props.applied, applied => { if (applied && editor.value) openReview(editor.value.id) })
|
||||||
|
defineExpose({ hasUnsavedChanges: () => editorDirty.value })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.followup-review, .review-table { min-width: 0; max-width: 100%; }
|
||||||
|
.review-tabs :deep(.el-tabs__header) { margin: 0 0 8px; }
|
||||||
|
.review-tabs :deep(.el-tabs__item) { padding: 0 14px; font-size: 13px; height: 36px; }
|
||||||
|
.review-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e2e8f0; }
|
||||||
|
.review-caption { display: flex; gap: 12px; justify-content: space-between; margin: 0 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.review-table { overflow: hidden; }
|
||||||
|
.review-table :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||||
|
.review-table :deep(.el-table .cell) { line-height: 1.6; }
|
||||||
|
.result-line { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
||||||
|
.result-line > span { color: #64748b; }
|
||||||
|
.item-meta, .muted, .permission-note { color: #64748b; font-size: 12px; }
|
||||||
|
.item-meta { margin-top: 2px; }
|
||||||
|
.mobile-event-time { display: none; }
|
||||||
|
.permission-note { margin: 0 0 8px; }
|
||||||
|
.uncertainty-list { margin-top: 12px; padding: 10px 12px; background: #f8fafc; border-radius: 6px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.uncertainty-list strong { font-size: 12px; font-weight: 500; color: var(--el-text-color-secondary); }
|
||||||
|
.uncertainty-list ul { padding-left: 18px; margin: 6px 0 0; }
|
||||||
|
.review-editor { display: grid; gap: 12px; min-width: 0; max-height: 65vh; overflow-y: auto; padding-right: 4px; }
|
||||||
|
.editor-heading { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.editor-heading strong { font-size: 14px; }
|
||||||
|
.evidence { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; overflow-wrap: anywhere; }
|
||||||
|
.evidence-title { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.evidence blockquote { margin: 6px 0; white-space: pre-wrap; line-height: 1.6; }
|
||||||
|
.evidence blockquote :deep(.el-button) { margin-left: 8px; }
|
||||||
|
.evidence p { margin: 6px 0 0; }
|
||||||
|
.review-flags { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--el-color-warning-dark-2); font-size: 12px; }
|
||||||
|
.event-time { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.event-time label { display: grid; gap: 5px; font-size: 12px; color: var(--el-text-color-regular); }
|
||||||
|
.event-time .target-field { grid-column: 1 / -1; }
|
||||||
|
.event-time :deep(.el-date-editor), .event-time :deep(.el-select), .suggested-value :deep(.el-input-number), .suggested-value :deep(.el-select), .suggested-value :deep(.el-date-editor) { width: 100%; }
|
||||||
|
.readonly-event { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; }
|
||||||
|
.target-hint, .editor-notice { margin: 0; color: var(--el-color-warning-dark-2); font-size: 12px; line-height: 1.6; }
|
||||||
|
.field-grid { display: grid; grid-template-columns: 1fr 1fr 1.6fr; align-items: start; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
|
||||||
|
.field-grid--header { color: var(--el-text-color-secondary); font-size: 12px; padding-bottom: 6px; }
|
||||||
|
.field-label small { display: block; color: var(--el-text-color-secondary); margin-top: 3px; }
|
||||||
|
.current-value { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.suggested-value { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.suggested-value > :deep(.el-button) { justify-self: end; font-size: 12px; }
|
||||||
|
.review-confirm { white-space: normal; height: auto; min-height: 32px; align-items: flex-start; }
|
||||||
|
.review-confirm :deep(.el-checkbox__input) { margin-top: 4px; }
|
||||||
|
.review-confirm :deep(.el-checkbox__label) { white-space: normal; line-height: 1.6; font-size: 13px; }
|
||||||
|
.review-workspace { margin-top: 14px; scroll-margin-top: 12px; outline: none; }
|
||||||
|
.review-workspace:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; }
|
||||||
|
.review-player { position: sticky; top: 0; z-index: 2; background: var(--el-bg-color, #fff); padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.inline-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0; }
|
||||||
|
.draft-hint { flex: 1 1 100%; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.review-error { color: var(--el-color-danger); font-size: 12px; margin: 0; }
|
||||||
|
.editor-footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.editor-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mobile-event-time { display: block; }
|
||||||
|
.review-tabs :deep(.el-tabs__item) { padding: 0 10px; }
|
||||||
|
.review-caption { gap: 6px; flex-wrap: wrap; }
|
||||||
|
.event-time { grid-template-columns: 1fr; }
|
||||||
|
.field-grid { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.field-grid--header { display: none; }
|
||||||
|
.field-label { font-weight: 600; }
|
||||||
|
.current-value::before { content: '当前值:'; color: var(--el-text-color-secondary); }
|
||||||
|
.suggested-value::before { content: '审核值:'; color: var(--el-text-color-secondary); }
|
||||||
|
.editor-footer { gap: 6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['rv-cell', { 'rv-cell--compact': compact }]">
|
||||||
|
<!-- 行 1:[第 N 诊] tag + 已开方/未开方 + 复诊医生 -->
|
||||||
|
<div class="rv-line rv-line--head">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="showDue && tooltipLines.length"
|
||||||
|
placement="top"
|
||||||
|
:show-after="300"
|
||||||
|
:hide-after="0"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="rv-tooltip">
|
||||||
|
<div v-for="line in tooltipLines" :key="line">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-tag size="small" :type="slotTagType" effect="light" class="rv-slot-tag">
|
||||||
|
{{ slotText }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag
|
||||||
|
v-else-if="showDue"
|
||||||
|
size="small"
|
||||||
|
:type="slotTagType"
|
||||||
|
effect="light"
|
||||||
|
class="rv-slot-tag"
|
||||||
|
>
|
||||||
|
{{ slotText }}
|
||||||
|
</el-tag>
|
||||||
|
<span
|
||||||
|
v-if="prescribedLabel"
|
||||||
|
:class="['rv-prescribed', hasPrescription ? 'rv-prescribed--yes' : 'rv-prescribed--no']"
|
||||||
|
>
|
||||||
|
{{ prescribedLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="followupDoctorName" class="rv-doctor">
|
||||||
|
<span v-if="prescribedLabel" class="rv-dot">·</span>{{ followupDoctorName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="showDue">
|
||||||
|
<!-- 行 2:最新开药日 · 开药 X 天 · 锚点来源;缺天数时橙色 -->
|
||||||
|
<div class="rv-line rv-line--order">
|
||||||
|
<span v-if="orderDateText" class="rv-order-date">{{ orderDateText }} 开药</span>
|
||||||
|
<span v-if="orderDateText" class="rv-dot">·</span>
|
||||||
|
<span :class="['rv-days', { 'rv-days--missing': daysMissing }]">{{ medicationText }}</span>
|
||||||
|
<template v-if="!daysMissing && anchorText">
|
||||||
|
<span class="rv-dot">·</span>
|
||||||
|
<span class="rv-anchor">{{ anchorText }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行 3:药尽 MM-DD + 状态 chip -->
|
||||||
|
<div class="rv-line rv-line--due">
|
||||||
|
<span v-if="dueDateText" class="rv-due-date">{{ dueDateText }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="chipText"
|
||||||
|
size="small"
|
||||||
|
:type="chipType"
|
||||||
|
:effect="chipEffect"
|
||||||
|
:class="chipClass"
|
||||||
|
>
|
||||||
|
{{ chipText }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行 4:已延迟 N 次 · 已联系 约 MM-DD(有值才显示) -->
|
||||||
|
<div v-if="followLineText" class="rv-line rv-line--follow">{{ followLineText }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rv-none">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 复诊节点单元格(PC 列表 / H5 卡片紧凑 / 只读页 三处共用)
|
||||||
|
*
|
||||||
|
* 只负责展示,不发请求;数据来自列表行 `revisit_due`(契约 C),由父组件用
|
||||||
|
* `normalizeRevisitDue` 规整后传入。state='none' 或 due 为 null 时仅显示行 1 + 「—」。
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatRevisitMonthDay,
|
||||||
|
hasRevisitDue,
|
||||||
|
isRevisitDaysMissing,
|
||||||
|
revisitAnchorSrcText,
|
||||||
|
revisitDueDateText,
|
||||||
|
revisitFollowLineText,
|
||||||
|
revisitMedicationDaysText,
|
||||||
|
revisitSlotText,
|
||||||
|
revisitStateChipClass,
|
||||||
|
revisitStateChipText,
|
||||||
|
revisitStateTagEffect,
|
||||||
|
revisitStateTagType,
|
||||||
|
revisitTooltipLines,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 规整后的复诊节点对象;null / state='none' 显示「—」 */
|
||||||
|
due: RevisitDue | null
|
||||||
|
/** H5 紧凑模式:更小字号、更紧行距 */
|
||||||
|
compact?: boolean
|
||||||
|
/** 是否已开方(决定「已开方/未开方」默认文案与颜色) */
|
||||||
|
hasPrescription?: boolean
|
||||||
|
/** 复诊医生(原列表 followup_doctor_name) */
|
||||||
|
followupDoctorName?: string
|
||||||
|
/** 自定义「已开方/未开方」文案;传 '' 隐藏该段(H5 卡片头已有标签时用) */
|
||||||
|
prescribedText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
compact: false,
|
||||||
|
hasPrescription: false,
|
||||||
|
followupDoctorName: ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const showDue = computed(() => hasRevisitDue(props.due))
|
||||||
|
|
||||||
|
const prescribedLabel = computed(() => {
|
||||||
|
if (props.prescribedText !== undefined) return props.prescribedText
|
||||||
|
return props.hasPrescription ? '已开方' : '未开方'
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotText = computed(() => revisitSlotText(props.due))
|
||||||
|
const slotTagType = computed<'primary' | 'info'>(() =>
|
||||||
|
showDue.value && Number(props.due?.current_slot) > 0 ? 'primary' : 'info'
|
||||||
|
)
|
||||||
|
const tooltipLines = computed(() => revisitTooltipLines(props.due))
|
||||||
|
|
||||||
|
const orderDateText = computed(() => (showDue.value ? formatRevisitMonthDay(props.due?.order_date) : ''))
|
||||||
|
const daysMissing = computed(() => isRevisitDaysMissing(props.due))
|
||||||
|
const medicationText = computed(() => revisitMedicationDaysText(props.due))
|
||||||
|
const anchorText = computed(() => (showDue.value ? revisitAnchorSrcText(props.due?.anchor_src) : ''))
|
||||||
|
|
||||||
|
const dueDateText = computed(() => revisitDueDateText(props.due))
|
||||||
|
const chipText = computed(() => revisitStateChipText(props.due))
|
||||||
|
const chipType = computed(() => revisitStateTagType(props.due?.state))
|
||||||
|
const chipEffect = computed(() => revisitStateTagEffect(props.due?.state))
|
||||||
|
const chipClass = computed(() => revisitStateChipClass(props.due))
|
||||||
|
|
||||||
|
const followLineText = computed(() => revisitFollowLineText(props.due))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rv-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
line-height: 1.35;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--head {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-slot-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed--yes {
|
||||||
|
color: #4a5dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed--no {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-doctor {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-dot {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--order {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-days--missing {
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-anchor {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--due {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-due-date {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 chip:el-tag 基础色由 type/effect 决定,这里只补深红加粗与描边细节。
|
||||||
|
前缀 .rv-cell 提高特异性,压过 Element 的 .el-tag--dark.el-tag--danger 变量定义 */
|
||||||
|
.rv-cell .rv-chip {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--overdue,
|
||||||
|
.rv-cell .rv-chip--missed {
|
||||||
|
--el-tag-bg-color: #dc2626;
|
||||||
|
--el-tag-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--severe,
|
||||||
|
.rv-cell .rv-chip--overdue.rv-chip--severe,
|
||||||
|
.rv-cell .rv-chip--missed.rv-chip--severe {
|
||||||
|
font-weight: 700;
|
||||||
|
--el-tag-bg-color: #b91c1c;
|
||||||
|
--el-tag-border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--warning {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||||
|
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--no_days {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-border-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--visited {
|
||||||
|
--el-tag-text-color: #16a34a;
|
||||||
|
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||||
|
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--booked {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||||
|
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--contacted {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-border-color: #4a5dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--follow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-none {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-tooltip {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 紧凑模式 */
|
||||||
|
.rv-cell--compact {
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.rv-line--head,
|
||||||
|
.rv-line--due,
|
||||||
|
.rv-prescribed {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--order,
|
||||||
|
.rv-line--follow,
|
||||||
|
.rv-doctor {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-none {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export type FollowupPlaybackChannel = 'original' | 0 | 1
|
||||||
|
|
||||||
|
function browserAudioContext(): AudioContext | null {
|
||||||
|
const browser = globalThis as typeof globalThis & { webkitAudioContext?: typeof AudioContext }
|
||||||
|
const Constructor = browser.AudioContext || browser.webkitAudioContext
|
||||||
|
return Constructor ? new Constructor() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One authorized HTML audio element owns one lazy graph; isolated audio is duplicated to both speakers. */
|
||||||
|
export function createFollowupAudioPlayback(element: HTMLMediaElement, createContext: () => AudioContext | null = browserAudioContext) {
|
||||||
|
let context: AudioContext | null = null
|
||||||
|
let source: MediaElementAudioSourceNode | null = null
|
||||||
|
let splitter: ChannelSplitterNode | null = null
|
||||||
|
let merger: ChannelMergerNode | null = null
|
||||||
|
let disposed = false
|
||||||
|
let generation = 0
|
||||||
|
function disconnect() { source?.disconnect(); splitter?.disconnect(); merger?.disconnect() }
|
||||||
|
return {
|
||||||
|
async select(channel: FollowupPlaybackChannel): Promise<boolean> {
|
||||||
|
if (disposed) return false
|
||||||
|
const ticket = ++generation
|
||||||
|
element.pause()
|
||||||
|
if (channel === 'original' && !context) return true
|
||||||
|
try {
|
||||||
|
if (!context) {
|
||||||
|
context = createContext()
|
||||||
|
if (!context) throw new Error('当前浏览器不支持 Web Audio,无法隔离声道;请使用原音播放。')
|
||||||
|
source = context.createMediaElementSource(element)
|
||||||
|
splitter = context.createChannelSplitter(2)
|
||||||
|
merger = context.createChannelMerger(2)
|
||||||
|
}
|
||||||
|
if (!source) throw new Error('音频播放节点未就绪')
|
||||||
|
disconnect()
|
||||||
|
if (channel === 'original') source.connect(context.destination)
|
||||||
|
else {
|
||||||
|
if (!splitter || !merger) throw new Error('声道播放节点未就绪')
|
||||||
|
source.connect(splitter)
|
||||||
|
splitter.connect(merger, channel, 0)
|
||||||
|
splitter.connect(merger, channel, 1)
|
||||||
|
merger.connect(context.destination)
|
||||||
|
}
|
||||||
|
await context.resume()
|
||||||
|
if (!disposed && ticket === generation && context.state !== 'running') throw new Error('音频上下文未运行')
|
||||||
|
return !disposed && ticket === generation
|
||||||
|
} catch (error) {
|
||||||
|
if (disposed || ticket !== generation) return false
|
||||||
|
disconnect()
|
||||||
|
// Keep an explicit original-audio fallback; never autoplay it after failed isolation.
|
||||||
|
if (source && context) source.connect(context.destination)
|
||||||
|
throw new Error(`无法隔离声道,已停止本次回听;可选择原音播放。${error instanceof Error ? error.message : ''}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return
|
||||||
|
disposed = true; generation++; element.pause(); disconnect()
|
||||||
|
if (context) void context.close().catch(() => { /* Element is already detached and all nodes disconnected. */ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { FollowupAudioChannel, FollowupCapabilities, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupTranscriptSegment, FollowupValue } from '@/api/followupAudio'
|
||||||
|
|
||||||
|
export const FOLLOWUP_CHUNK_BYTES = 2097152
|
||||||
|
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
|
||||||
|
export const followupStatusLabels: Record<string, string> = {
|
||||||
|
queued: '等待处理', running: '正在分析', review: '待人工审核', applied: '已采纳',
|
||||||
|
failed: '处理失败', needs_reconciliation: '结果待核对', expired: '已过保留期'
|
||||||
|
}
|
||||||
|
const followupStageLabels: Record<string, string> = { transcribing: '正在转写', extracting: '正在提炼', validating: '正在校验', review: '待人工审核' }
|
||||||
|
export function followupTaskStatus(task: Pick<FollowupTaskSummary, 'status' | 'stage'>): string {
|
||||||
|
return (task.status === 'running' && followupStageLabels[task.stage]) || followupStatusLabels[task.status] || task.status
|
||||||
|
}
|
||||||
|
export function followupProgressText(task: FollowupTaskSummary): string {
|
||||||
|
const progress = task.pipeline_progress
|
||||||
|
const valid = progress && Number.isSafeInteger(progress.transcribed) && Number.isSafeInteger(progress.total)
|
||||||
|
&& progress.total > 0 && progress.transcribed >= 0 && progress.transcribed <= progress.total
|
||||||
|
return `${followupTaskStatus(task)}${valid ? ` · 转写片段 ${progress.transcribed} / ${progress.total}` : ''};人工审核前不会写入业务记录。`
|
||||||
|
}
|
||||||
|
export function retainedTranscriptNotice(task: FollowupTaskDetail): string {
|
||||||
|
if (!task.transcript_available || !task.transcript.trim()) return ''
|
||||||
|
if (task.status === 'failed') return '处理失败,已完成片段的转写仍可查看。保留转写不代表提炼成功,不能直接写入病历。'
|
||||||
|
if (task.status === 'needs_reconciliation') return '结果待核对,已完成片段的转写仍可查看。请先核对上游结果,不要重复发送。'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][number]): string {
|
||||||
|
if (typeof quote.start_ms !== 'number' || !Number.isFinite(quote.start_ms) || quote.start_ms < 0) return ''
|
||||||
|
if (quote.channel !== undefined && quote.channel !== 0 && quote.channel !== 1) return ''
|
||||||
|
if (quote.position_type === 'segment') {
|
||||||
|
if (!quote.segment_id || typeof quote.end_ms !== 'number' || !Number.isFinite(quote.end_ms) || quote.end_ms <= quote.start_ms) return ''
|
||||||
|
return `回听对话 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}`
|
||||||
|
}
|
||||||
|
return `播放 ${audioTimestamp(quote.start_ms)}`
|
||||||
|
}
|
||||||
|
export function audioTimestamp(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
|
||||||
|
export function normalizedChannelRoles(value: unknown): FollowupChannelRoles { return value === 'left_service' || value === 'right_service' ? value : 'unconfirmed' }
|
||||||
|
export function hasStereoTranscript(segments: FollowupTranscriptSegment[] = []): boolean { return segments.some(segment => segment.channel === 0) && segments.some(segment => segment.channel === 1) }
|
||||||
|
export function followupChannelLabel(channel?: FollowupAudioChannel, roles: FollowupChannelRoles = 'unconfirmed'): string {
|
||||||
|
if (channel !== 0 && channel !== 1) return '未标注声道'
|
||||||
|
const side = channel === 0 ? '左声道' : '右声道'
|
||||||
|
const confirmed = normalizedChannelRoles(roles)
|
||||||
|
if (confirmed === 'unconfirmed') return `${side} · 角色待确认`
|
||||||
|
const service = confirmed === 'left_service' ? channel === 0 : channel === 1
|
||||||
|
return `${side} · ${service ? '客服端' : '患者/家属端'}(人工标注)`
|
||||||
|
}
|
||||||
|
/** Preserve coarse ASR windows; never invent speaker turn order or word-level timestamps. */
|
||||||
|
export function dialogueWindows(segments: FollowupTranscriptSegment[]) {
|
||||||
|
const windows = new Map<string, { id: string; start_ms: number; end_ms: number; segments: FollowupTranscriptSegment[] }>()
|
||||||
|
for (const segment of segments) {
|
||||||
|
const id = `${segment.start_ms}:${segment.end_ms}`
|
||||||
|
if (!windows.has(id)) windows.set(id, { id, start_ms: segment.start_ms, end_ms: segment.end_ms, segments: [] })
|
||||||
|
windows.get(id)!.segments.push(segment)
|
||||||
|
}
|
||||||
|
return [...windows.values()].sort((a, b) => a.start_ms - b.start_ms || a.end_ms - b.end_ms)
|
||||||
|
.map(window => ({ ...window, segments: [...window.segments].sort((a, b) => (a.channel ?? -1) - (b.channel ?? -1)) }))
|
||||||
|
}
|
||||||
|
export function dialoguePlaybackStart(milliseconds: number): number {
|
||||||
|
return Number.isFinite(milliseconds) ? Math.max(0, milliseconds - 3000) : 0
|
||||||
|
}
|
||||||
|
export function channelPlaybackLabel(channel: FollowupAudioChannel, roles: FollowupChannelRoles): string {
|
||||||
|
if (roles === 'unconfirmed') return channel === 0 ? '只听左声道' : '只听右声道'
|
||||||
|
const service = roles === 'left_service' ? channel === 0 : channel === 1
|
||||||
|
return service ? '只听客服' : '只听患者/家属'
|
||||||
|
}
|
||||||
|
export const cloneReviewItems = (items: FollowupReviewItem[]): FollowupReviewItem[] => JSON.parse(JSON.stringify(items))
|
||||||
|
export function fieldText(value: FollowupValue | undefined, field: FollowupField): string {
|
||||||
|
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) return '未记录'
|
||||||
|
const display = (part: string | number | boolean) => field.options?.find(option => String(option.value) === String(part))?.label || String(part)
|
||||||
|
return Array.isArray(value) ? value.map(display).join('、') : display(value)
|
||||||
|
}
|
||||||
|
export function hasEditableReviewFields(item: FollowupReviewItem, fields: FollowupFields): boolean {
|
||||||
|
return Object.keys(item.values).some(key => fields[item.kind]?.some(field => field.key === key && !field.readonly))
|
||||||
|
}
|
||||||
|
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
|
||||||
|
if (!item.selected) return ''
|
||||||
|
for (const key of Object.keys(item.values)) {
|
||||||
|
const field = fields[item.kind]?.find(candidate => candidate.key === key)
|
||||||
|
if (!field || field.readonly) return `${field?.label || key} 当前不可编辑,请先取消采纳该字段`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
export function reviewIssue(item: FollowupReviewItem, canDaily: boolean): string {
|
||||||
|
if (!item.selected) return ''
|
||||||
|
if (item.needs_review) return '请明确核对不确定、冲突或重复内容'
|
||||||
|
if (item.kind !== 'diagnosis' && !canDaily) return '没有日常记录编辑权限'
|
||||||
|
if (!Object.keys(item.values).length) return '至少填写一个有效字段'
|
||||||
|
if (item.kind !== 'diagnosis' && !validDate(item.record_date)) return '请核对并填写有效的记录日期'
|
||||||
|
if (item.record_time && !/^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(item.record_time)) return '请填写有效的记录时间'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
/** Pending is independent of selection so filtering never hides unresolved items. */
|
||||||
|
export function reviewPending(item: FollowupReviewItem, fields: FollowupFields, canDaily: boolean): boolean {
|
||||||
|
return item.needs_review || !!reviewFieldIssue({ ...item, selected: true }, fields) || !!reviewIssue({ ...item, selected: true }, canDaily)
|
||||||
|
}
|
||||||
|
export function reviewCounts(items: FollowupReviewItem[], fields: FollowupFields, canDaily: boolean) {
|
||||||
|
return {
|
||||||
|
all: items.length,
|
||||||
|
diagnosis: items.filter(item => item.kind === 'diagnosis').length,
|
||||||
|
daily: items.filter(item => item.kind !== 'diagnosis').length,
|
||||||
|
pending: items.filter(item => reviewPending(item, fields, canDaily)).length,
|
||||||
|
selected: items.filter(item => item.selected).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function changedReviewTarget(items: FollowupReviewItem[], snapshot: FollowupReviewItem[]): boolean {
|
||||||
|
const previous = new Map(snapshot.map(item => [item.id, item]))
|
||||||
|
return items.some(item => {
|
||||||
|
const original = previous.get(item.id)
|
||||||
|
return item.selected && original && (item.target_id !== original.target_id || item.record_date !== original.record_date)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/** The API accepts legacy seconds, while the review editor exposes minute precision. */
|
||||||
|
export function reviewTimeValue(value: string | null): string | null {
|
||||||
|
return value && /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value) ? value.slice(0, 5) : null
|
||||||
|
}
|
||||||
|
export function validDate(value: string | null): boolean {
|
||||||
|
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||||
|
}
|
||||||
|
/** A stale snapshot refresh never discards user values or reselects an item for them. */
|
||||||
|
export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupReviewItem[]): FollowupReviewItem[] {
|
||||||
|
const previous = new Map(local.map(item => [item.id, item]))
|
||||||
|
return cloneReviewItems(fresh).map(item => {
|
||||||
|
const edit = previous.get(item.id)
|
||||||
|
return edit ? { ...item, values: { ...edit.values }, record_date: edit.record_date, record_time: edit.record_time,
|
||||||
|
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
|
||||||
|
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
|
||||||
|
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
|
||||||
|
}
|
||||||
|
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
|
||||||
|
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified || (caps.manual_review_enabled === true && caps.manual_review_ready === true))
|
||||||
|
}
|
||||||
|
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||||
|
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
|
||||||
|
}
|
||||||
|
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||||
|
return followupFeatureEnabled(caps) && followupAudioReady(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
|
||||||
|
}
|
||||||
|
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
|
||||||
|
if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音'
|
||||||
|
if (!file || !file.size) return '请选择非空录音文件'
|
||||||
|
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
|
||||||
|
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(recordedAt) || !validDate(recordedAt.slice(0, 10))) return '请填写实际录音时间(北京时间)'
|
||||||
|
if (!caps.models.some(option => option.value === model)) return '请选择可用的分析模型'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
/** All async operations capture one generation; invalidate on patient switch, hide or dispose. */
|
||||||
|
export function createFollowupScope() {
|
||||||
|
let generation = 0
|
||||||
|
let controller = new AbortController()
|
||||||
|
return {
|
||||||
|
capture: () => ({ generation, signal: controller.signal }),
|
||||||
|
current: (ticket: { generation: number; signal: AbortSignal }) => ticket.generation === generation && !ticket.signal.aborted,
|
||||||
|
invalidate() { controller.abort(); generation++; controller = new AbortController() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function uploadChunks(
|
||||||
|
file: Blob,
|
||||||
|
chunkBytes: number,
|
||||||
|
send: (index: number, chunk: Blob) => Promise<{ received: boolean }>,
|
||||||
|
isCurrent: () => boolean,
|
||||||
|
progress: (percent: number) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 65536 || chunkBytes > FOLLOWUP_CHUNK_BYTES) throw new Error('服务端分片配置不匹配,请联系管理员')
|
||||||
|
for (let start = 0, index = 0; start < file.size; start += chunkBytes, index++) {
|
||||||
|
if (!isCurrent()) return false
|
||||||
|
const result = await send(index, file.slice(start, start + chunkBytes))
|
||||||
|
if (!isCurrent()) return false
|
||||||
|
if (!result.received) throw new Error('录音分片未被服务端确认,上传已停止')
|
||||||
|
progress(Math.round(Math.min(start + chunkBytes, file.size) / file.size * 100))
|
||||||
|
}
|
||||||
|
return isCurrent()
|
||||||
|
}
|
||||||
|
export function followupError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-tabs v-model="activeTab" class="diagnosis-tabs tcm-diagnosis-tabs">
|
<el-tabs v-model="activeTab" :before-leave="beforeTabLeave" class="diagnosis-tabs tcm-diagnosis-tabs">
|
||||||
<!-- 基本信息标签页 -->
|
<!-- 基本信息标签页 -->
|
||||||
<el-tab-pane label="病历" name="basic">
|
<el-tab-pane label="病历" name="basic">
|
||||||
<el-form
|
<el-form
|
||||||
@@ -660,6 +660,7 @@
|
|||||||
lazy
|
lazy
|
||||||
>
|
>
|
||||||
<daily-matrix
|
<daily-matrix
|
||||||
|
ref="dailyMatrixRef"
|
||||||
v-if="formData.id"
|
v-if="formData.id"
|
||||||
:diagnosis-id="Number(formData.id)"
|
:diagnosis-id="Number(formData.id)"
|
||||||
:patient-id="Number(formData.patient_id)"
|
:patient-id="Number(formData.patient_id)"
|
||||||
@@ -670,6 +671,21 @@
|
|||||||
<el-empty v-else description="请先保存诊单后查看" />
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="followupCapabilities?.enabled && formData.id" label="回访录音" name="followupAudio" lazy>
|
||||||
|
<FollowupAudioPanel
|
||||||
|
v-if="visible && formData.id && followupCapabilities"
|
||||||
|
:key="Number(formData.id)"
|
||||||
|
ref="followupAudioRef"
|
||||||
|
:diagnosis-id="Number(formData.id)"
|
||||||
|
:view-only="viewOnly"
|
||||||
|
:active="activeTab === 'followupAudio'"
|
||||||
|
:capabilities="followupCapabilities"
|
||||||
|
:basic-dirty="basicDirty"
|
||||||
|
:before-apply="canApplyFollowup"
|
||||||
|
@applied="handleFollowupApplied"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 病历记录标签页(开方后自动显示) -->
|
<!-- 病历记录标签页(开方后自动显示) -->
|
||||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||||
<div v-if="formData.id" class="bingli-tab-content">
|
<div v-if="formData.id" class="bingli-tab-content">
|
||||||
@@ -785,6 +801,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
@@ -794,6 +811,8 @@ import useAppStore from '@/stores/modules/app'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||||
import DailyMatrix from './components/DailyMatrix.vue'
|
import DailyMatrix from './components/DailyMatrix.vue'
|
||||||
|
import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
|
||||||
|
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
|
||||||
import CaseRecordList from './components/CaseRecordList.vue'
|
import CaseRecordList from './components/CaseRecordList.vue'
|
||||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||||
@@ -811,6 +830,12 @@ const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appSt
|
|||||||
const caseRecordListRef = ref()
|
const caseRecordListRef = ref()
|
||||||
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
|
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
|
||||||
const prescriptionRef = ref()
|
const prescriptionRef = ref()
|
||||||
|
const dailyMatrixRef = ref<{ refresh?: () => Promise<void> | void } | null>(null)
|
||||||
|
const followupAudioRef = ref<{ confirmLeave: () => Promise<boolean> } | null>(null)
|
||||||
|
const followupCapabilities = ref<FollowupCapabilities | null>(null)
|
||||||
|
const basicSnapshot = ref('')
|
||||||
|
let diagnosisGeneration = 0
|
||||||
|
let capabilitiesRequest: AbortController | undefined
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
|
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
|
||||||
@@ -970,6 +995,45 @@ const formData = ref({
|
|||||||
external_userid: ''
|
external_userid: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Include unmasked identity values so merely revealing a number is not an unsaved edit.
|
||||||
|
const basicFingerprint = () => JSON.stringify({ ...formData.value,
|
||||||
|
phone: originalPhone.value || formData.value.phone, id_card: originalIdCard.value || formData.value.id_card })
|
||||||
|
const basicDirty = computed(() => !viewOnly.value && !!basicSnapshot.value && basicFingerprint() !== basicSnapshot.value)
|
||||||
|
const canApplyFollowup = () => !basicDirty.value && visible.value && !viewOnly.value
|
||||||
|
async function beforeTabLeave(next: string | number, previous: string | number): Promise<boolean> {
|
||||||
|
if (next === 'followupAudio' && basicDirty.value) {
|
||||||
|
feedback.msgWarning('病历有未保存修改,请先保存后再进入回访录音审核,当前输入已保留')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (previous === 'followupAudio') return await followupAudioRef.value?.confirmLeave() ?? true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async function handleFollowupApplied(diagnosisId: number) {
|
||||||
|
if (!visible.value || Number(formData.value.id) !== diagnosisId) return
|
||||||
|
const generation = diagnosisGeneration
|
||||||
|
try {
|
||||||
|
// The response itself is also guarded, including edits made while it is in flight.
|
||||||
|
await loadDiagnosisDetailIntoForm(diagnosisId, true)
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value || Number(formData.value.id) !== diagnosisId) return
|
||||||
|
await dailyMatrixRef.value?.refresh?.()
|
||||||
|
emit('success')
|
||||||
|
} catch {
|
||||||
|
if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch([visible, () => formData.value.id], async ([isVisible, id]) => {
|
||||||
|
capabilitiesRequest?.abort()
|
||||||
|
followupCapabilities.value = null
|
||||||
|
if (!isVisible || !id) return
|
||||||
|
const request = new AbortController()
|
||||||
|
capabilitiesRequest = request
|
||||||
|
try {
|
||||||
|
const result = await followupAudioCapabilities(Number(id), request.signal)
|
||||||
|
if (!request.signal.aborted && visible.value && String(formData.value.id) === String(id)) followupCapabilities.value = result
|
||||||
|
} catch { /* Fail closed: an unavailable capability endpoint must not expose this feature. */ }
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { diagnosisGeneration++; capabilitiesRequest?.abort() })
|
||||||
|
|
||||||
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
||||||
const createSourceOptions = [
|
const createSourceOptions = [
|
||||||
{ value: '', label: '未设置' },
|
{ value: '', label: '未设置' },
|
||||||
@@ -1260,8 +1324,14 @@ const getDictOptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
async function loadDiagnosisDetailIntoForm(id: number, preserveDirty = false): Promise<void> {
|
||||||
|
const generation = diagnosisGeneration
|
||||||
const data = await tcmDiagnosisDetail({ id })
|
const data = await tcmDiagnosisDetail({ id })
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
if (preserveDirty && basicDirty.value) {
|
||||||
|
feedback.msgWarning('录音已采纳,病历仍保留你的未保存修改;请核对后再保存,未用刷新覆盖输入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
originalPhone.value = data.phone || ''
|
originalPhone.value = data.phone || ''
|
||||||
originalIdCard.value = data.id_card || ''
|
originalIdCard.value = data.id_card || ''
|
||||||
@@ -1277,6 +1347,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
|||||||
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
|
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
|
||||||
const y = data.diabetes_discovery_year
|
const y = data.diabetes_discovery_year
|
||||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||||
|
basicSnapshot.value = basicFingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPatientBasicLockState = () => {
|
const resetPatientBasicLockState = () => {
|
||||||
@@ -1286,6 +1357,11 @@ const resetPatientBasicLockState = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const open = async (type: string, id?: number) => {
|
const open = async (type: string, id?: number) => {
|
||||||
|
const generation = ++diagnosisGeneration
|
||||||
|
submitting.value = false
|
||||||
|
basicSnapshot.value = ''
|
||||||
|
followupCapabilities.value = null
|
||||||
|
formData.value.id = ''
|
||||||
viewOnly.value = false
|
viewOnly.value = false
|
||||||
mode.value = type
|
mode.value = type
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -1296,6 +1372,7 @@ const open = async (type: string, id?: number) => {
|
|||||||
|
|
||||||
// 加载字典数据
|
// 加载字典数据
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
|
||||||
if (type === 'edit' && id) {
|
if (type === 'edit' && id) {
|
||||||
await loadDiagnosisDetailIntoForm(id)
|
await loadDiagnosisDetailIntoForm(id)
|
||||||
@@ -1310,6 +1387,11 @@ const open = async (type: string, id?: number) => {
|
|||||||
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
|
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
|
||||||
const openViewOnly = async (id: number) => {
|
const openViewOnly = async (id: number) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
const generation = ++diagnosisGeneration
|
||||||
|
submitting.value = false
|
||||||
|
basicSnapshot.value = ''
|
||||||
|
followupCapabilities.value = null
|
||||||
|
formData.value.id = ''
|
||||||
viewOnly.value = true
|
viewOnly.value = true
|
||||||
mode.value = 'edit'
|
mode.value = 'edit'
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -1318,13 +1400,17 @@ const openViewOnly = async (id: number) => {
|
|||||||
idCardRevealUnlocked.value = false
|
idCardRevealUnlocked.value = false
|
||||||
resetPatientBasicLockState()
|
resetPatientBasicLockState()
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
await loadDiagnosisDetailIntoForm(id)
|
await loadDiagnosisDetailIntoForm(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (viewOnly.value) return
|
if (viewOnly.value || submitting.value) return
|
||||||
|
const generation = diagnosisGeneration
|
||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
// A user can keep typing while the request is pending. Only mark submitted bytes saved.
|
||||||
|
const submittedForm = JSON.parse(basicFingerprint()) as typeof formData.value
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// 准备提交数据,使用原始完整数据
|
// 准备提交数据,使用原始完整数据
|
||||||
@@ -1336,6 +1422,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
if (mode.value === 'add') {
|
if (mode.value === 'add') {
|
||||||
const result = await tcmDiagnosisAdd(submitData)
|
const result = await tcmDiagnosisAdd(submitData)
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
|
||||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||||
if (result && result.id) {
|
if (result && result.id) {
|
||||||
@@ -1343,11 +1430,14 @@ const handleSubmit = async () => {
|
|||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
idCardRevealUnlocked.value = false
|
idCardRevealUnlocked.value = false
|
||||||
formData.value.id = result.id
|
formData.value.id = result.id
|
||||||
|
submittedForm.id = result.id
|
||||||
|
|
||||||
// 重新获取详情,确保patient_id等字段正确
|
// 重新获取详情,确保patient_id等字段正确
|
||||||
try {
|
try {
|
||||||
const detail = await tcmDiagnosisDetail({ id: result.id })
|
const detail = await tcmDiagnosisDetail({ id: result.id })
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
formData.value.patient_id = detail.patient_id
|
formData.value.patient_id = detail.patient_id
|
||||||
|
submittedForm.patient_id = detail.patient_id
|
||||||
|
|
||||||
// 更新原始数据
|
// 更新原始数据
|
||||||
originalPhone.value = detail.phone || ''
|
originalPhone.value = detail.phone || ''
|
||||||
@@ -1367,11 +1457,13 @@ const handleSubmit = async () => {
|
|||||||
await tcmDiagnosisEdit(submitData)
|
await tcmDiagnosisEdit(submitData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
basicSnapshot.value = JSON.stringify(submittedForm)
|
||||||
emit('success')
|
emit('success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('提交失败:', error)
|
console.error('提交失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
if (generation === diagnosisGeneration) submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,7 +1487,12 @@ const handleOpenPrescription = () => {
|
|||||||
prescriptionRef.value?.open(data)
|
prescriptionRef.value?.open(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = async () => {
|
||||||
|
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
|
||||||
|
diagnosisGeneration++
|
||||||
|
capabilitiesRequest?.abort()
|
||||||
|
followupCapabilities.value = null
|
||||||
|
basicSnapshot.value = ''
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
formData.value = {
|
formData.value = {
|
||||||
id: '',
|
id: '',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
formData.revisit_state === '' &&
|
||||||
!hasLatestAppointmentFilter() &&
|
!hasLatestAppointmentFilter() &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,14 @@
|
|||||||
待预约
|
待预约
|
||||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 复诊到期:到期未挂号 ∪ 过号未面诊(revisit_state=due),点击默认按药尽日升序 -->
|
||||||
|
<span
|
||||||
|
:class="['chip', 'chip-revisit-due', { active: formData.revisit_state === 'due' }]"
|
||||||
|
@click="handleRevisitDueTabClick"
|
||||||
|
>
|
||||||
|
复诊到期
|
||||||
|
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
||||||
@click="handleCompletedVisitTabClick"
|
@click="handleCompletedVisitTabClick"
|
||||||
@@ -147,6 +156,36 @@
|
|||||||
:value="String(item.value)"
|
:value="String(item.value)"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="formData.revisit_state"
|
||||||
|
placeholder="复诊状态"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="revisit-state-select"
|
||||||
|
@change="handleRevisitFilterChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in revisitStateFilterOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="formData.revisit_days_bucket"
|
||||||
|
placeholder="开药天数档"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="revisit-days-bucket-select"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in revisitDaysBucketOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
<el-button size="small" @click="handleReset">重置</el-button>
|
<el-button size="small" @click="handleReset">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -278,22 +317,20 @@
|
|||||||
<span v-else class="status-unconfirmed">未确认</span>
|
<span v-else class="status-unconfirmed">未确认</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="复诊" min-width="120">
|
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
|
||||||
<template #default="{ row }">
|
<el-table-column
|
||||||
<div v-if="row.has_prescription" class="followup-cell">
|
label="复诊节点"
|
||||||
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
|
prop="revisit_due_date"
|
||||||
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
|
min-width="180"
|
||||||
<el-tag
|
sortable="custom"
|
||||||
v-if="row.followup_rx_voided"
|
|
||||||
type="info"
|
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
class="followup-void-tag"
|
|
||||||
>
|
>
|
||||||
处方已作废
|
<template #default="{ row }">
|
||||||
</el-tag>
|
<revisit-node-cell
|
||||||
</div>
|
:due="revisitDueOf(row)"
|
||||||
<span v-else class="followup-none">无</span>
|
:has-prescription="!!row.has_prescription"
|
||||||
|
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||||
|
:prescribed-text="prescribedTextOf(row)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
||||||
@@ -301,12 +338,6 @@
|
|||||||
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="开方" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
|
|
||||||
<span v-else class="status-unprescribed">未开方</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
<el-table-column
|
||||||
label="未服务天数"
|
label="未服务天数"
|
||||||
prop="unserved_days"
|
prop="unserved_days"
|
||||||
@@ -519,7 +550,6 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="继承">
|
<el-form-item label="继承">
|
||||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||||
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
||||||
@@ -737,7 +767,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="tcmDiagnosis">
|
<script setup lang="ts" name="tcmDiagnosis">
|
||||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
|
||||||
import { orderCreate } from '@/api/order'
|
import { orderCreate } from '@/api/order'
|
||||||
import { cancelAppointment } from '@/api/doctor'
|
import { cancelAppointment } from '@/api/doctor'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
|
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||||
|
import { formatGender } from '@/utils/diag-display'
|
||||||
|
import {
|
||||||
|
normalizeRevisitDue,
|
||||||
|
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||||
|
REVISIT_STATE_FILTER_OPTIONS,
|
||||||
|
revisitRowClassName,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -798,6 +837,12 @@ const formData = reactive({
|
|||||||
latest_assign_end_date: '' as string,
|
latest_assign_end_date: '' as string,
|
||||||
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||||
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||||
|
/** 复诊状态筛选:due(到期未挂号∪过号)|warning|booked|visited|contacted|no_days|pending,空=不限 */
|
||||||
|
revisit_state: '' as string,
|
||||||
|
/** 开药天数档筛选:14|21|28|30|other,空=不限 */
|
||||||
|
revisit_days_bucket: '' as string,
|
||||||
|
/** 复诊节点(药尽日)排序:asc=最早到期在前(NULL 置后) desc 反之;与 sort_unserved_days 互斥 */
|
||||||
|
sort_revisit_due: '' as '' | 'asc' | 'desc',
|
||||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||||
appointment_date: '' as string,
|
appointment_date: '' as string,
|
||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
@@ -898,11 +943,13 @@ function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
|||||||
latest_appointment_end_date: formData.latest_appointment_end_date,
|
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||||
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||||
latest_assign_start_date: formData.latest_assign_start_date,
|
latest_assign_start_date: formData.latest_assign_start_date,
|
||||||
latest_assign_end_date: formData.latest_assign_end_date
|
latest_assign_end_date: formData.latest_assign_end_date,
|
||||||
|
revisit_state: formData.revisit_state,
|
||||||
|
revisit_days_bucket: formData.revisit_days_bucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
|
||||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
|
|||||||
pending_assign_order_month: '',
|
pending_assign_order_month: '',
|
||||||
pending_assign_keyword: '',
|
pending_assign_keyword: '',
|
||||||
sort_unserved_days: '',
|
sort_unserved_days: '',
|
||||||
|
revisit_state: '',
|
||||||
|
sort_revisit_due: '',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
|||||||
formData.latest_assign_start_date = ''
|
formData.latest_assign_start_date = ''
|
||||||
formData.latest_assign_end_date = ''
|
formData.latest_assign_end_date = ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.revisit_days_bucket = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
formData.pending_assign_order_month = ''
|
formData.pending_assign_order_month = ''
|
||||||
activeTab.value = 'all'
|
activeTab.value = 'all'
|
||||||
if (kw1 !== '') {
|
if (kw1 !== '') {
|
||||||
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
if (!formData.pending_assign_order_month) {
|
if (!formData.pending_assign_order_month) {
|
||||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||||
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
|||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
|
/** 复诊状态 / 开药天数档 下拉选项(文案与请求值映射在 utils/revisitDue.ts) */
|
||||||
|
const revisitStateFilterOptions = REVISIT_STATE_FILTER_OPTIONS
|
||||||
|
const revisitDaysBucketOptions = REVISIT_DAYS_BUCKET_OPTIONS
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」角标:revisit_state=due 的 count */
|
||||||
|
const revisitDueCount = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 切换其它顶部 Tab 时清掉复诊 chip 状态与其排序(revisit_days_bucket 属「更多筛选」,保留) */
|
||||||
|
const clearRevisitTabFilters = () => {
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊,默认按药尽日升序(超期最多在前) */
|
||||||
|
const handleRevisitDueTabClick = async () => {
|
||||||
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.has_appointment = ''
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
|
formData.revisit_state = 'due'
|
||||||
|
formData.sort_revisit_due = 'asc'
|
||||||
|
formData.sort_unserved_days = ''
|
||||||
|
pager.page = 1
|
||||||
|
await getLists()
|
||||||
|
fetchDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多筛选 → 复诊状态」下拉:与顶部日期 Tab 互斥(同 chip 语义),选中后不再默认当天挂号 */
|
||||||
|
const handleRevisitFilterChange = () => {
|
||||||
|
if (formData.revisit_state !== '') {
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
const noSortYet = formData.sort_revisit_due === '' && formData.sort_unserved_days === ''
|
||||||
|
if (formData.revisit_state === 'due' && noSortYet) {
|
||||||
|
formData.sort_revisit_due = 'asc'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
|
}
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,单元格显示「—」) */
|
||||||
|
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||||
|
|
||||||
|
/** 「复诊节点」行 1 的开方文案:已开方 / 已开方(处方已作废) / 未开方 */
|
||||||
|
const prescribedTextOf = (row: any): string => {
|
||||||
|
if (!row?.has_prescription) return '未开方'
|
||||||
|
return row.followup_rx_voided ? '已开方(处方已作废)' : '已开方'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量 + 复诊到期数量
|
||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||||
tcmDiagnosisLists(
|
tcmDiagnosisLists(
|
||||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||||
),
|
),
|
||||||
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists(
|
tcmDiagnosisLists(
|
||||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||||
),
|
),
|
||||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
|
||||||
|
tcmDiagnosisLists(
|
||||||
|
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
|
||||||
|
)
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||||
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
|
|||||||
pendingBookingCount.value = noApt?.count ?? 0
|
pendingBookingCount.value = noApt?.count ?? 0
|
||||||
completedVisitCount.value = doneVisit?.count ?? 0
|
completedVisitCount.value = doneVisit?.count ?? 0
|
||||||
pendingAssignCount.value = pending?.count ?? 0
|
pendingAssignCount.value = pending?.count ?? 0
|
||||||
|
revisitDueCount.value = revisitDue?.count ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日期数量失败', e)
|
console.error('获取日期数量失败', e)
|
||||||
}
|
}
|
||||||
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
|
|||||||
prop: string
|
prop: string
|
||||||
order: 'ascending' | 'descending' | null
|
order: 'ascending' | 'descending' | null
|
||||||
}) => {
|
}) => {
|
||||||
if (prop === 'unserved_days') {
|
const dir: '' | 'asc' | 'desc' =
|
||||||
formData.sort_unserved_days =
|
|
||||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||||
} else {
|
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
|
||||||
}
|
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
}
|
}
|
||||||
@@ -1311,6 +1426,9 @@ const handleReset = () => {
|
|||||||
formData.latest_assign_start_date = ''
|
formData.latest_assign_start_date = ''
|
||||||
formData.latest_assign_end_date = ''
|
formData.latest_assign_end_date = ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.revisit_days_bucket = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
formData.diagnosis_confirmed = ''
|
formData.diagnosis_confirmed = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
|
|||||||
return 'unserved-green'
|
return 'unserved-green'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 行高亮:未确认诊单优先,其次未挂号
|
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
|
||||||
const getRowClassName = ({ row }: { row: any }) => {
|
const getRowClassName = ({ row }: { row: any }) => {
|
||||||
|
const revisitCls = revisitRowClassName(revisitDueOf(row))
|
||||||
|
if (revisitCls) return revisitCls
|
||||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||||
if (!row.has_appointment) return 'row-no-appointment'
|
if (!row.has_appointment) return 'row-no-appointment'
|
||||||
return ''
|
return ''
|
||||||
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
|
|||||||
const appointmentRef = ref()
|
const appointmentRef = ref()
|
||||||
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
||||||
const pendingRouteDiagnosisId = ref<number | null>(null)
|
const pendingRouteDiagnosisId = ref<number | null>(null)
|
||||||
|
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
|
||||||
|
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1370,6 +1492,53 @@ watch(
|
|||||||
{ flush: 'post' }
|
{ flush: 'post' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[appointmentRef, pendingRouteAppointmentRow],
|
||||||
|
() => {
|
||||||
|
const row = pendingRouteAppointmentRow.value
|
||||||
|
const opener = appointmentRef.value as { open?: (row: any) => void } | undefined
|
||||||
|
if (!row || !opener?.open) return
|
||||||
|
pendingRouteAppointmentRow.value = null
|
||||||
|
handleAppointment(row)
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深链打开挂号弹窗:优先用当前列表里的行(字段最全);不在本页时回退只读详情接口拼最小行
|
||||||
|
* (appointment.vue open() 只读 patient_id / patient_name / gender_desc / age)。
|
||||||
|
*/
|
||||||
|
const openAppointmentFromRoute = async (diagnosisId: number) => {
|
||||||
|
// 深链打开挂号弹窗前先校验挂号权限(与行内「预约」按钮 v-perms 同口径)
|
||||||
|
if (!hasPermission(['tcm.diagnosis/guahao'])) {
|
||||||
|
feedback.msgWarning('无挂号权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inList = (pager.lists as any[]).find((r) => Number(r?.id) === diagnosisId)
|
||||||
|
if (inList) {
|
||||||
|
pendingRouteAppointmentRow.value = inList
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res: any = await diagnosisReadonlyDetail({ id: diagnosisId })
|
||||||
|
const diag = res?.diagnosis
|
||||||
|
if (!diag || !diag.patient_id) {
|
||||||
|
feedback.msgWarning('未找到该诊单或患者信息不完整,无法打开挂号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingRouteAppointmentRow.value = {
|
||||||
|
...diag,
|
||||||
|
id: diagnosisId,
|
||||||
|
patient_id: diag.patient_id,
|
||||||
|
patient_name: diag.patient_name || '',
|
||||||
|
gender_desc: formatGender(diag.gender),
|
||||||
|
age: diag.age ?? 0
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgWarning(e?.msg || e?.message || '加载诊单失败,无法打开挂号')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const watchCallVisible = ref(false)
|
const watchCallVisible = ref(false)
|
||||||
const watchCallDiagnosisId = ref(0)
|
const watchCallDiagnosisId = ref(0)
|
||||||
|
|
||||||
@@ -2220,20 +2389,55 @@ const goReadonly = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 默认展示当天挂号,突出重点
|
|
||||||
formData.appointment_date = todayStr.value
|
|
||||||
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
||||||
getDictOptions()
|
getDictOptions()
|
||||||
|
if (String(route.query.revisit_state ?? '') === 'due') {
|
||||||
|
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
|
||||||
|
void handleRevisitDueTabClick()
|
||||||
|
} else {
|
||||||
|
// 默认展示当天挂号,突出重点
|
||||||
|
formData.appointment_date = todayStr.value
|
||||||
getLists()
|
getLists()
|
||||||
scheduleDeferredDateCounts()
|
scheduleDeferredDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
||||||
|
|
||||||
const raw = route.query.id
|
const raw = route.query.id
|
||||||
if (raw != null && raw !== '') {
|
if (raw != null && raw !== '') {
|
||||||
const num = Number(raw)
|
const num = Number(raw)
|
||||||
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
|
if (!Number.isNaN(num) && num > 0) {
|
||||||
|
if (String(route.query.action ?? '') === 'appointment') {
|
||||||
|
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
|
||||||
|
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
|
||||||
|
} else {
|
||||||
|
pendingRouteDiagnosisId.value = num
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** onMounted 里 getLists() 已发起;等待首屏列表 loading 结束(最多 8s)再找行,失败/超时不阻断 */
|
||||||
|
const getListsPromiseForRoute = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (!pager.loading) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let settled = false
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
stop()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const stop = watch(
|
||||||
|
() => pager.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (!loading) finish()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setTimeout(finish, 8000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -2313,6 +2517,21 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip-revisit-due {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-color-danger-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chip-completed-visit {
|
.chip-completed-visit {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--el-color-success);
|
color: var(--el-color-success);
|
||||||
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
|
|||||||
.latest-appointment-channel {
|
.latest-appointment-channel {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.revisit-state-select {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-days-bucket-select {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card {
|
.list-card {
|
||||||
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-prescribed {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #4A5DFF;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-unprescribed {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||||
.unserved-days {
|
.unserved-days {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -2696,33 +2912,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.followup-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
line-height: 1.35;
|
|
||||||
|
|
||||||
.followup-time {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-doctor {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-void-tag {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-none {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-cell {
|
.assistant-cell {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
|
|||||||
:deep(.el-table__row.row-no-appointment:hover > td) {
|
:deep(.el-table__row.row-no-appointment:hover > td) {
|
||||||
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
||||||
}
|
}
|
||||||
|
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 row-unconfirmed 结构,warning → danger) */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue) {
|
||||||
|
border-left: 3px solid var(--el-color-danger);
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
/* 超期 ≥ severe_overdue_days:底色加深到 10%,悬停 14% */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-list {
|
.record-list {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
formData.revisit_state === '' &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
@@ -40,6 +41,13 @@
|
|||||||
待预约
|
待预约
|
||||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
:class="['dh5-chip', 'dh5-chip--revisit', { active: formData.revisit_state === 'due' }]"
|
||||||
|
@click="handleRevisitDueTabClick"
|
||||||
|
>
|
||||||
|
复诊到期
|
||||||
|
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
||||||
@click="handleCompletedVisitTabClick"
|
@click="handleCompletedVisitTabClick"
|
||||||
@@ -170,13 +178,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 复诊块(仅已开方时显示) -->
|
<!-- 复诊节点块(已开方或有复诊节点数据时显示;卡片头已有「已开方」标签,此处隐藏开方文案) -->
|
||||||
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
|
<div v-if="row.has_prescription || hasRevisitDue(revisitDueOf(row))" class="dh5-row dh5-row--followup">
|
||||||
<span class="dh5-row-key">复诊</span>
|
<span class="dh5-row-key">复诊</span>
|
||||||
<div class="dh5-row-val">
|
<div class="dh5-row-val dh5-row-val--revisit">
|
||||||
<span>{{ row.followup_time_text || '—' }}</span>
|
<revisit-node-cell
|
||||||
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
|
compact
|
||||||
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
|
:due="revisitDueOf(row)"
|
||||||
|
:has-prescription="!!row.has_prescription"
|
||||||
|
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||||
|
:prescribed-text="row.followup_rx_voided ? '处方作废' : ''"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -625,6 +637,13 @@ import {
|
|||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||||
|
import {
|
||||||
|
hasRevisitDue,
|
||||||
|
isRevisitDueHighlight,
|
||||||
|
normalizeRevisitDue,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -685,7 +704,9 @@ const formData = reactive({
|
|||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
pending_assign: '' as '' | '1',
|
pending_assign: '' as '' | '1',
|
||||||
pending_booking: '' as '' | '1',
|
pending_booking: '' as '' | '1',
|
||||||
completed_appointment: '' as '' | '1'
|
completed_appointment: '' as '' | '1',
|
||||||
|
/** 顶部「复诊到期」chip:due = 到期未挂号 ∪ 过号未面诊(后端 revisit_state) */
|
||||||
|
revisit_state: '' as '' | 'due'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showMoreFilter = ref(false)
|
const showMoreFilter = ref(false)
|
||||||
@@ -740,6 +761,7 @@ const handleReset = () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -767,6 +789,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -778,6 +801,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = '1'
|
formData.pending_booking = '1'
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -789,6 +813,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -800,6 +825,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.pending_assign = '1'
|
formData.pending_assign = '1'
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -807,9 +833,27 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊;后端按 revisit_state=due 过滤,排序默认药尽日升序 */
|
||||||
|
const handleRevisitDueTabClick = async () => {
|
||||||
|
formData.pending_assign = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
formData.has_appointment = ''
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.revisit_state = 'due'
|
||||||
|
pager.page = 1
|
||||||
|
await getLists()
|
||||||
|
fetchDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisitDueCount = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,不渲染复诊块) */
|
||||||
|
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||||
|
|
||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||||
@@ -818,7 +862,8 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ has_appointment: 0, 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({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 }),
|
||||||
|
tcmDiagnosisLists({ revisit_state: 'due', page_no: 1, page_size: 1 })
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||||
@@ -831,6 +876,7 @@ const fetchDateCounts = async () => {
|
|||||||
pendingBookingCount.value = noApt?.count ?? 0
|
pendingBookingCount.value = noApt?.count ?? 0
|
||||||
completedVisitCount.value = doneVisit?.count ?? 0
|
completedVisitCount.value = doneVisit?.count ?? 0
|
||||||
pendingAssignCount.value = pending?.count ?? 0
|
pendingAssignCount.value = pending?.count ?? 0
|
||||||
|
revisitDueCount.value = revisitDue?.count ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日期数量失败', e)
|
console.error('获取日期数量失败', e)
|
||||||
}
|
}
|
||||||
@@ -898,7 +944,9 @@ const isDiagnosisConfirmed = (row: any) => {
|
|||||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卡片左边条优先级(同 PC 行高亮):复诊超期/过号 > 未确认 > 未挂号
|
||||||
const getCardClass = (row: any) => {
|
const getCardClass = (row: any) => {
|
||||||
|
if (isRevisitDueHighlight(revisitDueOf(row))) return 'dh5-card--overdue'
|
||||||
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
||||||
if (!row.has_appointment) return 'dh5-card--noapt'
|
if (!row.has_appointment) return 'dh5-card--noapt'
|
||||||
return ''
|
return ''
|
||||||
@@ -1582,6 +1630,8 @@ $dh5-card-bg: #ffffff;
|
|||||||
.dh5-chip--booking.active { background: $dh5-warn; }
|
.dh5-chip--booking.active { background: $dh5-warn; }
|
||||||
.dh5-chip--done.active { background: $dh5-success; }
|
.dh5-chip--done.active { background: $dh5-success; }
|
||||||
.dh5-chip--pending.active { background: $dh5-danger; }
|
.dh5-chip--pending.active { background: $dh5-danger; }
|
||||||
|
.dh5-chip--revisit { color: #b91c1c; background: rgba(239, 68, 68, 0.10); }
|
||||||
|
.dh5-chip--revisit.active { color: #fff; background: $dh5-danger; }
|
||||||
|
|
||||||
.dh5-quick {
|
.dh5-quick {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1675,6 +1725,12 @@ $dh5-card-bg: #ffffff;
|
|||||||
|
|
||||||
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
||||||
.dh5-card--noapt::before { background: $dh5-warn; }
|
.dh5-card--noapt::before { background: $dh5-warn; }
|
||||||
|
/* 复诊到期未挂号 / 过号未面诊:左边条同 danger,外框与底色轻微染红以区分「未确认」 */
|
||||||
|
.dh5-card--overdue::before { background: $dh5-danger; }
|
||||||
|
.dh5-card--overdue {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, $dh5-card-bg 60%);
|
||||||
|
}
|
||||||
|
|
||||||
.dh5-card-head {
|
.dh5-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1800,6 +1856,11 @@ $dh5-card-bg: #ffffff;
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
|
||||||
|
.dh5-row-val--revisit {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* 挂号块 - 状态色 */
|
/* 挂号块 - 状态色 */
|
||||||
.dh5-row--apt {
|
.dh5-row--apt {
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
|
|||||||
@@ -25,6 +25,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
||||||
|
<!-- 复诊节点徽标:第 N 诊 · 药尽 MM-DD · 超期 N 天(接口无 revisit_due 字段时不渲染) -->
|
||||||
|
<el-tooltip
|
||||||
|
v-if="revisitBadgeLabel"
|
||||||
|
placement="bottom"
|
||||||
|
:disabled="!revisitTooltipText"
|
||||||
|
:content="revisitTooltipText"
|
||||||
|
>
|
||||||
|
<span :class="['unserved-badge', 'revisit-badge', revisitBadgeColorClass]">
|
||||||
|
{{ revisitBadgeLabel }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,6 +167,12 @@ import { diagnosisReadonlyDetail } from '@/api/tcm'
|
|||||||
import { getDoctorNotes } from '@/api/patient'
|
import { getDoctorNotes } from '@/api/patient'
|
||||||
import { formatGender } from '@/utils/diag-display'
|
import { formatGender } from '@/utils/diag-display'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
import {
|
||||||
|
normalizeRevisitDue,
|
||||||
|
revisitBadgeText,
|
||||||
|
revisitStateColorClass,
|
||||||
|
revisitTooltipLines
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
import PatientInfoCard from './components/PatientInfoCard.vue'
|
import PatientInfoCard from './components/PatientInfoCard.vue'
|
||||||
import PatientCaseCard from './components/PatientCaseCard.vue'
|
import PatientCaseCard from './components/PatientCaseCard.vue'
|
||||||
import DailyMatrix from './components/DailyMatrix.vue'
|
import DailyMatrix from './components/DailyMatrix.vue'
|
||||||
@@ -193,6 +210,17 @@ const unservedDays = computed<number | null>(() => {
|
|||||||
})
|
})
|
||||||
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
||||||
|
|
||||||
|
/** 复诊节点:readonlyDetail 顶层 revisit_due(兼容挂在 diagnosis 下);字段不存在 → null → 不渲染徽标 */
|
||||||
|
const revisitDue = computed(() =>
|
||||||
|
normalizeRevisitDue(detail.value?.revisit_due ?? detail.value?.diagnosis?.revisit_due)
|
||||||
|
)
|
||||||
|
const revisitBadgeLabel = computed(() => revisitBadgeText(revisitDue.value))
|
||||||
|
const revisitBadgeColorClass = computed(() => {
|
||||||
|
const due = revisitDue.value
|
||||||
|
return due ? revisitStateColorClass(due.state) : 'unserved-muted'
|
||||||
|
})
|
||||||
|
const revisitTooltipText = computed(() => revisitTooltipLines(revisitDue.value).join(';'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「未服务天数」着色规则(与 T3 列表一致):
|
* 「未服务天数」着色规则(与 T3 列表一致):
|
||||||
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||||
@@ -352,6 +380,13 @@ onMounted(fetchDetail)
|
|||||||
border-color: #fecaca;
|
border-color: #fecaca;
|
||||||
background: rgba(220, 38, 38, 0.08);
|
background: rgba(220, 38, 38, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复诊节点徽标专用:已约 / 已联系 */
|
||||||
|
&.unserved-blue {
|
||||||
|
color: #4a5dff;
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
background: rgba(74, 93, 255, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
const dir = path.resolve(__dirname, '../src/views/tcm/diagnosis/components')
|
||||||
|
const reviewFile = process.env.FOLLOWUP_INLINE_REVIEW_SOURCE || path.join(dir, 'FollowupAudioReview.vue')
|
||||||
|
function execute(source, requireFn) {
|
||||||
|
const out = ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
|
||||||
|
const module={exports:{}};new Function('require','module','exports',out)(requireFn,module,module.exports);return module.exports
|
||||||
|
}
|
||||||
|
const state=execute(fs.readFileSync(path.join(dir,'followupAudioState.ts'),'utf8'),require)
|
||||||
|
const row=(id,extra={})=>({id,kind:'blood',values:{fasting_blood_sugar:6.2},record_date:'2026-10-12',record_time:'08:00',time_period:'早晨',time_estimated:true,time_text:'早晨',date_text:'今天',evidence:[{text:'六点二',channel:1,segment_id:'right-1',start_ms:0,end_ms:15000,position_type:'segment'}],needs_review:true,selected:false,target_id:null,current_values:{},expected_hash:'original',conflict:false,possible_duplicate:false,...extra})
|
||||||
|
function setup(extra={}){
|
||||||
|
const descriptor=parse(fs.readFileSync(reviewFile,'utf8'),{filename:reviewFile}).descriptor
|
||||||
|
const compiled=compileScript(descriptor,{id:'inline-review'})
|
||||||
|
const mod=execute(compiled.content,name=>name==='vue'?vue:name==='./followupAudioState'?state:{default:{}})
|
||||||
|
const props=vue.reactive({items:[row('one'),row('two'),row('three')],fields:{blood:[{key:'fasting_blood_sugar',label:'空腹血糖',type:'number'}]},disabled:false,canDaily:true,applied:false,saveState:'saved',...extra})
|
||||||
|
const events=[];const scope=vue.effectScope();const review=scope.run(()=>mod.default.setup(props,{expose(){},emit(event,...args){events.push([event,...args]);if(event==='update:items')props.items=args[0]}}))
|
||||||
|
return {review,props,events,dispose:()=>scope.stop()}
|
||||||
|
}
|
||||||
|
test('inline editor has one permanently mounted player slot and no modal overlay',()=>{
|
||||||
|
const source=fs.readFileSync(reviewFile,'utf8');assert.doesNotMatch(source,/<el-dialog/);assert.equal((source.match(/<slot name="player"/g)||[]).length,1)
|
||||||
|
assert.match(source,/position: sticky/);assert.match(source,/aria-label="同页核对与回听"/)
|
||||||
|
const descriptor=parse(source,{filename:reviewFile}).descriptor;const script=compileScript(descriptor,{id:'inline-compile'})
|
||||||
|
const render=compileTemplate({source:descriptor.template.content,filename:reviewFile,id:'inline-compile',compilerOptions:{bindingMetadata:script.bindings}});assert.deepEqual(render.errors,[])
|
||||||
|
})
|
||||||
|
test('accept-next requires explicit verification, retains immutable evidence and opens next unselected item',async()=>{
|
||||||
|
const f=setup({items:[row('one'),row('two',{selected:true,needs_review:false}),row('three')]});f.review.openReview('one')
|
||||||
|
f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||||
|
f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||||
|
assert.equal(f.props.items[0].selected,true);assert.equal(f.props.items[0].needs_review,false);assert.equal(f.review.editor.value.id,'three')
|
||||||
|
assert.deepEqual(f.props.items[0].evidence,row('one').evidence);assert.ok(f.events.some(e=>e[0]==='saved-next'))
|
||||||
|
await vue.nextTick();assert.equal(f.review.editor.value.id,'three');f.dispose()
|
||||||
|
})
|
||||||
|
test('pending-filter accept-next captures next id before accepted row leaves filter',()=>{
|
||||||
|
const f=setup();f.review.group.value='pending';f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||||
|
assert.equal(f.review.editor.value.id,'two');assert.deepEqual(f.review.visibleItems.value.map(r=>r.id),['two','three']);f.dispose()
|
||||||
|
})
|
||||||
|
test('skip preserves patient values, does not claim review complete, and never writes or selects',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.skipAndNext();assert.equal(f.review.editor.value.id,'two');assert.equal(f.props.items[0].needs_review,true);assert.equal(f.props.items[0].selected,false)
|
||||||
|
assert.deepEqual(f.props.items[0].values,{fasting_blood_sugar:6.2});assert.ok(f.events.every(e=>['update:items','saved-next'].includes(e[0])));f.dispose()
|
||||||
|
})
|
||||||
|
test('inline row switching or skipping never silently drops unsaved edits',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',6.8);f.review.openReview('two');assert.equal(f.review.editor.value.id,'one');assert.match(f.review.editorNotice.value,/未保存/)
|
||||||
|
f.review.skipAndNext();assert.equal(f.review.editor.value.id,'one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,6.8);assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);f.dispose()
|
||||||
|
})
|
||||||
|
test('date change remains gated until saved and authoritative target refreshed',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.patchEditor({record_date:'2026-10-11'});f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||||
|
f.review.saveReview(false);assert.equal(f.props.items[0].record_date,'2026-10-11');assert.equal(f.props.items[0].needs_review,true);assert.ok(f.events.some(e=>e[0]==='saved-next'));f.dispose()
|
||||||
|
})
|
||||||
|
test('last item completes without wrapping or applying, read-only cannot advance mutations',()=>{
|
||||||
|
const f=setup({items:[row('one')]});f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.review.editor.value,null);assert.match(f.review.navigationNotice.value,/末项/);assert.equal(f.events.some(e=>e[0]==='apply'),false);f.dispose()
|
||||||
|
const g=setup({disabled:true});g.review.openReview('one');g.review.skipAndNext();g.review.saveReview(true,true);assert.equal(g.events.length,0);g.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reopening the same row or toggling its selection preserves unsaved editor data',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',7.1)
|
||||||
|
f.review.openReview('one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||||
|
f.review.toggleSelection(f.props.items[0],true);assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||||
|
assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);assert.equal(f.props.items[0].selected,false);f.dispose()
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Windows 直接在项目根目录双击:
|
Windows 直接在项目根目录双击:
|
||||||
|
|
||||||
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
|
- `一键运行_医生工作站.bat`:在源码目录且 `.venv` 可用时运行最新源码;否则运行现有成品;没有成品时使用 `uv` 准备源码环境并运行。
|
||||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
||||||
|
|
||||||
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorksta
|
|||||||
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
|
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
|
||||||
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
|
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
|
||||||
$SourceRoot = Join-Path $ProjectRoot "src"
|
$SourceRoot = Join-Path $ProjectRoot "src"
|
||||||
|
$SourceEntry = Join-Path $SourceRoot "doctor_workstation\__main__.py"
|
||||||
|
|
||||||
function Test-ProjectPython {
|
function Test-ProjectPython {
|
||||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||||
@@ -33,7 +34,13 @@ function Find-Uv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
$Python = $null
|
||||||
|
if ((Test-Path -LiteralPath $SourceEntry -PathType Leaf) -and
|
||||||
|
(Test-ProjectPython -Candidate $ProjectPython)) {
|
||||||
|
$Python = $ProjectPython
|
||||||
|
Write-Host "Using current source tree: $SourceRoot"
|
||||||
|
}
|
||||||
|
elseif (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
||||||
Write-Host "Using packaged application: $FrozenExecutable"
|
Write-Host "Using packaged application: $FrozenExecutable"
|
||||||
if ($ValidateOnly) {
|
if ($ValidateOnly) {
|
||||||
Write-Host "Windows run entry validation passed."
|
Write-Host "Windows run entry validation passed."
|
||||||
@@ -47,13 +54,7 @@ try {
|
|||||||
}
|
}
|
||||||
exit $ApplicationExitCode
|
exit $ApplicationExitCode
|
||||||
}
|
}
|
||||||
|
elseif (Test-Path -LiteralPath $SourceEntry -PathType Leaf) {
|
||||||
$Python = $null
|
|
||||||
if (Test-ProjectPython -Candidate $ProjectPython) {
|
|
||||||
$Python = $ProjectPython
|
|
||||||
Write-Host "Using existing project environment: $ProjectPython"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$Uv = Find-Uv
|
$Uv = Find-Uv
|
||||||
if (-not $Uv) {
|
if (-not $Uv) {
|
||||||
throw "No packaged application or usable .venv was found, and uv is not installed."
|
throw "No packaged application or usable .venv was found, and uv is not installed."
|
||||||
@@ -108,6 +109,9 @@ try {
|
|||||||
}
|
}
|
||||||
$Python = $ProjectPython
|
$Python = $ProjectPython
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
throw "No source entry or packaged application was found."
|
||||||
|
}
|
||||||
|
|
||||||
if ($ValidateOnly) {
|
if ($ValidateOnly) {
|
||||||
Write-Host "Windows run entry validation passed."
|
Write-Host "Windows run entry validation passed."
|
||||||
|
|||||||
@@ -1004,6 +1004,8 @@ class ApplicationController(QObject):
|
|||||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||||
margin = 18
|
margin = 18
|
||||||
|
|
||||||
|
if callable(getattr(video_window, "setProperty", None)):
|
||||||
|
video_window.setProperty("doctorSkipOpenGeometry", True)
|
||||||
video_window.showNormal()
|
video_window.showNormal()
|
||||||
video_window.setMinimumSize(440, 300)
|
video_window.setMinimumSize(440, 300)
|
||||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||||
@@ -1058,6 +1060,8 @@ class ApplicationController(QObject):
|
|||||||
window.raise_()
|
window.raise_()
|
||||||
if activate:
|
if activate:
|
||||||
window.activateWindow()
|
window.activateWindow()
|
||||||
|
if callable(getattr(window, "setProperty", None)):
|
||||||
|
window.setProperty("doctorSkipOpenGeometry", False)
|
||||||
except (AttributeError, RuntimeError):
|
except (AttributeError, RuntimeError):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
|||||||
"tcm.diagnosis/editAiReport",
|
"tcm.diagnosis/editAiReport",
|
||||||
"tcm.diagnosis/aiAnalysis",
|
"tcm.diagnosis/aiAnalysis",
|
||||||
"tcm.diagnosis/aiAssistant",
|
"tcm.diagnosis/aiAssistant",
|
||||||
|
"tcm.diagnosis/aiGeneratePrescription",
|
||||||
"tcm.diagnosis/patientAiReports",
|
"tcm.diagnosis/patientAiReports",
|
||||||
"tcm.diagnosis/generatePatientAiReport",
|
"tcm.diagnosis/generatePatientAiReport",
|
||||||
"tcm.diagnosis/dailyRecord",
|
"tcm.diagnosis/dailyRecord",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from doctor_workstation.core.errors import (
|
|||||||
ApiBusinessError,
|
ApiBusinessError,
|
||||||
ApiHttpError,
|
ApiHttpError,
|
||||||
ApiProtocolError,
|
ApiProtocolError,
|
||||||
ApiTimeoutError,
|
|
||||||
ApiTransportError,
|
ApiTransportError,
|
||||||
AuthenticationExpiredError,
|
AuthenticationExpiredError,
|
||||||
)
|
)
|
||||||
@@ -1413,11 +1412,11 @@ class RemoteDoctorRepository:
|
|||||||
task: str = "custom",
|
task: str = "custom",
|
||||||
cancelled: Callable[[], bool] | None = None,
|
cancelled: Callable[[], bool] | None = None,
|
||||||
) -> Iterator[dict[str, Any]]:
|
) -> Iterator[dict[str, Any]]:
|
||||||
"""Stream a diagnosis answer, with one legacy fallback before first content."""
|
"""Stream a diagnosis answer; use the legacy route only when streaming is absent."""
|
||||||
|
|
||||||
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
||||||
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
|
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
|
||||||
received_delta = False
|
received_event = False
|
||||||
received_done = False
|
received_done = False
|
||||||
try:
|
try:
|
||||||
for raw_event in self.client.post_event_stream(
|
for raw_event in self.client.post_event_stream(
|
||||||
@@ -1428,13 +1427,12 @@ class RemoteDoctorRepository:
|
|||||||
):
|
):
|
||||||
if cancelled is not None and cancelled():
|
if cancelled is not None and cancelled():
|
||||||
return
|
return
|
||||||
|
received_event = True
|
||||||
event = _normalise_diagnosis_ai_event(raw_event)
|
event = _normalise_diagnosis_ai_event(raw_event)
|
||||||
if event is None:
|
if event is None:
|
||||||
continue
|
continue
|
||||||
kind = event["event"]
|
kind = event["event"]
|
||||||
if kind == "delta":
|
if kind == "done":
|
||||||
received_delta = True
|
|
||||||
elif kind == "done":
|
|
||||||
received_done = True
|
received_done = True
|
||||||
yield event
|
yield event
|
||||||
if kind == "done":
|
if kind == "done":
|
||||||
@@ -1443,12 +1441,16 @@ class RemoteDoctorRepository:
|
|||||||
return
|
return
|
||||||
if not received_done:
|
if not received_done:
|
||||||
raise ApiProtocolError("AI assistant stream ended before done")
|
raise ApiProtocolError("AI assistant stream ended before done")
|
||||||
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
|
except ApiHttpError as error:
|
||||||
if received_delta or (cancelled is not None and cancelled()):
|
if (
|
||||||
|
error.status_code not in {404, 405}
|
||||||
|
or received_event
|
||||||
|
or (cancelled is not None and cancelled())
|
||||||
|
):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Older deployments do not expose the stream route. Submit exactly one
|
# Only a missing stream route on an older deployment justifies another
|
||||||
# request through the confirmed non-streaming endpoint in that case.
|
# full request. Timeouts, transport failures and malformed streams do not.
|
||||||
result = self.analyze_diagnosis_ai(
|
result = self.analyze_diagnosis_ai(
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
clean_prompt,
|
clean_prompt,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..core.appointment_modes import APPOINTMENT_MODES
|
from ..core.appointment_modes import APPOINTMENT_MODES
|
||||||
|
from .theme import main_window_target_rect
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -1766,6 +1767,11 @@ class AppointmentDrawer(QDialog):
|
|||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
def _sync_to_owner(self) -> None:
|
def _sync_to_owner(self) -> None:
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
self.setGeometry(target)
|
||||||
|
self._update_drawer_width()
|
||||||
|
return
|
||||||
if self._owner is None:
|
if self._owner is None:
|
||||||
self._update_drawer_width()
|
self._update_drawer_width()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from ..diagnosis_terms import (
|
|||||||
TermIndex,
|
TermIndex,
|
||||||
shared_terms,
|
shared_terms,
|
||||||
)
|
)
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..theme import apply_reading_rhythm, mark_business_dialog
|
from ..theme import apply_reading_rhythm, mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
@@ -176,8 +177,8 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs {
|
|||||||
}
|
}
|
||||||
QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab {
|
QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 16px;
|
padding: 0 7px;
|
||||||
margin-right: 4px;
|
margin-right: 0;
|
||||||
color: #606163;
|
color: #606163;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -189,6 +190,18 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab:selected {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 2px solid #4F63D9;
|
border-bottom: 2px solid #4F63D9;
|
||||||
}
|
}
|
||||||
|
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #4F63D9;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E1E5F4;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter:hover {
|
||||||
|
background-color: #EEF1FA;
|
||||||
|
}
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultChatPane,
|
QDialog#AiConsultDialog QFrame#AiConsultChatPane,
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultSide,
|
QDialog#AiConsultDialog QFrame#AiConsultSide,
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultRecordPane {
|
QDialog#AiConsultDialog QFrame#AiConsultRecordPane {
|
||||||
@@ -483,6 +496,22 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalDisclaimer {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
QDialog#AiConsultDialog QFrame#AiConsultReportProvenance {
|
||||||
|
background-color: #F7F8FC;
|
||||||
|
border: 1px solid #E4E8F2;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceTitle {
|
||||||
|
color: #27336B;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceLine {
|
||||||
|
color: #555B68;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultEventCard {
|
QDialog#AiConsultDialog QFrame#AiConsultEventCard {
|
||||||
background-color: #F7F7F7;
|
background-color: #F7F7F7;
|
||||||
border: 1px solid #EDEDEE;
|
border: 1px solid #EDEDEE;
|
||||||
@@ -637,6 +666,13 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
QDialog#AiConsultDialog QLabel#AiConsultTag[level="unknown"] {
|
||||||
|
color: #6A6B6D;
|
||||||
|
background-color: #F0F0F0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
QDialog#AiConsultDialog QPushButton#AiConsultChipButton {
|
QDialog#AiConsultDialog QPushButton#AiConsultChipButton {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
@@ -854,6 +890,11 @@ QUICK_PROMPTS: tuple[tuple[str, str], ...] = (
|
|||||||
("并发症风险评估", "请评估并发症风险并给出随访要点。"),
|
("并发症风险评估", "请评估并发症风险并给出随访要点。"),
|
||||||
("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"),
|
("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"),
|
||||||
)
|
)
|
||||||
|
AI_PRESCRIPTION_PERMISSION = "tcm.diagnosis/aiGeneratePrescription"
|
||||||
|
PRESCRIPTION_CREATE_PERMISSIONS = (
|
||||||
|
"tcm.diagnosis/chufang",
|
||||||
|
"tcm.diagnosis/kaifang",
|
||||||
|
)
|
||||||
TOOL_ITEMS: tuple[tuple[str, str, str], ...] = (
|
TOOL_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
|
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
|
||||||
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
|
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
|
||||||
@@ -2104,6 +2145,26 @@ def parse_structured_report(raw: Any) -> tuple[str, tuple[_ReportSection, ...],
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_provenance_widget(raw: Any, parent: QWidget) -> QFrame:
|
||||||
|
card = QFrame(parent)
|
||||||
|
card.setObjectName("AiConsultReportProvenance")
|
||||||
|
_style_surface(card)
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(12, 10, 12, 11)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
title = QLabel("报告出处", card)
|
||||||
|
title.setObjectName("AiConsultReportProvenanceTitle")
|
||||||
|
layout.addWidget(title)
|
||||||
|
for line in report_provenance_lines(raw):
|
||||||
|
label = QLabel(line, card)
|
||||||
|
label.setObjectName("AiConsultReportProvenanceLine")
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
layout.addWidget(label)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
|
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
|
||||||
|
|
||||||
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
|
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
|
||||||
@@ -2409,6 +2470,7 @@ class _StructuredReportPanel(QWidget):
|
|||||||
disclaimer: str,
|
disclaimer: str,
|
||||||
*,
|
*,
|
||||||
time_text: str = "",
|
time_text: str = "",
|
||||||
|
provenance: Any = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -2455,6 +2517,7 @@ class _StructuredReportPanel(QWidget):
|
|||||||
note = _FittedText(_reading_html(disclaimer), parent=self)
|
note = _FittedText(_reading_html(disclaimer), parent=self)
|
||||||
note.setObjectName("AiConsultReportDisclaimer")
|
note.setObjectName("AiConsultReportDisclaimer")
|
||||||
root.addWidget(note)
|
root.addWidget(note)
|
||||||
|
root.addWidget(_report_provenance_widget(provenance, self))
|
||||||
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
|
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
|
||||||
root.addStretch(1)
|
root.addStretch(1)
|
||||||
|
|
||||||
@@ -2528,6 +2591,7 @@ class _ClinicalAnalysisPanel(QWidget):
|
|||||||
model: _ClinicalAnalysisModel,
|
model: _ClinicalAnalysisModel,
|
||||||
*,
|
*,
|
||||||
time_text: str = "",
|
time_text: str = "",
|
||||||
|
provenance: Any = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -2621,6 +2685,7 @@ class _ClinicalAnalysisPanel(QWidget):
|
|||||||
disclaimer.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
disclaimer.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
_style_surface(disclaimer)
|
_style_surface(disclaimer)
|
||||||
root.addWidget(disclaimer)
|
root.addWidget(disclaimer)
|
||||||
|
root.addWidget(_report_provenance_widget(provenance, self))
|
||||||
QTimer.singleShot(0, self._apply_responsive_layout)
|
QTimer.singleShot(0, self._apply_responsive_layout)
|
||||||
|
|
||||||
def set_time_text(self, time_text: str) -> None:
|
def set_time_text(self, time_text: str) -> None:
|
||||||
@@ -2844,6 +2909,7 @@ class _ChatBubble(QWidget):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._role = role
|
self._role = role
|
||||||
self._raw_payload = text
|
self._raw_payload = text
|
||||||
|
self._provenance: Mapping[str, Any] = {}
|
||||||
self._clinical_panel: _ClinicalAnalysisPanel | None = None
|
self._clinical_panel: _ClinicalAnalysisPanel | None = None
|
||||||
row = QHBoxLayout(self)
|
row = QHBoxLayout(self)
|
||||||
row.setContentsMargins(12, 7, 12, 7)
|
row.setContentsMargins(12, 7, 12, 7)
|
||||||
@@ -2914,6 +2980,9 @@ class _ChatBubble(QWidget):
|
|||||||
if self.body is not None:
|
if self.body is not None:
|
||||||
self.body.set_payload(text)
|
self.body.set_payload(text)
|
||||||
|
|
||||||
|
def set_provenance(self, value: Any) -> None:
|
||||||
|
self._provenance = dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
def set_time_text(self, text: str) -> None:
|
def set_time_text(self, text: str) -> None:
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
@@ -2934,6 +3003,7 @@ class _ChatBubble(QWidget):
|
|||||||
panel = _ClinicalAnalysisPanel(
|
panel = _ClinicalAnalysisPanel(
|
||||||
model,
|
model,
|
||||||
time_text=self.stamp.text(),
|
time_text=self.stamp.text(),
|
||||||
|
provenance=self._provenance,
|
||||||
parent=self._bubble_frame,
|
parent=self._bubble_frame,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -2944,6 +3014,7 @@ class _ChatBubble(QWidget):
|
|||||||
panel = _StructuredReportPanel(
|
panel = _StructuredReportPanel(
|
||||||
*report,
|
*report,
|
||||||
time_text=self.stamp.text(),
|
time_text=self.stamp.text(),
|
||||||
|
provenance=self._provenance,
|
||||||
parent=self._bubble_frame,
|
parent=self._bubble_frame,
|
||||||
)
|
)
|
||||||
if panel is None:
|
if panel is None:
|
||||||
@@ -3075,6 +3146,8 @@ class _AiStreamWorker(QRunnable):
|
|||||||
@Slot()
|
@Slot()
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
if self.is_cancelled():
|
||||||
|
return
|
||||||
stream = getattr(self.repository, "stream_diagnosis_ai", None)
|
stream = getattr(self.repository, "stream_diagnosis_ai", None)
|
||||||
if callable(stream):
|
if callable(stream):
|
||||||
events = stream(
|
events = stream(
|
||||||
@@ -3098,6 +3171,8 @@ class _AiStreamWorker(QRunnable):
|
|||||||
{"event": "delta", "text": answer, "fallback": True},
|
{"event": "delta", "text": answer, "fallback": True},
|
||||||
{**payload, "event": "done", "fallback": True},
|
{**payload, "event": "done", "fallback": True},
|
||||||
)
|
)
|
||||||
|
if self.is_cancelled():
|
||||||
|
return
|
||||||
for event in events:
|
for event in events:
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
return
|
return
|
||||||
@@ -3136,13 +3211,18 @@ class _ClickCard(QFrame):
|
|||||||
AI_CONTEXT_MAX_CHARS = 320
|
AI_CONTEXT_MAX_CHARS = 320
|
||||||
AI_PROMPT_LIMIT = 500
|
AI_PROMPT_LIMIT = 500
|
||||||
|
|
||||||
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
|
#: 等待服务端整理诊单资料时显示的占位文案。
|
||||||
AI_STREAM_PENDING_TEXT = "正在生成…"
|
AI_STREAM_PENDING_TEXT = "正在整理诊单资料…"
|
||||||
|
|
||||||
|
#: 服务端确认资料已准备好、尚未收到回答时显示的占位文案。
|
||||||
|
AI_STREAM_WAITING_TEXT = "资料已整理,正在等待 AI 回复…"
|
||||||
|
|
||||||
#: 连接结束但一个字都没收到时的兜底文案。
|
#: 连接结束但一个字都没收到时的兜底文案。
|
||||||
AI_STREAM_SILENT_TEXT = (
|
AI_STREAM_SILENT_TEXT = (
|
||||||
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
|
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
|
||||||
)
|
)
|
||||||
|
AI_STREAM_TOTAL_TIMEOUT_MS = 180_000
|
||||||
|
AI_STREAM_TIMEOUT_TEXT = "等待 AI 回复超时,已停止本次等待,请稍后重试。"
|
||||||
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
|
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -3458,10 +3538,14 @@ class AiConsultDialog(QDialog):
|
|||||||
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
|
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
|
||||||
self._stream_completed = False
|
self._stream_completed = False
|
||||||
self._follow_chat = True
|
self._follow_chat = True
|
||||||
|
self._chat_filter = "all"
|
||||||
self._flush_timer = QTimer(self)
|
self._flush_timer = QTimer(self)
|
||||||
self._flush_timer.setSingleShot(True)
|
self._flush_timer.setSingleShot(True)
|
||||||
self._flush_timer.setInterval(40)
|
self._flush_timer.setInterval(40)
|
||||||
self._flush_timer.timeout.connect(self._flush_stream_chunks)
|
self._flush_timer.timeout.connect(self._flush_stream_chunks)
|
||||||
|
self._stream_timeout_timer = QTimer(self)
|
||||||
|
self._stream_timeout_timer.setSingleShot(True)
|
||||||
|
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
|
||||||
self.setObjectName("AiConsultDialog")
|
self.setObjectName("AiConsultDialog")
|
||||||
self.setWindowTitle("问诊详情")
|
self.setWindowTitle("问诊详情")
|
||||||
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
||||||
@@ -3508,6 +3592,7 @@ class AiConsultDialog(QDialog):
|
|||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._image_generation += 1
|
self._image_generation += 1
|
||||||
self._clear_chat()
|
self._clear_chat()
|
||||||
|
self._set_chat_filter("all")
|
||||||
# The AI context envelope depends on workspace data that has not yet
|
# The AI context envelope depends on workspace data that has not yet
|
||||||
# been fetched, so reset its cache eagerly instead of letting it
|
# been fetched, so reset its cache eagerly instead of letting it
|
||||||
# linger from a previous patient session.
|
# linger from a previous patient session.
|
||||||
@@ -3540,6 +3625,24 @@ class AiConsultDialog(QDialog):
|
|||||||
layout = QVBoxLayout(shell)
|
layout = QVBoxLayout(shell)
|
||||||
layout.setContentsMargins(18, 14, 18, 14)
|
layout.setContentsMargins(18, 14, 18, 14)
|
||||||
layout.setSpacing(12)
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
splitter.setChildrenCollapsible(False)
|
||||||
|
splitter.addWidget(self._build_center())
|
||||||
|
splitter.addWidget(self._build_right_pane())
|
||||||
|
splitter.setStretchFactor(0, 1)
|
||||||
|
splitter.setStretchFactor(1, 0)
|
||||||
|
splitter.setSizes([920, 460])
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
def _build_right_pane(self) -> QWidget:
|
||||||
|
host = QWidget()
|
||||||
|
host.setObjectName("AiConsultRightPane")
|
||||||
|
host.setMinimumWidth(360)
|
||||||
|
host.setMaximumWidth(480)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
layout.addLayout(self._build_crumb())
|
layout.addLayout(self._build_crumb())
|
||||||
layout.addWidget(self._build_hero())
|
layout.addWidget(self._build_hero())
|
||||||
self.tabs = QTabBar()
|
self.tabs = QTabBar()
|
||||||
@@ -3548,15 +3651,31 @@ class AiConsultDialog(QDialog):
|
|||||||
self.tabs.addTab(title)
|
self.tabs.addTab(title)
|
||||||
self.tabs.currentChanged.connect(self._tab_changed)
|
self.tabs.currentChanged.connect(self._tab_changed)
|
||||||
layout.addWidget(self.tabs)
|
layout.addWidget(self.tabs)
|
||||||
|
layout.addWidget(self._build_side(), 1)
|
||||||
|
return host
|
||||||
|
|
||||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
def _build_chat_filter(self) -> QPushButton:
|
||||||
splitter.setChildrenCollapsible(False)
|
self.chat_filter_button = QPushButton("筛选")
|
||||||
splitter.addWidget(self._build_center())
|
self.chat_filter_button.setObjectName("AiConsultChatFilter")
|
||||||
splitter.addWidget(self._build_side())
|
self.chat_filter_button.setAccessibleName("筛选问诊对话")
|
||||||
splitter.setStretchFactor(0, 1)
|
self.chat_filter_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
splitter.setStretchFactor(1, 0)
|
self.chat_filter_menu = QMenu(self.chat_filter_button)
|
||||||
splitter.setSizes([920, 400])
|
for value, label in (
|
||||||
layout.addWidget(splitter, 1)
|
("all", "全部对话"),
|
||||||
|
("patient", "患者消息"),
|
||||||
|
("doctor", "医生消息"),
|
||||||
|
("ai", "AI 助手消息"),
|
||||||
|
("system", "系统事件"),
|
||||||
|
):
|
||||||
|
action = self.chat_filter_menu.addAction(label)
|
||||||
|
action.setData(value)
|
||||||
|
action.setCheckable(True)
|
||||||
|
action.setChecked(value == "all")
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda _checked=False, role=value: self._set_chat_filter(role)
|
||||||
|
)
|
||||||
|
self.chat_filter_button.setMenu(self.chat_filter_menu)
|
||||||
|
return self.chat_filter_button
|
||||||
|
|
||||||
def _build_crumb(self) -> QHBoxLayout:
|
def _build_crumb(self) -> QHBoxLayout:
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
@@ -3572,6 +3691,7 @@ class AiConsultDialog(QDialog):
|
|||||||
current.setObjectName("AiConsultCrumbCurrent")
|
current.setObjectName("AiConsultCrumbCurrent")
|
||||||
row.addWidget(current)
|
row.addWidget(current)
|
||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
|
row.addWidget(self._build_chat_filter())
|
||||||
back = QPushButton("返回")
|
back = QPushButton("返回")
|
||||||
back.setObjectName("AiConsultBack")
|
back.setObjectName("AiConsultBack")
|
||||||
back.clicked.connect(self.reject)
|
back.clicked.connect(self.reject)
|
||||||
@@ -3582,9 +3702,11 @@ class AiConsultDialog(QDialog):
|
|||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setObjectName("AiConsultHero")
|
card.setObjectName("AiConsultHero")
|
||||||
_style_surface(card)
|
_style_surface(card)
|
||||||
row = QHBoxLayout(card)
|
body = QVBoxLayout(card)
|
||||||
row.setContentsMargins(16, 14, 16, 14)
|
body.setContentsMargins(14, 12, 14, 12)
|
||||||
row.setSpacing(14)
|
body.setSpacing(8)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.setSpacing(10)
|
||||||
self.avatar = QLabel("患")
|
self.avatar = QLabel("患")
|
||||||
self.avatar.setObjectName("AiConsultAvatar")
|
self.avatar.setObjectName("AiConsultAvatar")
|
||||||
self.avatar.setFixedSize(44, 44)
|
self.avatar.setFixedSize(44, 44)
|
||||||
@@ -3599,13 +3721,14 @@ class AiConsultDialog(QDialog):
|
|||||||
self.identity_label = QLabel("")
|
self.identity_label = QLabel("")
|
||||||
self.identity_label.setObjectName("AiConsultMeta")
|
self.identity_label.setObjectName("AiConsultMeta")
|
||||||
info.addWidget(self.identity_label)
|
info.addWidget(self.identity_label)
|
||||||
self.chip_row = _FlowRow()
|
|
||||||
info.addWidget(self.chip_row)
|
|
||||||
row.addLayout(info, 1)
|
row.addLayout(info, 1)
|
||||||
full = QPushButton("查看完整资料")
|
full = QPushButton("查看完整资料")
|
||||||
full.setObjectName("AiConsultFullRecord")
|
full.setObjectName("AiConsultFullRecord")
|
||||||
full.clicked.connect(self._open_full_record)
|
full.clicked.connect(self._open_full_record)
|
||||||
row.addWidget(full, 0, Qt.AlignmentFlag.AlignTop)
|
row.addWidget(full, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
body.addLayout(row)
|
||||||
|
self.chip_row = _FlowRow()
|
||||||
|
body.addWidget(self.chip_row)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
def _build_center(self) -> QWidget:
|
def _build_center(self) -> QWidget:
|
||||||
@@ -3662,6 +3785,8 @@ class AiConsultDialog(QDialog):
|
|||||||
hint.setObjectName("AiConsultComposerLabel")
|
hint.setObjectName("AiConsultComposerLabel")
|
||||||
chips.addWidget(hint)
|
chips.addWidget(hint)
|
||||||
for label, prompt in QUICK_PROMPTS[:3]:
|
for label, prompt in QUICK_PROMPTS[:3]:
|
||||||
|
if label == "AI 生成处方" and not self._can_generate_ai_prescription():
|
||||||
|
continue
|
||||||
button = QPushButton(label)
|
button = QPushButton(label)
|
||||||
button.setObjectName("AiConsultChipButton")
|
button.setObjectName("AiConsultChipButton")
|
||||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
@@ -3808,7 +3933,6 @@ class AiConsultDialog(QDialog):
|
|||||||
side.setObjectName("AiConsultSide")
|
side.setObjectName("AiConsultSide")
|
||||||
_style_surface(side)
|
_style_surface(side)
|
||||||
side.setMinimumWidth(350)
|
side.setMinimumWidth(350)
|
||||||
side.setMaximumWidth(420)
|
|
||||||
outer = QVBoxLayout(side)
|
outer = QVBoxLayout(side)
|
||||||
outer.setContentsMargins(18, 16, 18, 16)
|
outer.setContentsMargins(18, 16, 18, 16)
|
||||||
outer.setSpacing(14)
|
outer.setSpacing(14)
|
||||||
@@ -3911,6 +4035,32 @@ class AiConsultDialog(QDialog):
|
|||||||
|
|
||||||
def _tab_changed(self, index: int) -> None:
|
def _tab_changed(self, index: int) -> None:
|
||||||
self.center_stack.setCurrentIndex(index)
|
self.center_stack.setCurrentIndex(index)
|
||||||
|
self.chat_filter_button.setVisible(index == 0)
|
||||||
|
|
||||||
|
def _set_chat_filter(self, role: str) -> None:
|
||||||
|
self._chat_filter = role
|
||||||
|
label = "全部对话"
|
||||||
|
for action in self.chat_filter_menu.actions():
|
||||||
|
selected = action.data() == role
|
||||||
|
action.setChecked(selected)
|
||||||
|
if selected:
|
||||||
|
label = action.text()
|
||||||
|
self.chat_filter_button.setText(
|
||||||
|
"筛选" if role == "all" else f"筛选:{label}"
|
||||||
|
)
|
||||||
|
for index in range(self.chat_layout.count() - 1):
|
||||||
|
widget = self.chat_layout.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setVisible(self._chat_item_matches_filter(widget))
|
||||||
|
|
||||||
|
def _chat_item_matches_filter(self, widget: QWidget) -> bool:
|
||||||
|
if self._chat_filter == "all":
|
||||||
|
return True
|
||||||
|
if isinstance(widget, _ChatEvent):
|
||||||
|
return self._chat_filter == "system"
|
||||||
|
if isinstance(widget, _ChatBubble):
|
||||||
|
return widget._role == self._chat_filter
|
||||||
|
return True
|
||||||
|
|
||||||
def _clear_chat(self) -> None:
|
def _clear_chat(self) -> None:
|
||||||
while self.chat_layout.count() > 1:
|
while self.chat_layout.count() > 1:
|
||||||
@@ -3932,6 +4082,7 @@ class AiConsultDialog(QDialog):
|
|||||||
) -> _ChatBubble:
|
) -> _ChatBubble:
|
||||||
bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra)
|
bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra)
|
||||||
self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble)
|
self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble)
|
||||||
|
bubble.setVisible(self._chat_item_matches_filter(bubble))
|
||||||
if self._follow_chat:
|
if self._follow_chat:
|
||||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||||
return bubble
|
return bubble
|
||||||
@@ -3953,6 +4104,7 @@ class AiConsultDialog(QDialog):
|
|||||||
marker=marker,
|
marker=marker,
|
||||||
)
|
)
|
||||||
self.chat_layout.insertWidget(self.chat_layout.count() - 1, event)
|
self.chat_layout.insertWidget(self.chat_layout.count() - 1, event)
|
||||||
|
event.setVisible(self._chat_item_matches_filter(event))
|
||||||
if self._follow_chat:
|
if self._follow_chat:
|
||||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||||
return event
|
return event
|
||||||
@@ -4550,6 +4702,7 @@ class AiConsultDialog(QDialog):
|
|||||||
time_text=item.time_text,
|
time_text=item.time_text,
|
||||||
)
|
)
|
||||||
if item.role == "ai":
|
if item.role == "ai":
|
||||||
|
bubble.set_provenance(row)
|
||||||
bubble.finalize_clinical_analysis()
|
bubble.finalize_clinical_analysis()
|
||||||
|
|
||||||
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
|
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
|
||||||
@@ -4564,51 +4717,25 @@ class AiConsultDialog(QDialog):
|
|||||||
)
|
)
|
||||||
top_risk = risk_rows[0] if risk_rows else {}
|
top_risk = risk_rows[0] if risk_rows else {}
|
||||||
risk_label = display_text(first_value(top_risk, "label"), "")
|
risk_label = display_text(first_value(top_risk, "label"), "")
|
||||||
risk_level = str(first_value(top_risk, "level", default="medium") or "medium")
|
risk_level = str(first_value(top_risk, "level", default="unknown") or "unknown")
|
||||||
if risk_level not in {"high", "medium", "low"}:
|
if risk_level not in {"high", "medium", "low"}:
|
||||||
risk_level = "medium"
|
risk_level = "unknown"
|
||||||
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险"}[risk_level]
|
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险", "unknown": "未提供"}[risk_level]
|
||||||
diagnosis = _as_mapping(get_value(self._detail, "diagnosis", default=self._detail) or self._seed)
|
if not advice and not treatment and not risk_rows:
|
||||||
appointment = _as_mapping(get_value(self._detail, "appointment", default={}))
|
self._set_insights(
|
||||||
clinical = display_text(
|
[("分析状态", "当前诊单暂无可用的 AI 分析报告,请重新分析后查看。", "未生成", "unknown")]
|
||||||
_pick(("clinical_diagnosis", "diagnosis_name"), diagnosis, appointment, self._seed),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
complaint = display_text(_pick(("chief_complaint",), diagnosis, self._seed), "")
|
|
||||||
glucose = _pick(("fasting_blood_sugar",), diagnosis, appointment, self._seed)
|
|
||||||
glucose_number = _as_float(glucose)
|
|
||||||
if not advice:
|
|
||||||
advice = _join_meta(clinical, f"主诉 {complaint}" if complaint else "") or "结合现有病历评估证候与病情。"
|
|
||||||
if not risk_label:
|
|
||||||
if glucose_number is not None and glucose_number >= 7.0:
|
|
||||||
risk_label, risk_level, level_text = "高血糖风险", "high", "高风险"
|
|
||||||
elif glucose_number is not None and glucose_number >= 6.1:
|
|
||||||
risk_label, risk_level, level_text = "血糖波动风险", "medium", "中风险"
|
|
||||||
else:
|
|
||||||
risk_label, risk_level, level_text = "需持续随访", "low", "低风险"
|
|
||||||
if not treatment:
|
|
||||||
treatment = "结合处方、依从性与最新检验结果评估是否需要调整用药。"
|
|
||||||
markdown = _analysis_markdown(
|
|
||||||
{
|
|
||||||
"diagnosis_advice": advice,
|
|
||||||
"treatment_advice": treatment,
|
|
||||||
"risk_assessment": risk_rows
|
|
||||||
or [{"label": risk_label, "level": risk_level}],
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
markdown = _analysis_markdown(payload)
|
||||||
if markdown:
|
if markdown:
|
||||||
bubble = self._append_bubble("ai", markdown)
|
bubble = self._append_bubble("ai", markdown)
|
||||||
|
bubble.set_provenance(payload)
|
||||||
bubble.finalize_clinical_analysis()
|
bubble.finalize_clinical_analysis()
|
||||||
glucose_tag, glucose_level = (
|
|
||||||
("未达标", "warn")
|
|
||||||
if "高血糖" in risk_label or risk_level == "high"
|
|
||||||
else ("需关注", "low")
|
|
||||||
)
|
|
||||||
self._set_insights(
|
self._set_insights(
|
||||||
[
|
[
|
||||||
("血糖控制评估", advice, glucose_tag, glucose_level),
|
("血糖控制评估", advice or "报告未提供此项分析。", "已分析" if advice else "未提供", "low" if advice else "unknown"),
|
||||||
("并发症风险评估", risk_label, level_text, risk_level),
|
("并发症风险评估", risk_label or "报告未列出风险项。", level_text, risk_level),
|
||||||
("用药合理性评估", treatment, "需关注", "low"),
|
("用药合理性评估", treatment or "报告未提供此项分析。", "已分析" if treatment else "未提供", "low" if treatment else "unknown"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5464,6 +5591,13 @@ class AiConsultDialog(QDialog):
|
|||||||
def _submit(self) -> None:
|
def _submit(self) -> None:
|
||||||
self._ask(self.input.text())
|
self._ask(self.input.text())
|
||||||
|
|
||||||
|
def _can_generate_ai_prescription(self) -> bool:
|
||||||
|
return has_permission(
|
||||||
|
self.permissions, AI_PRESCRIPTION_PERMISSION, default=False
|
||||||
|
) and has_permission(
|
||||||
|
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_local_action(self, text: str) -> bool:
|
def _handle_local_action(self, text: str) -> bool:
|
||||||
if not _is_open_prescription_intent(text):
|
if not _is_open_prescription_intent(text):
|
||||||
return False
|
return False
|
||||||
@@ -5474,9 +5608,8 @@ class AiConsultDialog(QDialog):
|
|||||||
text,
|
text,
|
||||||
time_text=datetime.now().strftime("%H:%M"),
|
time_text=datetime.now().strftime("%H:%M"),
|
||||||
)
|
)
|
||||||
can_prescribe = any(
|
can_prescribe = has_permission(
|
||||||
has_permission(self.permissions, code, default=False)
|
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
|
||||||
for code in ("tcm.diagnosis/chufang", "tcm.diagnosis/kaifang")
|
|
||||||
)
|
)
|
||||||
if not can_prescribe:
|
if not can_prescribe:
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
@@ -5485,6 +5618,15 @@ class AiConsultDialog(QDialog):
|
|||||||
time_text="系统",
|
time_text="系统",
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
if not has_permission(
|
||||||
|
self.permissions, AI_PRESCRIPTION_PERMISSION, default=False
|
||||||
|
):
|
||||||
|
self._append_bubble(
|
||||||
|
"ai",
|
||||||
|
"当前账号没有 AI 生成处方权限,无法生成处方草稿。",
|
||||||
|
time_text="系统",
|
||||||
|
)
|
||||||
|
return True
|
||||||
if not callable(getattr(self.repository, "create_prescription", None)):
|
if not callable(getattr(self.repository, "create_prescription", None)):
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
"ai",
|
"ai",
|
||||||
@@ -5898,6 +6040,7 @@ class AiConsultDialog(QDialog):
|
|||||||
worker.signals.finished.connect(
|
worker.signals.finished.connect(
|
||||||
lambda: self._stream_finished(generation, stream_generation, worker)
|
lambda: self._stream_finished(generation, stream_generation, worker)
|
||||||
)
|
)
|
||||||
|
self._stream_timeout_timer.start(AI_STREAM_TOTAL_TIMEOUT_MS)
|
||||||
QThreadPool.globalInstance().start(worker)
|
QThreadPool.globalInstance().start(worker)
|
||||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||||
|
|
||||||
@@ -5916,6 +6059,8 @@ class AiConsultDialog(QDialog):
|
|||||||
kind = str(payload.get("event") or "").lower()
|
kind = str(payload.get("event") or "").lower()
|
||||||
if kind == "start":
|
if kind == "start":
|
||||||
self._stream_meta.update(payload)
|
self._stream_meta.update(payload)
|
||||||
|
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
|
||||||
|
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
|
||||||
return
|
return
|
||||||
if kind == "delta":
|
if kind == "delta":
|
||||||
chunk = payload.get("text")
|
chunk = payload.get("text")
|
||||||
@@ -5928,6 +6073,7 @@ class AiConsultDialog(QDialog):
|
|||||||
return
|
return
|
||||||
self._stream_meta.update(payload)
|
self._stream_meta.update(payload)
|
||||||
self._stream_completed = True
|
self._stream_completed = True
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
if not self._stream_text and not self._pending_chunks:
|
if not self._stream_text and not self._pending_chunks:
|
||||||
answer = first_value(payload, "answer", "content", default="")
|
answer = first_value(payload, "answer", "content", default="")
|
||||||
if answer not in (None, ""):
|
if answer not in (None, ""):
|
||||||
@@ -5944,6 +6090,7 @@ class AiConsultDialog(QDialog):
|
|||||||
self._stream_bubble.set_time_text(
|
self._stream_bubble.set_time_text(
|
||||||
_join_meta(model, datetime.now().strftime("%H:%M"))
|
_join_meta(model, datetime.now().strftime("%H:%M"))
|
||||||
)
|
)
|
||||||
|
self._stream_bubble.set_provenance(self._stream_meta)
|
||||||
self._stream_bubble.finalize_clinical_analysis()
|
self._stream_bubble.finalize_clinical_analysis()
|
||||||
|
|
||||||
def _flush_stream_chunks(self) -> None:
|
def _flush_stream_chunks(self) -> None:
|
||||||
@@ -5963,6 +6110,7 @@ class AiConsultDialog(QDialog):
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not self._stream_is_current(generation, stream_generation):
|
if not self._stream_is_current(generation, stream_generation):
|
||||||
return
|
return
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
self._flush_stream_chunks()
|
self._flush_stream_chunks()
|
||||||
message = friendly_error(error)
|
message = friendly_error(error)
|
||||||
if self._stream_text:
|
if self._stream_text:
|
||||||
@@ -5981,10 +6129,11 @@ class AiConsultDialog(QDialog):
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not self._stream_is_current(generation, stream_generation):
|
if not self._stream_is_current(generation, stream_generation):
|
||||||
return
|
return
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
if self._stream_worker is worker:
|
if self._stream_worker is worker:
|
||||||
self._stream_worker = None
|
self._stream_worker = None
|
||||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
|
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
|
||||||
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
|
# 将阶段占位文案替换成明确说明。
|
||||||
if (
|
if (
|
||||||
not self._stream_completed
|
not self._stream_completed
|
||||||
and not self._stream_text
|
and not self._stream_text
|
||||||
@@ -5996,9 +6145,23 @@ class AiConsultDialog(QDialog):
|
|||||||
self._asking = False
|
self._asking = False
|
||||||
self.send_button.setEnabled(True)
|
self.send_button.setEnabled(True)
|
||||||
|
|
||||||
|
def _stream_timed_out(self) -> None:
|
||||||
|
if not self._asking or self._stream_completed:
|
||||||
|
return
|
||||||
|
self._flush_stream_chunks()
|
||||||
|
bubble = self._stream_bubble
|
||||||
|
text = self._stream_text
|
||||||
|
self._cancel_stream()
|
||||||
|
if bubble is not None:
|
||||||
|
bubble.set_payload(
|
||||||
|
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
|
||||||
|
)
|
||||||
|
bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||||
|
|
||||||
def _cancel_stream(self) -> None:
|
def _cancel_stream(self) -> None:
|
||||||
self._stream_generation += 1
|
self._stream_generation += 1
|
||||||
self._flush_timer.stop()
|
self._flush_timer.stop()
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
if self._stream_worker is not None:
|
if self._stream_worker is not None:
|
||||||
self._stream_worker.cancel()
|
self._stream_worker.cancel()
|
||||||
self._stream_worker = None
|
self._stream_worker = None
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from ..diagnosis_media import (
|
|||||||
safe_image_sources,
|
safe_image_sources,
|
||||||
)
|
)
|
||||||
from ..infinite_list import InfiniteList
|
from ..infinite_list import InfiniteList
|
||||||
|
from ..theme import main_window_target_rect
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -716,6 +717,11 @@ class OrderDetailDrawer(QDialog):
|
|||||||
QTimer.singleShot(0, self.sync_to_owner)
|
QTimer.singleShot(0, self.sync_to_owner)
|
||||||
|
|
||||||
def sync_to_owner(self) -> None:
|
def sync_to_owner(self) -> None:
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
self.setGeometry(target)
|
||||||
|
self.drawer_panel.setFixedWidth(max(1, round(target.width() * self.drawer_ratio)))
|
||||||
|
return
|
||||||
owner = self._owner
|
owner = self._owner
|
||||||
if owner is None:
|
if owner is None:
|
||||||
self.resize(1152, 720)
|
self.resize(1152, 720)
|
||||||
@@ -1836,6 +1842,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
def _sync_host_geometry(self) -> None:
|
def _sync_host_geometry(self) -> None:
|
||||||
if self._embedded:
|
if self._embedded:
|
||||||
return
|
return
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
self.setGeometry(target)
|
||||||
|
return
|
||||||
owner = self._owner
|
owner = self._owner
|
||||||
if owner is None:
|
if owner is None:
|
||||||
if self.width() < 760 or self.height() < 520:
|
if self.width() < 760 or self.height() < 520:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ...core.errors import AuthenticationExpiredError
|
from ...core.errors import AuthenticationExpiredError
|
||||||
from ..reception_style import body_family
|
from ..reception_style import body_family
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..widgets import first_value, format_record_time, friendly_error, has_permission, run_async
|
from ..widgets import first_value, format_record_time, friendly_error, has_permission, run_async
|
||||||
from .issued_prescription_ai_console import AgreementBar, StepRail, rail_qss
|
from .issued_prescription_ai_console import AgreementBar, StepRail, rail_qss
|
||||||
from .issued_prescription_ai_glyphs import Glyph, LogoTile, glyph_icon
|
from .issued_prescription_ai_glyphs import Glyph, LogoTile, glyph_icon
|
||||||
@@ -1414,7 +1415,14 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
||||||
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
||||||
_set_html(views["comparison"], comparison_html)
|
_set_html(views["comparison"], comparison_html)
|
||||||
_set_html(views["sources"], _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))}))
|
source_html = _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))})
|
||||||
|
reference_lines = [line for line in report_provenance_lines(model)
|
||||||
|
if line.startswith(("知识参考:", "参考章节:", "原文:", "古籍参考:"))]
|
||||||
|
if reference_lines:
|
||||||
|
source_html += '<p class="sec">文献出处</p>' + "".join(
|
||||||
|
"<p>" + escape(line) + "</p>" for line in reference_lines
|
||||||
|
)
|
||||||
|
_set_html(views["sources"], source_html)
|
||||||
if first_load and not self._pending_drafts:
|
if first_load and not self._pending_drafts:
|
||||||
review = mapping(model.get("review"))
|
review = mapping(model.get("review"))
|
||||||
views["comment"].setPlainText(str(review.get("comment") or ""))
|
views["comment"].setPlainText(str(review.get("comment") or ""))
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ from PySide6.QtWidgets import (
|
|||||||
from .. import icons
|
from .. import icons
|
||||||
from ..diagnosis_terms import shared_terms
|
from ..diagnosis_terms import shared_terms
|
||||||
from ..infinite_list import InfiniteList
|
from ..infinite_list import InfiniteList
|
||||||
|
from ..theme import main_window_target_rect
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
display_text,
|
display_text,
|
||||||
@@ -3306,6 +3307,17 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self._fit_drawer_geometry()
|
self._fit_drawer_geometry()
|
||||||
|
|
||||||
def _fit_drawer_geometry(self) -> None:
|
def _fit_drawer_geometry(self) -> None:
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
width = target.width()
|
||||||
|
self.drawer_surface.setMaximumWidth(max(self.DRAWER_WIDTH, width))
|
||||||
|
self.setGeometry(target)
|
||||||
|
if self.diagnosis_host.isVisible():
|
||||||
|
editor_width = min(self.DRAWER_WIDTH, max(520, round(width * 0.55)))
|
||||||
|
self.workspace_splitter.setSizes([max(300, width - editor_width - 1), editor_width])
|
||||||
|
else:
|
||||||
|
self.workspace_splitter.setSizes([0, width])
|
||||||
|
return
|
||||||
parent = self.parentWidget()
|
parent = self.parentWidget()
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..theme import apply_reading_rhythm
|
from ..theme import apply_reading_rhythm
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -1413,8 +1414,22 @@ class PrescriptionAiReportDialog(QDialog):
|
|||||||
or "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。"
|
or "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。"
|
||||||
)
|
)
|
||||||
self._add_label(disclaimer, "PrescriptionAiMuted")
|
self._add_label(disclaimer, "PrescriptionAiMuted")
|
||||||
|
self._add_report_provenance(data)
|
||||||
return
|
return
|
||||||
self._section("报告正文", str(data.get("content") or "暂无报告正文"))
|
self._section("报告正文", str(data.get("content") or "暂无报告正文"))
|
||||||
|
self._add_report_provenance(data)
|
||||||
|
|
||||||
|
def _add_report_provenance(self, data: dict[str, Any]) -> None:
|
||||||
|
if self.kind is not DIAGNOSIS_AI_KIND:
|
||||||
|
return
|
||||||
|
lines = [f"关联诊单:#{self._entity_id()}", *report_provenance_lines(data)]
|
||||||
|
label = QLabel("报告出处\n" + "\n".join(lines))
|
||||||
|
label.setObjectName("DiagnosisAiReportProvenance")
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
|
||||||
|
self.host_layout.addWidget(label)
|
||||||
|
|
||||||
def reject(self) -> None:
|
def reject(self) -> None:
|
||||||
self.dialog_generation += 1
|
self.dialog_generation += 1
|
||||||
@@ -1464,6 +1479,13 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
self.answer_label = _AiAnswerBrowser()
|
self.answer_label = _AiAnswerBrowser()
|
||||||
self.answer_scroll = self.answer_label
|
self.answer_scroll = self.answer_label
|
||||||
root.addWidget(self.answer_scroll, 1)
|
root.addWidget(self.answer_scroll, 1)
|
||||||
|
self.source_label = QLabel("")
|
||||||
|
self.source_label.setObjectName("DiagnosisAiAssistantProvenance")
|
||||||
|
self.source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
self.source_label.setWordWrap(True)
|
||||||
|
self.source_label.setVisible(False)
|
||||||
|
root.addWidget(self.source_label)
|
||||||
actions = QHBoxLayout()
|
actions = QHBoxLayout()
|
||||||
actions.addStretch(1)
|
actions.addStretch(1)
|
||||||
self.retry_button = QPushButton("重试")
|
self.retry_button = QPushButton("重试")
|
||||||
@@ -1481,6 +1503,8 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
self.question_label.setText(f"问题:{self.prompt}")
|
self.question_label.setText(f"问题:{self.prompt}")
|
||||||
self.answer_label.clear()
|
self.answer_label.clear()
|
||||||
self.model_label.clear()
|
self.model_label.clear()
|
||||||
|
self.source_label.clear()
|
||||||
|
self.source_label.hide()
|
||||||
self._ask()
|
self._ask()
|
||||||
|
|
||||||
def _ask(self) -> None:
|
def _ask(self) -> None:
|
||||||
@@ -1519,6 +1543,8 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.model_label.setText(f"本次模型:{model}")
|
self.model_label.setText(f"本次模型:{model}")
|
||||||
self.answer_label.set_answer(answer)
|
self.answer_label.set_answer(answer)
|
||||||
|
self.source_label.setText("分析出处:" + ";".join(report_provenance_lines(payload)))
|
||||||
|
self.source_label.show()
|
||||||
self.status_banner.show_message("分析完成", "success")
|
self.status_banner.show_message("分析完成", "success")
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self.retry_button.setEnabled(True)
|
self.retry_button.setEnabled(True)
|
||||||
@@ -1527,6 +1553,7 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
if generation != self.request_generation:
|
if generation != self.request_generation:
|
||||||
return
|
return
|
||||||
self.status_banner.show_message(error_message(error), "danger")
|
self.status_banner.show_message(error_message(error), "danger")
|
||||||
|
self.source_label.hide()
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self.retry_button.setEnabled(True)
|
self.retry_button.setEnabled(True)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from ..dialogs.prescription_ai import (
|
|||||||
)
|
)
|
||||||
from ..filter_disclosure import FilterDisclosure
|
from ..filter_disclosure import FilterDisclosure
|
||||||
from ..reception_style import body_family, heading_family, number_family
|
from ..reception_style import body_family, heading_family, number_family
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..theme import mark_business_dialog
|
from ..theme import mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -3335,6 +3336,14 @@ class _ReceptionAiAnalysisDialog(QDialog):
|
|||||||
"",
|
"",
|
||||||
"ReceptionAiAnalysisDialogTreatmentText",
|
"ReceptionAiAnalysisDialogTreatmentText",
|
||||||
)
|
)
|
||||||
|
source_label = QLabel("", body)
|
||||||
|
source_label.setObjectName("ReceptionAiAnalysisDialogSources")
|
||||||
|
source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
source_label.setWordWrap(True)
|
||||||
|
source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
|
||||||
|
body_layout.addWidget(source_label)
|
||||||
|
self.source_label = source_label
|
||||||
body_layout.addStretch(1)
|
body_layout.addStretch(1)
|
||||||
scroll.setWidget(body)
|
scroll.setWidget(body)
|
||||||
surface_layout.addWidget(scroll, 1)
|
surface_layout.addWidget(scroll, 1)
|
||||||
@@ -3525,6 +3534,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
|
|||||||
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
|
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
|
||||||
)
|
)
|
||||||
self.meta_label.setVisible(bool(self.meta_label.text()))
|
self.meta_label.setVisible(bool(self.meta_label.text()))
|
||||||
|
self.source_label.setText("报告出处\n" + "\n".join(report_provenance_lines(payload)))
|
||||||
self.scroll_area.verticalScrollBar().setValue(0)
|
self.scroll_area.verticalScrollBar().setValue(0)
|
||||||
|
|
||||||
|
|
||||||
@@ -5198,6 +5208,13 @@ class ReceptionPage(QWidget):
|
|||||||
state_layout.addStretch(1)
|
state_layout.addStretch(1)
|
||||||
self.ai_analysis_stack.addWidget(analysis_state)
|
self.ai_analysis_stack.addWidget(analysis_state)
|
||||||
analysis_layout.addWidget(self.ai_analysis_stack)
|
analysis_layout.addWidget(self.ai_analysis_stack)
|
||||||
|
self.ai_source_label = QLabel("", analysis_card)
|
||||||
|
self.ai_source_label.setObjectName("ReceptionAiSourceSummary")
|
||||||
|
self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
self.ai_source_label.setWordWrap(True)
|
||||||
|
self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;")
|
||||||
|
analysis_layout.addWidget(self.ai_source_label)
|
||||||
|
|
||||||
self.ai_detail_actions_host = QWidget(analysis_card)
|
self.ai_detail_actions_host = QWidget(analysis_card)
|
||||||
report_footer = QHBoxLayout(self.ai_detail_actions_host)
|
report_footer = QHBoxLayout(self.ai_detail_actions_host)
|
||||||
@@ -5432,6 +5449,7 @@ class ReceptionPage(QWidget):
|
|||||||
self.ai_analysis_stack.setCurrentWidget(
|
self.ai_analysis_stack.setCurrentWidget(
|
||||||
self.ai_analysis_content_page if success else self.ai_analysis_state_page
|
self.ai_analysis_content_page if success else self.ai_analysis_state_page
|
||||||
)
|
)
|
||||||
|
self.ai_source_label.setVisible(success)
|
||||||
if success and payload is not None:
|
if success and payload is not None:
|
||||||
if (
|
if (
|
||||||
self._ai_analysis_rendered_model != model
|
self._ai_analysis_rendered_model != model
|
||||||
@@ -5559,6 +5577,9 @@ class ReceptionPage(QWidget):
|
|||||||
)
|
)
|
||||||
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
|
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
|
||||||
self.ai_analysis_card.setToolTip("")
|
self.ai_analysis_card.setToolTip("")
|
||||||
|
source_lines = report_provenance_lines(payload)
|
||||||
|
self.ai_source_label.setText("报告出处:" + ";".join(source_lines[:2]))
|
||||||
|
self.ai_source_label.setToolTip("\n".join(source_lines))
|
||||||
|
|
||||||
def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None:
|
def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None:
|
||||||
_clear_ai_layout(self.ai_risk_chip_layout)
|
_clear_ai_layout(self.ai_risk_chip_layout)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Human-readable, server-recorded source scope for clinical AI reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
_REPORT_SOURCE_COUNTS = (
|
||||||
|
("diagnosis_count", "诊单"),
|
||||||
|
("doctor_note_count", "医生备注"),
|
||||||
|
("tracking_note_count", "随访记录"),
|
||||||
|
("blood_record_count", "血糖血压记录"),
|
||||||
|
("diet_record_count", "饮食记录"),
|
||||||
|
("exercise_record_count", "运动记录"),
|
||||||
|
("prescription_count", "历史处方"),
|
||||||
|
("im_message_count", "IM 消息"),
|
||||||
|
("wechat_message_count", "企微消息"),
|
||||||
|
("call_record_count", "视频问诊"),
|
||||||
|
("transcript_segment_count", "视频转写片段"),
|
||||||
|
)
|
||||||
|
_NIHAIXIA_SOURCE_URL = "https://github.com/coinweth/nihaixia-skill"
|
||||||
|
_NIHAIXIA_COMMIT = "270e94e637c4249dc411e54ac6715f21247de3cb"
|
||||||
|
_ANCIENT_BOOKS_COMMIT = "db0155dc7c42b9c6b3736896661f317c7110038f"
|
||||||
|
_ANCIENT_BOOKS_URL = "https://github.com/xiaopangxia/TCM-Ancient-Books"
|
||||||
|
|
||||||
|
|
||||||
|
def _source_text(value: Any, limit: int) -> str:
|
||||||
|
"""Keep displayed source metadata brief and free of repository links."""
|
||||||
|
text = re.sub(r"https://github\.com(?:/[^\s,。;、」)》\]]*)?", "", str(value or ""))
|
||||||
|
return " ".join(text.split())[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def report_provenance_lines(raw: Any) -> tuple[str, ...]:
|
||||||
|
"""Describe recorded inputs; never turn model prose into a claimed citation."""
|
||||||
|
report = raw if isinstance(raw, Mapping) else {}
|
||||||
|
lines: list[str] = []
|
||||||
|
ids = report.get("source_diagnosis_ids")
|
||||||
|
diagnosis_ids = [
|
||||||
|
value for value in ids if type(value) is int and value > 0
|
||||||
|
] if isinstance(ids, (list, tuple)) else []
|
||||||
|
if diagnosis_ids:
|
||||||
|
shown = "、".join(f"#{value}" for value in diagnosis_ids[:12])
|
||||||
|
lines.append("来源诊单:" + shown + (" 等" if len(diagnosis_ids) > 12 else ""))
|
||||||
|
|
||||||
|
summary = report.get("source_summary")
|
||||||
|
counts = []
|
||||||
|
if isinstance(summary, Mapping):
|
||||||
|
for key, label in _REPORT_SOURCE_COUNTS:
|
||||||
|
value = summary.get(key)
|
||||||
|
if type(value) is int and value > 0:
|
||||||
|
counts.append(f"{label} {value} 条")
|
||||||
|
if counts:
|
||||||
|
lines.append("患者资料范围:" + "、".join(counts))
|
||||||
|
elif isinstance(summary, Mapping) and type(summary.get("source_record_count")) is int and summary["source_record_count"] > 0:
|
||||||
|
lines.append(f"患者资料范围:记录 {summary['source_record_count']} 条,分类未记录")
|
||||||
|
elif not diagnosis_ids:
|
||||||
|
lines.append("患者资料范围:此报告未附可核查的来源清单")
|
||||||
|
|
||||||
|
knowledge = report.get("knowledge_source")
|
||||||
|
url = knowledge.get("url") if isinstance(knowledge, Mapping) else None
|
||||||
|
legacy_source_match = re.fullmatch(
|
||||||
|
re.escape(_NIHAIXIA_SOURCE_URL) + r"/blob/([0-9a-f]{40})/SKILL\.md",
|
||||||
|
url,
|
||||||
|
) if isinstance(url, str) else None
|
||||||
|
valid_knowledge = isinstance(knowledge, Mapping) and knowledge.get("commit") == _NIHAIXIA_COMMIT and (
|
||||||
|
(url in (None, "") and knowledge.get("path") == "SKILL.md")
|
||||||
|
or (
|
||||||
|
legacy_source_match is not None
|
||||||
|
and legacy_source_match.group(1) == _NIHAIXIA_COMMIT
|
||||||
|
and knowledge.get("path") in (None, "SKILL.md")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if valid_knowledge:
|
||||||
|
lines.append(
|
||||||
|
"知识参考:倪海厦临床参考 Skill;本地资料:"
|
||||||
|
"server/knowledge/nihaixia-skill/SKILL.md"
|
||||||
|
+ f"(版本 {_NIHAIXIA_COMMIT[:12]})"
|
||||||
|
)
|
||||||
|
sections = knowledge.get("sections")
|
||||||
|
allowed_sections = {"六经速查表", "六经辨证诊断公式", "七步走辨证思维模式"}
|
||||||
|
if isinstance(sections, (list, tuple)) and sections and all(
|
||||||
|
isinstance(item, str) and item in allowed_sections for item in sections
|
||||||
|
):
|
||||||
|
lines.append("参考章节:" + "、".join(sections))
|
||||||
|
else:
|
||||||
|
lines.append("知识参考:此报告未记录所用知识资料版本")
|
||||||
|
|
||||||
|
ancient_sources = report.get("ancient_book_sources")
|
||||||
|
shown_sources: set[tuple[str, int]] = set()
|
||||||
|
if isinstance(ancient_sources, (list, tuple)):
|
||||||
|
for source in ancient_sources[:12]:
|
||||||
|
if not isinstance(source, Mapping) or source.get("commit") != _ANCIENT_BOOKS_COMMIT:
|
||||||
|
continue
|
||||||
|
ancient_url = source.get("url")
|
||||||
|
filename = source.get("path")
|
||||||
|
line_number = source.get("line")
|
||||||
|
if ancient_url not in (None, ""):
|
||||||
|
legacy_match = re.fullmatch(
|
||||||
|
re.escape(_ANCIENT_BOOKS_URL)
|
||||||
|
+ r"/blob/"
|
||||||
|
+ _ANCIENT_BOOKS_COMMIT
|
||||||
|
+ r"/([^/#?]+\.txt)#L([1-9]\d{0,8})",
|
||||||
|
ancient_url,
|
||||||
|
) if isinstance(ancient_url, str) else None
|
||||||
|
if legacy_match is None:
|
||||||
|
continue
|
||||||
|
legacy_filename = unquote(legacy_match.group(1))
|
||||||
|
legacy_line = int(legacy_match.group(2))
|
||||||
|
if filename not in (None, legacy_filename) or line_number not in (None, legacy_line):
|
||||||
|
continue
|
||||||
|
filename, line_number = legacy_filename, legacy_line
|
||||||
|
if not (
|
||||||
|
isinstance(filename, str)
|
||||||
|
and re.fullmatch(r"[^/\\\x00-\x1f]{1,255}\.txt", filename)
|
||||||
|
and type(line_number) is int
|
||||||
|
and 0 < line_number < 1_000_000_000
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
source_key = (filename, line_number)
|
||||||
|
if source_key in shown_sources:
|
||||||
|
continue
|
||||||
|
shown_sources.add(source_key)
|
||||||
|
title = _source_text(source.get("title"), 60) or "古籍原文"
|
||||||
|
chapter = _source_text(source.get("chapter"), 60)
|
||||||
|
excerpt = _source_text(source.get("excerpt"), 120)
|
||||||
|
lines.append(
|
||||||
|
"古籍参考:《" + title + "》"
|
||||||
|
+ ("「" + chapter + "」" if chapter else "")
|
||||||
|
+ f";本地资料:server/knowledge/tcm-ancient-books/{filename}"
|
||||||
|
+ f" 第{line_number}行(版本 {_ANCIENT_BOOKS_COMMIT[:12]})"
|
||||||
|
+ (";摘录:" + excerpt if excerpt else "")
|
||||||
|
)
|
||||||
|
if not shown_sources and "-ancient-" in str(
|
||||||
|
report.get("prompt_version") or report.get("context_version") or ""
|
||||||
|
):
|
||||||
|
lines.append("古籍参考:本次未记录可核查的古籍片段")
|
||||||
|
if report.get("is_edited") is True:
|
||||||
|
lines.append("人工编辑:报告生成后已修改,来源范围仅对应原始生成版本。")
|
||||||
|
lines.append("以上列出本次输入范围与知识参考,不代表每条结论均有逐项引用。")
|
||||||
|
return tuple(lines)
|
||||||
@@ -885,6 +885,7 @@ class ShellWindow(QMainWindow):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setProperty("doctorMainWindow", True)
|
||||||
self.setWindowTitle("甄养堂 · 问诊中心")
|
self.setWindowTitle("甄养堂 · 问诊中心")
|
||||||
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||||
# The ground paints its own rounded corner, so everything outside that
|
# The ground paints its own rounded corner, so everything outside that
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from string import Template
|
from string import Template
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QPointF, Qt
|
from PySide6.QtCore import QEvent, QObject, QPointF, QRect, Qt
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
|||||||
QScrollArea,
|
QScrollArea,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from doctor_workstation.resources import resource_path
|
from doctor_workstation.resources import resource_path
|
||||||
@@ -1450,6 +1451,73 @@ def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> Non
|
|||||||
_refresh_widget_style(dialog)
|
_refresh_widget_style(dialog)
|
||||||
|
|
||||||
|
|
||||||
|
def main_window_target_rect(window: QWidget) -> QRect | None:
|
||||||
|
"""Return a centered rectangle covering 85% of the visible workstation."""
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return None
|
||||||
|
main = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in app.topLevelWidgets()
|
||||||
|
if candidate is not window
|
||||||
|
and candidate.property("doctorMainWindow")
|
||||||
|
and candidate.isVisible()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if main is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
main_frame = main.frameGeometry()
|
||||||
|
target_width = max(1, round(main_frame.width() * 0.85))
|
||||||
|
target_height = max(1, round(main_frame.height() * 0.85))
|
||||||
|
return QRect(
|
||||||
|
main_frame.center().x() - target_width // 2,
|
||||||
|
main_frame.center().y() - target_height // 2,
|
||||||
|
target_width,
|
||||||
|
target_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _size_new_window_to_main(window: QWidget) -> None:
|
||||||
|
"""Open a business window at 85% of the visible workstation, centered."""
|
||||||
|
|
||||||
|
if not window.isWindow() or window.property("doctorMainWindow"):
|
||||||
|
return
|
||||||
|
if window.property("doctorSkipOpenGeometry"):
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
isinstance(window, (QMessageBox, QInputDialog, QFileDialog))
|
||||||
|
or window.metaObject().className() == "QPrintDialog"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if window.windowFlags() & Qt.WindowType.FramelessWindowHint:
|
||||||
|
# Drawers have their own owner-sync routines and use the same target rect.
|
||||||
|
return
|
||||||
|
target = main_window_target_rect(window)
|
||||||
|
if target is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_width = target.width()
|
||||||
|
target_height = target.height()
|
||||||
|
if window.minimumWidth() > target_width:
|
||||||
|
window.setMinimumWidth(target_width)
|
||||||
|
if window.minimumHeight() > target_height:
|
||||||
|
window.setMinimumHeight(target_height)
|
||||||
|
if window.maximumWidth() < target_width:
|
||||||
|
window.setMaximumWidth(target_width)
|
||||||
|
if window.maximumHeight() < target_height:
|
||||||
|
window.setMaximumHeight(target_height)
|
||||||
|
window.resize(target_width, target_height)
|
||||||
|
frame = window.frameGeometry()
|
||||||
|
window.move(
|
||||||
|
target.center().x() - frame.width() // 2,
|
||||||
|
target.center().y() - frame.height() // 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _BusinessDialogStyleFilter(QObject):
|
class _BusinessDialogStyleFilter(QObject):
|
||||||
"""Style dynamically-created Qt dialogs just before they become visible."""
|
"""Style dynamically-created Qt dialogs just before they become visible."""
|
||||||
|
|
||||||
@@ -1490,6 +1558,7 @@ class _BusinessDialogStyleFilter(QObject):
|
|||||||
if not self._is_native_or_overlay(watched):
|
if not self._is_native_or_overlay(watched):
|
||||||
allow_dialog_resize(watched)
|
allow_dialog_resize(watched)
|
||||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
||||||
|
_size_new_window_to_main(watched)
|
||||||
for button_box in watched.findChildren(QDialogButtonBox):
|
for button_box in watched.findChildren(QDialogButtonBox):
|
||||||
_polish_dialog_buttons(button_box)
|
_polish_dialog_buttons(button_box)
|
||||||
if not self._is_native_or_overlay(watched):
|
if not self._is_native_or_overlay(watched):
|
||||||
@@ -1505,6 +1574,13 @@ class _BusinessDialogStyleFilter(QObject):
|
|||||||
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
|
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
|
||||||
elif isinstance(watched, QInputDialog):
|
elif isinstance(watched, QInputDialog):
|
||||||
watched.setMinimumWidth(max(watched.minimumWidth(), 480))
|
watched.setMinimumWidth(max(watched.minimumWidth(), 480))
|
||||||
|
elif (
|
||||||
|
isinstance(watched, QWidget)
|
||||||
|
and event_type == QEvent.Type.Show
|
||||||
|
and watched.isWindow()
|
||||||
|
and watched.windowType() == Qt.WindowType.Window
|
||||||
|
):
|
||||||
|
_size_new_window_to_main(watched)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -1600,4 +1676,5 @@ __all__ = [
|
|||||||
"apply_theme",
|
"apply_theme",
|
||||||
"crisp_pixmap",
|
"crisp_pixmap",
|
||||||
"mark_business_dialog",
|
"mark_business_dialog",
|
||||||
|
"main_window_target_rect",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import QPoint, Qt
|
||||||
from PySide6.QtTest import QTest
|
from PySide6.QtTest import QTest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -125,6 +125,56 @@ def test_ai_consult_dialog_matches_workspace_chrome(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_and_chat_filter_move_to_right_pane_and_filter_message_roles(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||||||
|
)
|
||||||
|
patient = dialog._append_bubble("patient", "患者提问")
|
||||||
|
doctor = dialog._append_bubble("doctor", "医生回复")
|
||||||
|
ai = dialog._append_bubble("ai", "智能分析")
|
||||||
|
system = dialog._append_event(title="视频开始")
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
right_pane = dialog.findChild(QWidget, "AiConsultRightPane")
|
||||||
|
chat_pane = dialog.findChild(QWidget, "AiConsultChatPane")
|
||||||
|
assert right_pane is not None and chat_pane is not None
|
||||||
|
assert right_pane.mapTo(dialog, QPoint(0, 0)).x() >= chat_pane.mapTo(
|
||||||
|
dialog, QPoint(chat_pane.width(), 0)
|
||||||
|
).x()
|
||||||
|
assert dialog.tabs.parentWidget() is right_pane
|
||||||
|
assert dialog.chat_filter_button.parentWidget() is right_pane
|
||||||
|
assert right_pane.findChild(QWidget, "AiConsultHero") is not None
|
||||||
|
assert all(item.isVisible() for item in (patient, doctor, ai, system))
|
||||||
|
|
||||||
|
actions = {action.data(): action for action in dialog.chat_filter_menu.actions()}
|
||||||
|
actions["doctor"].trigger()
|
||||||
|
application.processEvents()
|
||||||
|
assert doctor.isVisible()
|
||||||
|
assert not patient.isVisible()
|
||||||
|
assert not ai.isVisible()
|
||||||
|
assert not system.isVisible()
|
||||||
|
|
||||||
|
new_doctor = dialog._append_bubble("doctor", "新的医生回复")
|
||||||
|
new_patient = dialog._append_bubble("patient", "新的患者提问")
|
||||||
|
application.processEvents()
|
||||||
|
assert new_doctor.isVisible()
|
||||||
|
assert not new_patient.isVisible()
|
||||||
|
|
||||||
|
actions["all"].trigger()
|
||||||
|
application.processEvents()
|
||||||
|
assert all(item.isVisible() for item in (patient, doctor, ai, system, new_doctor, new_patient))
|
||||||
|
dialog.tabs.setCurrentIndex(1)
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog.chat_filter_button.isVisible()
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.chat_filter_button.isVisible()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
||||||
def test_input_enter_submits_once_without_closing_dialog(
|
def test_input_enter_submits_once_without_closing_dialog(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
@@ -197,6 +247,52 @@ def test_prescription_action_without_permission_does_not_call_ai_or_repository(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("grants", "visible"),
|
||||||
|
[
|
||||||
|
(["tcm.diagnosis/aiAssistant"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/aiGeneratePrescription"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"], True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ai_prescription_quick_prompt_requires_both_permissions(
|
||||||
|
application: QApplication,
|
||||||
|
grants: list[str],
|
||||||
|
visible: bool,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(DemoDoctorRepository(), PermissionSet(grants))
|
||||||
|
buttons = dialog.findChildren(QPushButton, "AiConsultChipButton")
|
||||||
|
assert ("AI 生成处方" in [button.text() for button in buttons]) is visible
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescription_action_with_write_permission_but_without_ai_grant_is_blocked(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
class Repository:
|
||||||
|
def create_prescription(self, _payload: Any) -> None:
|
||||||
|
raise AssertionError("must not create without AI prescription permission")
|
||||||
|
|
||||||
|
def generate_ai_prescription(self, _diagnosis_id: int) -> None:
|
||||||
|
raise AssertionError("must not generate without AI prescription permission")
|
||||||
|
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
Repository(),
|
||||||
|
PermissionSet(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]),
|
||||||
|
)
|
||||||
|
dialog.diagnosis_id = 501
|
||||||
|
dialog._ask("开个处方")
|
||||||
|
application.processEvents()
|
||||||
|
bodies = [
|
||||||
|
browser.toPlainText()
|
||||||
|
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||||
|
]
|
||||||
|
assert any("没有 AI 生成处方权限" in body for body in bodies)
|
||||||
|
assert dialog._stream_worker is None
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
@@ -298,7 +394,7 @@ def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership
|
|||||||
dialog = AiConsultDialog(
|
dialog = AiConsultDialog(
|
||||||
Repository(),
|
Repository(),
|
||||||
PermissionSet(
|
PermissionSet(
|
||||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
|
||||||
),
|
),
|
||||||
parent=host,
|
parent=host,
|
||||||
)
|
)
|
||||||
@@ -366,7 +462,7 @@ def test_prescription_action_rejects_mismatched_patient_detail(
|
|||||||
dialog = AiConsultDialog(
|
dialog = AiConsultDialog(
|
||||||
Repository(),
|
Repository(),
|
||||||
PermissionSet(
|
PermissionSet(
|
||||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
dialog.diagnosis_id = 501
|
dialog.diagnosis_id = 501
|
||||||
@@ -617,6 +713,29 @@ def test_ai_consult_sidebar_loads_patient_facts_and_reports(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_server_analysis_is_not_presented_as_ai_diagnosis(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||||||
|
)
|
||||||
|
dialog._seed = {
|
||||||
|
"clinical_diagnosis": "糖尿病",
|
||||||
|
"fasting_blood_sugar": 11.2,
|
||||||
|
}
|
||||||
|
dialog._render_analysis({})
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
titles = [
|
||||||
|
widget.text()
|
||||||
|
for widget in dialog.findChildren(QLabel, "AiConsultRecordTitle")
|
||||||
|
]
|
||||||
|
assert titles == ["分析状态"]
|
||||||
|
assert not dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
@@ -1372,6 +1491,55 @@ def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stalled_stream_exits_pending_state_and_ignores_late_events(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
dialog._ask("请总结当前病情")
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
bubble = dialog._stream_bubble
|
||||||
|
worker = dialog._stream_worker
|
||||||
|
assert bubble is not None and worker is not None
|
||||||
|
assert dialog._stream_timeout_timer.isActive()
|
||||||
|
|
||||||
|
dialog._stream_timed_out()
|
||||||
|
assert worker.is_cancelled()
|
||||||
|
assert not dialog._stream_timeout_timer.isActive()
|
||||||
|
assert not dialog._asking
|
||||||
|
assert dialog.send_button.isEnabled()
|
||||||
|
assert bubble._raw_payload == ai_consult_module.AI_STREAM_TIMEOUT_TEXT
|
||||||
|
|
||||||
|
calls: list[int] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dialog.repository,
|
||||||
|
"stream_diagnosis_ai",
|
||||||
|
lambda *args, **kwargs: calls.append(1),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
worker.run()
|
||||||
|
assert calls == []
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到回复"})
|
||||||
|
dialog._stream_finished(generation, stream_generation, worker)
|
||||||
|
assert bubble._raw_payload == ai_consult_module.AI_STREAM_TIMEOUT_TEXT
|
||||||
|
|
||||||
|
dialog._ask("请补充说明")
|
||||||
|
second_bubble = dialog._stream_bubble
|
||||||
|
assert second_bubble is not None
|
||||||
|
dialog._stream_event(
|
||||||
|
dialog._generation,
|
||||||
|
dialog._stream_generation,
|
||||||
|
{"event": "delta", "text": "已收到部分分析"},
|
||||||
|
)
|
||||||
|
dialog._stream_timed_out()
|
||||||
|
assert second_bubble._raw_payload.startswith("已收到部分分析\n\n>")
|
||||||
|
assert ai_consult_module.AI_STREAM_TIMEOUT_TEXT in second_bubble._raw_payload
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_answered_stream_replaces_the_pending_placeholder(
|
def test_answered_stream_replaces_the_pending_placeholder(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -1382,15 +1550,29 @@ def test_answered_stream_replaces_the_pending_placeholder(
|
|||||||
|
|
||||||
dialog._ask("请总结当前病情")
|
dialog._ask("请总结当前病情")
|
||||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
assert dialog._stream_bubble is not None
|
||||||
|
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||||
|
dialog._stream_event(
|
||||||
|
generation,
|
||||||
|
stream_generation,
|
||||||
|
{"event": "start", "source_summary": {"patient_name": "敏感姓名"}},
|
||||||
|
)
|
||||||
|
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_WAITING_TEXT
|
||||||
|
assert "敏感姓名" not in dialog._stream_bubble._raw_payload
|
||||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
||||||
|
dialog._flush_timer.stop()
|
||||||
|
dialog._flush_stream_chunks()
|
||||||
|
assert dialog._stream_bubble._raw_payload == "证候:"
|
||||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
||||||
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
||||||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
assert dialog._stream_text == "证候:脾肾两虚"
|
assert dialog._stream_text == "证候:脾肾两虚"
|
||||||
|
assert not dialog._stream_timeout_timer.isActive()
|
||||||
texts = _bubble_texts(dialog)
|
texts = _bubble_texts(dialog)
|
||||||
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
||||||
|
assert not any(ai_consult_module.AI_STREAM_WAITING_TEXT in text for text in texts)
|
||||||
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
@@ -1522,6 +1704,49 @@ def test_sectioned_reply_becomes_a_structured_report(application: QApplication)
|
|||||||
bubble.deleteLater()
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_report_shows_recorded_patient_and_skill_sources(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
reply = (
|
||||||
|
"以下根据当前病历及历史随访资料作初步梳理,请医师结合原始记录复核所有结论,并确认记录时间与患者本人相符。\n"
|
||||||
|
"1. 病情判断\n- 当前资料尚不足以确认具体证型,仍需补充舌脉与检查资料。\n"
|
||||||
|
"2. 建议下一步\n- 请核对既往病历、血糖血压记录及随访记录,再决定后续检查与处置。\n"
|
||||||
|
)
|
||||||
|
commit = "270e94e637c4249dc411e54ac6715f21247de3cb"
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=reply)
|
||||||
|
bubble.set_provenance({
|
||||||
|
"source_diagnosis_ids": [501, 502],
|
||||||
|
"source_summary": {"diagnosis_count": 2, "blood_record_count": 3},
|
||||||
|
"knowledge_source": {
|
||||||
|
"title": "倪海厦 skill",
|
||||||
|
"url": f"https://github.com/coinweth/nihaixia-skill/blob/{commit}/SKILL.md",
|
||||||
|
"commit": commit,
|
||||||
|
"sections": ["六经速查表"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
card = bubble.findChild(ai_consult_module.QFrame, "AiConsultReportProvenance")
|
||||||
|
assert card is not None
|
||||||
|
lines = [label.text() for label in card.findChildren(QLabel)]
|
||||||
|
assert any("#501" in line and "#502" in line for line in lines)
|
||||||
|
assert any("诊单 2 条" in line and "血糖血压记录 3 条" in line for line in lines)
|
||||||
|
assert any("server/knowledge/nihaixia-skill/SKILL.md" in line and "版本 270e94e637c4" in line for line in lines)
|
||||||
|
assert all("https://github.com" not in line for line in lines)
|
||||||
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clinical_report_marks_unrecorded_provenance(application: QApplication) -> None:
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=LONG_CLINICAL_REPLY)
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
card = bubble.findChild(ai_consult_module.QFrame, "AiConsultReportProvenance")
|
||||||
|
assert card is not None
|
||||||
|
lines = [label.text() for label in card.findChildren(QLabel)]
|
||||||
|
assert any("未附可核查的来源清单" in line for line in lines)
|
||||||
|
assert any("未记录所用知识资料版本" in line for line in lines)
|
||||||
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"reply",
|
"reply",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
|||||||
release_launcher = read("packaging/windows/start_release.bat")
|
release_launcher = read("packaging/windows/start_release.bat")
|
||||||
|
|
||||||
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
||||||
|
assert run_script.index('Write-Host "Using current source tree:') < run_script.index('Write-Host "Using packaged application:')
|
||||||
assert "& $Uv sync --frozen" in run_script
|
assert "& $Uv sync --frozen" in run_script
|
||||||
assert '"UV_CACHE_DIR"' in run_script
|
assert '"UV_CACHE_DIR"' in run_script
|
||||||
assert "$ProjectUvCache" in run_script
|
assert "$ProjectUvCache" in run_script
|
||||||
|
|||||||
@@ -1335,6 +1335,34 @@ def test_patient_report_dialog_uses_one_scroll_owner_and_wrapped_risk_flow(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reception_report_displays_recorded_sources(application: QApplication) -> None:
|
||||||
|
commit = "270e94e637c4249dc411e54ac6715f21247de3cb"
|
||||||
|
payload = _snapshot("qwen", 2, "2026-10-08 15:00:00")
|
||||||
|
payload.update({
|
||||||
|
"source_diagnosis_ids": [501],
|
||||||
|
"source_summary": {"diagnosis_count": 1, "prescription_count": 2},
|
||||||
|
"knowledge_source": {
|
||||||
|
"url": f"https://github.com/coinweth/nihaixia-skill/blob/{commit}/SKILL.md",
|
||||||
|
"commit": commit,
|
||||||
|
"sections": ["六经速查表"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dialog = _ReceptionAiAnalysisDialog({"qwen": [payload]})
|
||||||
|
assert "#501" in dialog.source_label.text()
|
||||||
|
assert "历史处方 2 条" in dialog.source_label.text()
|
||||||
|
assert "六经速查表" in dialog.source_label.text()
|
||||||
|
assert "server/knowledge/nihaixia-skill/SKILL.md" in dialog.source_label.text()
|
||||||
|
assert "https://github.com" not in dialog.source_label.text()
|
||||||
|
|
||||||
|
page = ReceptionPage(object(), PermissionSet([]))
|
||||||
|
page._render_ai_analysis_payload(payload, "qwen")
|
||||||
|
assert "报告出处" in page.ai_source_label.text()
|
||||||
|
assert "#501" in page.ai_source_label.text()
|
||||||
|
assert "https://github.com" not in page.ai_source_label.text()
|
||||||
|
dialog.close()
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
def test_reception_ai_card_is_compact_preview_without_nested_scroll(
|
def test_reception_ai_card_is_compact_preview_without_nested_scroll(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ def test_diagnosis_dialog_renders_saved_case_report(
|
|||||||
assert dialog.snapshot_caption.text() == "完整病历"
|
assert dialog.snapshot_caption.text() == "完整病历"
|
||||||
assert "肝郁脾虚证" in dialog.snapshot_body.text()
|
assert "肝郁脾虚证" in dialog.snapshot_body.text()
|
||||||
assert any("核心判断" in text for text in labels)
|
assert any("核心判断" in text for text in labels)
|
||||||
|
assert any("报告出处" in text and "关联诊单:#501" in text for text in labels)
|
||||||
assert dialog.can_refresh is True
|
assert dialog.can_refresh is True
|
||||||
assert not dialog.generate_button.isHidden()
|
assert not dialog.generate_button.isHidden()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from doctor_workstation.ui.report_provenance import report_provenance_lines
|
||||||
|
|
||||||
|
_COMMIT = "db0155dc7c42b9c6b3736896661f317c7110038f"
|
||||||
|
_URL = (
|
||||||
|
"https://github.com/xiaopangxia/TCM-Ancient-Books/blob/"
|
||||||
|
f"{_COMMIT}/123-%E6%B5%8B%E8%AF%95%E5%8F%A4%E7%B1%8D.txt#L42"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_ancient_book_reference_has_title_chapter_and_pinned_line() -> None:
|
||||||
|
lines = report_provenance_lines(
|
||||||
|
{
|
||||||
|
"prompt_version": "patient-longitudinal-report-nihaixia-ancient-v4",
|
||||||
|
"ancient_book_sources": [
|
||||||
|
{
|
||||||
|
"title": "测试古籍",
|
||||||
|
"chapter": "消渴篇",
|
||||||
|
"path": "123-测试古籍.txt",
|
||||||
|
"line": 42,
|
||||||
|
"excerpt": "消渴者,当辨其证。",
|
||||||
|
"url": _URL,
|
||||||
|
"commit": _COMMIT,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"《测试古籍》「消渴篇」" in line
|
||||||
|
and "server/knowledge/tcm-ancient-books/123-测试古籍.txt 第42行" in line
|
||||||
|
and "版本 db0155dc7c42" in line
|
||||||
|
and "摘录:消渴者,当辨其证。" in line
|
||||||
|
for line in lines
|
||||||
|
)
|
||||||
|
assert "https://github.com" not in "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_locally_bundled_sources_render_without_external_urls() -> None:
|
||||||
|
lines = report_provenance_lines({
|
||||||
|
"prompt_version": "patient-longitudinal-report-nihaixia-ancient-v4",
|
||||||
|
"knowledge_source": {
|
||||||
|
"commit": "270e94e637c4249dc411e54ac6715f21247de3cb",
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"url": "",
|
||||||
|
},
|
||||||
|
"ancient_book_sources": [{
|
||||||
|
"title": "测试古籍",
|
||||||
|
"chapter": "消渴篇",
|
||||||
|
"path": "123-测试古籍.txt",
|
||||||
|
"line": 42,
|
||||||
|
"excerpt": "消渴者,当辨其证。",
|
||||||
|
"commit": _COMMIT,
|
||||||
|
"url": "",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
assert any("server/knowledge/nihaixia-skill/SKILL.md" in line for line in lines)
|
||||||
|
assert any("server/knowledge/tcm-ancient-books/123-测试古籍.txt 第42行" in line for line in lines)
|
||||||
|
assert "https://github.com" not in "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ancient_book_reference_rejects_conflicting_local_metadata() -> None:
|
||||||
|
lines = report_provenance_lines({
|
||||||
|
"ancient_book_sources": [
|
||||||
|
{"title": "测试古籍", "path": "别的书.txt", "line": 42, "url": _URL, "commit": _COMMIT},
|
||||||
|
{"title": "测试古籍", "path": "123-测试古籍.txt", "line": 43, "url": _URL, "commit": _COMMIT},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert all("古籍参考:《" not in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_or_unverified_report_does_not_gain_ancient_citation() -> None:
|
||||||
|
lines = report_provenance_lines(
|
||||||
|
{
|
||||||
|
"prompt_version": "patient-longitudinal-report-nihaixia-v3",
|
||||||
|
"ancient_book_sources": [
|
||||||
|
{"title": "伪造", "url": _URL.replace(_COMMIT, "0" * 40), "commit": _COMMIT}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert all("古籍参考:《" not in line for line in lines)
|
||||||
@@ -8,7 +8,13 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiProtocolError
|
from doctor_workstation.core.errors import (
|
||||||
|
ApiBusinessError,
|
||||||
|
ApiHttpError,
|
||||||
|
ApiProtocolError,
|
||||||
|
ApiTimeoutError,
|
||||||
|
ApiTransportError,
|
||||||
|
)
|
||||||
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
||||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||||
from doctor_workstation.services.repository import (
|
from doctor_workstation.services.repository import (
|
||||||
@@ -774,10 +780,11 @@ def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
|
|||||||
assert client.post_calls == []
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None:
|
@pytest.mark.parametrize("status_code", [404, 405])
|
||||||
|
def test_remote_diagnosis_ai_stream_falls_back_for_missing_route(status_code: int) -> None:
|
||||||
class MissingStreamClient(RecordingClient):
|
class MissingStreamClient(RecordingClient):
|
||||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
raise ApiHttpError("missing", status_code=404)
|
raise ApiHttpError("missing", status_code=status_code)
|
||||||
|
|
||||||
missing_client = MissingStreamClient()
|
missing_client = MissingStreamClient()
|
||||||
events = list(
|
events = list(
|
||||||
@@ -789,6 +796,57 @@ def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() ->
|
|||||||
"tcm.diagnosis/aiAssistant"
|
"tcm.diagnosis/aiAssistant"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"stream_error",
|
||||||
|
[
|
||||||
|
ApiHttpError("server error", status_code=500),
|
||||||
|
ApiTimeoutError("timed out"),
|
||||||
|
ApiTransportError("disconnected"),
|
||||||
|
ApiProtocolError("invalid event stream"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_remote_diagnosis_ai_stream_failures_do_not_issue_full_request(
|
||||||
|
stream_error: Exception,
|
||||||
|
) -> None:
|
||||||
|
class FailedStreamClient(RecordingClient):
|
||||||
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
|
raise stream_error
|
||||||
|
|
||||||
|
client = FailedStreamClient()
|
||||||
|
with pytest.raises(type(stream_error)):
|
||||||
|
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
|
||||||
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("after_start", [False, True])
|
||||||
|
def test_remote_diagnosis_ai_stream_incomplete_response_does_not_fall_back(
|
||||||
|
after_start: bool,
|
||||||
|
) -> None:
|
||||||
|
class IncompleteStreamClient(RecordingClient):
|
||||||
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
|
if after_start:
|
||||||
|
yield {"event": "start", "data": {"model_key": "qwen"}}
|
||||||
|
|
||||||
|
client = IncompleteStreamClient()
|
||||||
|
with pytest.raises(ApiProtocolError, match="ended before done"):
|
||||||
|
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
|
||||||
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_diagnosis_ai_stream_does_not_fall_back_after_start() -> None:
|
||||||
|
class FailedAfterStartClient(RecordingClient):
|
||||||
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
|
yield {"event": "start", "data": {"model_key": "qwen"}}
|
||||||
|
raise ApiHttpError("missing", status_code=404)
|
||||||
|
|
||||||
|
client = FailedAfterStartClient()
|
||||||
|
with pytest.raises(ApiHttpError):
|
||||||
|
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
|
||||||
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_diagnosis_ai_stream_error_event_does_not_fall_back() -> None:
|
||||||
class ErrorStreamClient(RecordingClient):
|
class ErrorStreamClient(RecordingClient):
|
||||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
yield {"event": "error", "data": {"message": "模型繁忙"}}
|
yield {"event": "error", "data": {"message": "模型繁忙"}}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.appointment_drawer import AppointmentDrawer
|
||||||
|
from doctor_workstation.ui.dialogs.ai_consult_picker import AiConsultTargetDialog
|
||||||
|
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer
|
||||||
|
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||||
|
from doctor_workstation.ui.shell import ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import _install_business_dialog_styling
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_dialog_and_im_window_use_85_percent_of_main_window() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.setGeometry(40, 60, 1200, 800)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
dialog = QDialog(main)
|
||||||
|
dialog.setMinimumSize(1100, 700)
|
||||||
|
im_window = QMainWindow()
|
||||||
|
im_window.resize(1120, 760)
|
||||||
|
try:
|
||||||
|
for window in (dialog, im_window):
|
||||||
|
window.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert window.size().width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert window.size().height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
assert abs(window.frameGeometry().center().x() - main.frameGeometry().center().x()) <= 1
|
||||||
|
assert abs(window.frameGeometry().center().y() - main.frameGeometry().center().y()) <= 1
|
||||||
|
window.hide()
|
||||||
|
main.resize(1400, 900)
|
||||||
|
app.processEvents()
|
||||||
|
for window in (dialog, im_window):
|
||||||
|
window.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert window.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert window.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
finally:
|
||||||
|
im_window.close()
|
||||||
|
dialog.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_frameless_drawer_keeps_its_owner_layout() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.resize(1200, 800)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
drawer = QDialog(main)
|
||||||
|
drawer.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||||
|
drawer.resize(500, 400)
|
||||||
|
try:
|
||||||
|
drawer.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert drawer.size().width() == 500
|
||||||
|
assert drawer.size().height() == 400
|
||||||
|
finally:
|
||||||
|
drawer.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_exec_uses_main_window_geometry() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.setGeometry(30, 50, 1200, 800)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
dialog = QDialog(main)
|
||||||
|
try:
|
||||||
|
QTimer.singleShot(0, dialog.accept)
|
||||||
|
dialog.exec()
|
||||||
|
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.setGeometry(30, 50, 1400, 900)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
dialogs = [
|
||||||
|
AppointmentDrawer({"id": 501, "diagnosis_id": 501}, repository, main, autoload=False),
|
||||||
|
DiagnosisDialog(repository, main),
|
||||||
|
OrderDetailDrawer(main),
|
||||||
|
PrescriptionEditorDialog(repository, parent=main),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for dialog in dialogs:
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
dialog.hide()
|
||||||
|
finally:
|
||||||
|
for dialog in dialogs:
|
||||||
|
dialog.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_maximized_workstation_opens_patient_picker_at_85_percent() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
permissions = PermissionSet(["*"])
|
||||||
|
main = ShellWindow(
|
||||||
|
repository,
|
||||||
|
{"user": {"name": "测试医生"}, "demo_mode": True},
|
||||||
|
permissions,
|
||||||
|
)
|
||||||
|
main.showMaximized()
|
||||||
|
app.processEvents()
|
||||||
|
picker = AiConsultTargetDialog(repository, permissions, main)
|
||||||
|
try:
|
||||||
|
picker.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert main.isMaximized()
|
||||||
|
assert picker.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert picker.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
finally:
|
||||||
|
picker.close()
|
||||||
|
main.close()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
*
|
||||||
|
!Dockerfile
|
||||||
|
!audio-requirements.txt
|
||||||
|
!cpu_preflight.py
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
acceptance/
|
||||||
|
|
||||||
|
.gpu1-mode*
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Copying this placeholder is insufficient: init-env.py creates random secret .env.
|
||||||
|
ASR_IMAGE=sha256:REPLACE_WITH_BUILT_IMAGE_ID
|
||||||
|
ASR_API_KEY=REPLACE_WITH_RANDOM_SECRET
|
||||||
|
ASR_GPU_MEMORY_UTILIZATION=0.15
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
image-id.txt
|
||||||
|
fixtures/
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
acceptance/
|
||||||
|
|
||||||
|
.gpu1-mode*
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ARG BASE_IMAGE=local/qwen-asr-base:20261008-251eba5cc7c1
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
LABEL org.opencontainers.image.description="Isolated Qwen3-ASR audio dependencies" \
|
||||||
|
local.base_image_id="sha256:251eba5cc7c12fed0b75da22a9240e582b1c9e39f6fbc064f86781b963bd814f"
|
||||||
|
COPY audio-requirements.txt /opt/qwen-asr/audio-requirements.txt
|
||||||
|
RUN python3 -m pip install --no-cache-dir --no-deps --only-binary=:all: -r /opt/qwen-asr/audio-requirements.txt
|
||||||
|
COPY cpu_preflight.py /opt/qwen-asr/cpu_preflight.py
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Qwen3-ASR isolated preparation package
|
||||||
|
|
||||||
|
This package prepares a **separate** ASR image and immutable model snapshot. It does
|
||||||
|
not change, replace, stop, or restart the existing `qwen36-35b` deployment.
|
||||||
|
No GPU process is started by `download_model.py`, `build-image.sh`, or `init-env.py`.
|
||||||
|
|
||||||
|
## Fixed identities
|
||||||
|
|
||||||
|
- Base image ID: `sha256:251eba5cc7c12fed0b75da22a9240e582b1c9e39f6fbc064f86781b963bd814f`
|
||||||
|
- Base: vLLM 0.24.0, torch 2.11.0+cu130, transformers 5.12.1, numpy 2.2.6.
|
||||||
|
- Official model: `Qwen/Qwen3-ASR-1.7B`.
|
||||||
|
- Official ModelScope snapshot: `a04930dbe5419bfee073f7cade734f572689a3a8`.
|
||||||
|
- Weights total 4,698,521,512 bytes; do not use the earlier ~3.5GB estimate.
|
||||||
|
- New service root: `/home/www/qwen-vllm/asr`.
|
||||||
|
- Model root: `/home/www/qwen-vllm/asr/models/Qwen3-ASR-1.7B`.
|
||||||
|
- `DOWNLOAD_MANIFEST.json` preserves official SHA256 and size for every payload.
|
||||||
|
- Runtime image is pinned to the built immutable image ID in private `.env`.
|
||||||
|
|
||||||
|
The existing base contains the Qwen3ASR model registry and transcription implementation
|
||||||
|
but lacks audio extras. The independent thin image adds only `av`, `scipy`,
|
||||||
|
`soundfile`, and `soxr` with explicit versions and `--no-deps`; it preserves torch,
|
||||||
|
transformers, numpy and the original image bytes. CPU preflight performs actual WAV
|
||||||
|
encode/decode, resampling, and Whisper feature extraction plus static route checks.
|
||||||
|
This is **not** GPU inference or endpoint acceptance.
|
||||||
|
|
||||||
|
## Prepare without GPU execution
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/www/qwen-vllm/asr
|
||||||
|
python3 download_model.py /home/www/qwen-vllm/asr/models/Qwen3-ASR-1.7B
|
||||||
|
./build-image.sh
|
||||||
|
python3 init-env.py
|
||||||
|
docker compose --env-file .env -f compose.yaml config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
`init-env.py` creates a random API key in a new mode-0600 `.env`, refuses to overwrite
|
||||||
|
an existing file, and never prints the key. Do not publish `.env`, full compose
|
||||||
|
config output, container environment, or API Authorization headers.
|
||||||
|
|
||||||
|
## Start only after the project controller has released GPU1
|
||||||
|
|
||||||
|
**The controller must first verify that Qwen is healthy on GPU0 alone, GPU1 has no
|
||||||
|
competing process, and the shared-card guard permits ASR. Preparation is not this gate.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/www/qwen-vllm/asr
|
||||||
|
docker compose --env-file .env -f compose.yaml up -d --no-build --pull never asr
|
||||||
|
```
|
||||||
|
|
||||||
|
Host GPU1 alone is exposed via Docker DeviceIDs `["1"]`; it appears as logical
|
||||||
|
CUDA device 0 inside the one-GPU container. Host port is only `127.0.0.1:8001`.
|
||||||
|
`restart: unless-stopped` supports reboot persistence without reactivating an
|
||||||
|
explicitly stopped service. Initial `gpu_memory_utilization=0.15` is adjustable
|
||||||
|
in `.env`; do not increase without measuring allocation and the shared-card budget.
|
||||||
|
`max-num-seqs=1`, `max-model-len=32768`, eager execution and max batched tokens 4096
|
||||||
|
bound the initial test workload. The model native text context is 65536. The installed
|
||||||
|
vLLM audio encoder maps about 13 audio tokens/second: 745 seconds is about 9685
|
||||||
|
audio tokens before output, so 4096 is not safe for an unsplit 7–12 minute input.
|
||||||
|
A 32768-token BF16 KV cache is approximately 3.5 GiB from the model configuration
|
||||||
|
(28 layers, 8 KV heads, head dimension 128); this is an estimate, not measured VRAM.
|
||||||
|
Keep the 0.15 allocation cap and verify actual startup/long-audio behavior. The
|
||||||
|
OpenAI transcription implementation has internal clip splitting based on the
|
||||||
|
feature extractor's 30-second clip limit, but the application should still segment
|
||||||
|
recordings into at most 5-minute bounded jobs with overlap and ordered merging.
|
||||||
|
An hour-long recording must be split; this package does not claim a one-hour
|
||||||
|
single-request acceptance. Long calls need a separate chunking/quality gate.
|
||||||
|
No forced aligner is loaded; word timestamps are not promised.
|
||||||
|
|
||||||
|
## Verify after controller start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/www/qwen-vllm/asr
|
||||||
|
python3 probe.py /absolute/path/to/non-sensitive-speech.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
The probe requires `/health` HTTP 200, rejects unauthenticated `/v1/models` with
|
||||||
|
401, confirms the served model with authentication, and requires nonempty text
|
||||||
|
from an actual multipart `/v1/audio/transcriptions` request. The authenticated
|
||||||
|
probe reads the key locally and does not print it. A public or synthetic fixture is
|
||||||
|
required; never use patient recordings for this gate. Recognition accuracy needs
|
||||||
|
an expected-text comparison/listening in addition to a successful HTTP response.
|
||||||
|
|
||||||
|
## Stop / rollback only the new ASR service
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/www/qwen-vllm/asr
|
||||||
|
docker compose --env-file .env -f compose.yaml stop asr
|
||||||
|
```
|
||||||
|
|
||||||
|
This releases ASR's GPU allocation while retaining model/image/config artifacts.
|
||||||
|
Do not start ComfyUI on GPU1 until the parent-controlled shared-card guard has
|
||||||
|
confirmed ASR stopped and memory released. ComfyUI is not deployed by this package.
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
- https://github.com/QwenLM/Qwen3-ASR#deployment-with-vllm
|
||||||
|
- https://modelscope.cn/models/Qwen/Qwen3-ASR-1.7B
|
||||||
|
- https://huggingface.co/Qwen/Qwen3-ASR-1.7B
|
||||||
|
|
||||||
|
|
||||||
|
## GPU 1 与未来 ComfyUI 测试的互斥使用
|
||||||
|
|
||||||
|
在 ai 服务器运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /home/www/qwen-vllm/asr/gpu1-mode.py status
|
||||||
|
# 等待 ASR 在途/排队请求结束并停止它,确认 GPU 1 空闲;不会启动或停止 ComfyUI。
|
||||||
|
python3 /home/www/qwen-vllm/asr/gpu1-mode.py test
|
||||||
|
# 测试实例完全退出、GPU 1 空闲后恢复 ASR,并等待健康检查。
|
||||||
|
python3 /home/www/qwen-vllm/asr/gpu1-mode.py asr
|
||||||
|
```
|
||||||
|
|
||||||
|
控制器遇到未知 GPU 1 进程会拒绝操作,不强杀。ComfyUI 测试实例尚未创建;未来应显式绑定 GPU 1,不修改当前 GPU 2/3 上的服务。请通过此控制器切换,而非同时启动两个占卡服务。`unless-stopped` 保留手工停止状态,新服务同时设置 600 秒引擎优雅关闭和 660 秒容器停止宽限。
|
||||||
|
|
||||||
|
Qwen 单卡切换的原始配置、候选配置、合成文本/长文本/图片检查与回滚控制器保存在 ai 的 `/home/www/qwen-vllm/rollouts/20261008-single-gpu/`。需要回退双卡时,先释放 GPU 1,再运行该目录的 `deploy.py rollback`;它拒绝覆盖不认识的配置或驱逐 GPU 1 上的其他进程。此操作会重启 Qwen,需维护窗口。源码回滚和服务器运行状态回滚不是同一件事。
|
||||||
|
|
||||||
|
控制器无 GPU 单元检查:`python3 deployment/followup-audio-asr/test_gpu1_mode.py`。真实启动、转录和卡交接结果请以本次部署证据为准,不把 CPU-only 准备检查当 GPU 运行成功。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Small isolated audio layer; base torch/transformers/numpy must not be replaced.
|
||||||
|
av==16.0.1
|
||||||
|
scipy==1.16.3
|
||||||
|
soundfile==0.13.1
|
||||||
|
soxr==1.0.0
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
base=sha256:251eba5cc7c12fed0b75da22a9240e582b1c9e39f6fbc064f86781b963bd814f
|
||||||
|
base_tag=local/qwen-asr-base:20261008-251eba5cc7c1
|
||||||
|
actual=$(docker image inspect "$base" --format '{{.Id}}')
|
||||||
|
[ "$actual" = "$base" ] || { echo 'Base image mismatch' >&2; exit 1; }
|
||||||
|
docker tag "$base" "$base_tag"
|
||||||
|
docker build --pull=false --build-arg BASE_IMAGE="$base_tag" -t local/qwen-asr:20261008 .
|
||||||
|
docker image inspect local/qwen-asr:20261008 --format '{{.Id}}' > image-id.txt
|
||||||
|
printf 'ASR_IMAGE='; cat image-id.txt
|
||||||
|
# Explicitly no --gpus. This checks imports and signal processing only.
|
||||||
|
docker run --rm --network none -e NVIDIA_VISIBLE_DEVICES=void --entrypoint python3 "$(cat image-id.txt)" /opt/qwen-asr/cpu_preflight.py
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: followup-audio-asr
|
||||||
|
services:
|
||||||
|
asr:
|
||||||
|
image: ${ASR_IMAGE:?Run build-image.sh and init-env.py first}
|
||||||
|
pull_policy: never
|
||||||
|
container_name: followup-audio-asr
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 660s
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8001:8000"
|
||||||
|
environment:
|
||||||
|
CUDA_VISIBLE_DEVICES: "0"
|
||||||
|
VLLM_API_KEY: ${ASR_API_KEY:?Initialize the private .env first}
|
||||||
|
HF_HUB_OFFLINE: "1"
|
||||||
|
TRANSFORMERS_OFFLINE: "1"
|
||||||
|
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||||
|
DO_NOT_TRACK: "1"
|
||||||
|
VLLM_NO_USAGE_STATS: "1"
|
||||||
|
OMP_NUM_THREADS: "4"
|
||||||
|
volumes:
|
||||||
|
- /home/www/qwen-vllm/asr/models/Qwen3-ASR-1.7B:/model:ro
|
||||||
|
shm_size: "2gb"
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["1"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
command:
|
||||||
|
- /model
|
||||||
|
- --served-model-name
|
||||||
|
- Qwen/Qwen3-ASR-1.7B
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "8000"
|
||||||
|
- --dtype
|
||||||
|
- bfloat16
|
||||||
|
- --tensor-parallel-size
|
||||||
|
- "1"
|
||||||
|
- --gpu-memory-utilization
|
||||||
|
- ${ASR_GPU_MEMORY_UTILIZATION:-0.15}
|
||||||
|
- --max-num-seqs
|
||||||
|
- "1"
|
||||||
|
- --max-model-len
|
||||||
|
- "32768"
|
||||||
|
- --max-num-batched-tokens
|
||||||
|
- "4096"
|
||||||
|
- --shutdown-timeout
|
||||||
|
- "600"
|
||||||
|
- --enforce-eager
|
||||||
|
- --no-enable-log-requests
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, python3, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 8s
|
||||||
|
start_period: 180s
|
||||||
|
retries: 6
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CPU-only dependency, format and route checks. Does not instantiate a model."""
|
||||||
|
import importlib.metadata as metadata
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import soxr
|
||||||
|
import scipy
|
||||||
|
import av
|
||||||
|
import vllm
|
||||||
|
import torch
|
||||||
|
from transformers import WhisperFeatureExtractor
|
||||||
|
|
||||||
|
signal=np.zeros(16000,dtype=np.float32)
|
||||||
|
encoded=io.BytesIO()
|
||||||
|
sf.write(encoded,signal,16000,format='WAV')
|
||||||
|
encoded.seek(0)
|
||||||
|
decoded,rate=sf.read(encoded,dtype='float32')
|
||||||
|
assert rate==16000 and decoded.shape==(16000,)
|
||||||
|
resampled=soxr.resample(decoded,16000,8000)
|
||||||
|
assert len(resampled)==8000
|
||||||
|
features=WhisperFeatureExtractor()(decoded,sampling_rate=rate,return_tensors='np')
|
||||||
|
assert features['input_features'].shape[0]==1
|
||||||
|
root=Path(vllm.__file__).parent
|
||||||
|
model_source=(root/'model_executor/models/qwen3_asr.py').read_text()
|
||||||
|
registry_source=(root/'model_executor/models/registry.py').read_text()
|
||||||
|
assert 'Qwen3ASRForConditionalGeneration' in registry_source
|
||||||
|
assert 'SupportsTranscription' in model_source
|
||||||
|
routes=[str(path.relative_to(root)) for path in (root/'entrypoints').rglob('*.py') if '"/v1/audio/transcriptions"' in path.read_text()]
|
||||||
|
assert routes, 'Transcription route missing'
|
||||||
|
assert not Path('/dev/nvidia0').exists(), 'This test must not have GPU device access'
|
||||||
|
assert vllm.__version__=='0.24.0'
|
||||||
|
assert metadata.version('numpy')=='2.2.6'
|
||||||
|
assert metadata.version('torch')=='2.11.0+cu130'
|
||||||
|
assert metadata.version('transformers')=='5.12.1'
|
||||||
|
print(json.dumps({'event':'cpu_preflight_pass','versions':{name:metadata.version(name) for name in ['vllm','torch','transformers','numpy','soundfile','scipy','soxr','av']},'wav_roundtrip_shape':list(decoded.shape),'resampled_shape':list(resampled.shape),'feature_shape':list(features['input_features'].shape),'transcription_route_files':routes,'gpu_device_access':False}))
|
||||||
|
|
||||||
|
# Validate only the installed CLI option declarations. Constructing the full vLLM
|
||||||
|
# config on a no-GPU preflight host would try to infer a device.
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
from vllm.engine.arg_utils import AsyncEngineArgs
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
tree = ast.parse(Path(inspect.getfile(AsyncEngineArgs)).read_text())
|
||||||
|
found = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||||
|
and isinstance(node.func.value, ast.Name) and node.func.value.id == "parser"
|
||||||
|
and node.func.attr == "add_argument" and node.args
|
||||||
|
and isinstance(node.args[0], ast.Constant)
|
||||||
|
and node.args[0].value in {"--enable-log-requests", "--shutdown-timeout"}):
|
||||||
|
eval(compile(ast.Expression(node), "<installed-option-declaration>", "eval"),
|
||||||
|
{"parser": parser, "argparse": argparse, "AsyncEngineArgs": AsyncEngineArgs, "int": int})
|
||||||
|
found.add(node.args[0].value)
|
||||||
|
assert len(found) == 2
|
||||||
|
args = parser.parse_args(["--no-enable-log-requests", "--shutdown-timeout", "600"])
|
||||||
|
assert args.enable_log_requests is False and args.shutdown_timeout == 600
|
||||||
|
print("CLI_PRIVACY_AND_SHUTDOWN_DECLARATIONS_PASS")
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download a pinned official ModelScope snapshot; never replace mismatched files."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MODEL = 'Qwen/Qwen3-ASR-1.7B'
|
||||||
|
REVISION = 'a04930dbe5419bfee073f7cade734f572689a3a8'
|
||||||
|
ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else '/home/www/qwen-vllm/asr/models/Qwen3-ASR-1.7B')
|
||||||
|
API = f'https://modelscope.cn/api/v1/models/{MODEL}/repo'
|
||||||
|
|
||||||
|
def digest(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open('rb') as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b''):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
url = API + '/files?' + urllib.parse.urlencode({'Revision': REVISION, 'Recursive': 'true'})
|
||||||
|
with urllib.request.urlopen(url, timeout=60) as response:
|
||||||
|
listing = json.load(response)
|
||||||
|
assert listing['Code'] == 200 and listing['Success'], listing
|
||||||
|
files = [x for x in listing['Data']['Files'] if x['Type'] == 'blob' and x['Path'] != '.gitattributes']
|
||||||
|
manifest = {'provider': 'ModelScope', 'model': MODEL, 'revision': REVISION, 'api': url, 'files': files}
|
||||||
|
manifest_path = ROOT / 'DOWNLOAD_MANIFEST.json'
|
||||||
|
if manifest_path.exists():
|
||||||
|
old = json.loads(manifest_path.read_text())
|
||||||
|
assert old == manifest, 'Existing manifest differs; preserve and abort'
|
||||||
|
else:
|
||||||
|
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
|
||||||
|
for row in files:
|
||||||
|
relative = Path(row['Path'])
|
||||||
|
assert not relative.is_absolute() and '..' not in relative.parts
|
||||||
|
path = ROOT / relative
|
||||||
|
if path.exists():
|
||||||
|
assert path.stat().st_size == row['Size'] and digest(path) == row['Sha256'], f'Existing file mismatch: {path}'
|
||||||
|
print(json.dumps({'event': 'download_reused', 'path': str(path), 'sha256': row['Sha256'], 'bytes': row['Size']}), flush=True)
|
||||||
|
continue
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
partial = path.with_name(path.name + '.partial')
|
||||||
|
# A partial file is only a disposable incomplete transfer created by this script.
|
||||||
|
target = API + '?' + urllib.parse.urlencode({'Revision': REVISION, 'FilePath': row['Path']})
|
||||||
|
print(json.dumps({'event': 'download_started', 'file': row['Path'], 'bytes': row['Size'], 'revision': REVISION}), flush=True)
|
||||||
|
h = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with urllib.request.urlopen(target, timeout=120) as source, partial.open('wb') as output:
|
||||||
|
while True:
|
||||||
|
chunk = source.read(8 * 1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output.write(chunk)
|
||||||
|
h.update(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
assert size == row['Size'] and h.hexdigest() == row['Sha256'], f'Download integrity mismatch: {path}'
|
||||||
|
os.replace(partial, path)
|
||||||
|
print(json.dumps({'event': 'download_verified', 'path': str(path), 'sha256': h.hexdigest(), 'bytes': size}), flush=True)
|
||||||
|
config = json.loads((ROOT / 'config.json').read_text())
|
||||||
|
assert config['architectures'] == ['Qwen3ASRForConditionalGeneration'], config['architectures']
|
||||||
|
weights = json.loads((ROOT / 'model.safetensors.index.json').read_text())
|
||||||
|
assert set(weights['weight_map'].values()) <= {x['Path'] for x in files}
|
||||||
|
# Public model weights must remain readable through a read-only bind mount with cap_drop=ALL.
|
||||||
|
# This does not change the private service .env or acceptance directory permissions.
|
||||||
|
ROOT.chmod(0o755)
|
||||||
|
manifest_path.chmod(0o644)
|
||||||
|
for row in files:
|
||||||
|
(ROOT / row['Path']).chmod(0o644)
|
||||||
|
print(json.dumps({'event': 'model_ready', 'model': MODEL, 'revision': REVISION, 'manifest_sha256': digest(manifest_path), 'total_bytes': sum(x['Size'] for x in files), 'architectures': config['architectures'], 'model_type': config.get('model_type'), 'dtype': config.get('torch_dtype', config.get('dtype')), 'weight_metadata': weights['metadata']}), flush=True)
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exclusive operational handoff: ASR on GPU1, or GPU1 released for future tests.
|
||||||
|
Never starts/stops ComfyUI, never kills unknown GPU processes, never prints API keys.
|
||||||
|
"""
|
||||||
|
import argparse,fcntl,json,pathlib,subprocess,time,urllib.request
|
||||||
|
ROOT=pathlib.Path(__file__).resolve().parent
|
||||||
|
CONTAINER='followup-audio-asr'
|
||||||
|
|
||||||
|
def command(args):
|
||||||
|
p=subprocess.run(args,capture_output=True,text=True,timeout=700)
|
||||||
|
if p.returncode: raise RuntimeError('COMMAND_FAILED: '+args[0])
|
||||||
|
return p.stdout
|
||||||
|
|
||||||
|
def gpu_pids():
|
||||||
|
text=command(['nvidia-smi','--id=1','--query-compute-apps=pid','--format=csv,noheader,nounits'])
|
||||||
|
return {int(line.strip()) for line in text.splitlines() if line.strip()}
|
||||||
|
|
||||||
|
def container_state():
|
||||||
|
p=subprocess.run(['docker','inspect','--format','{{json .State}}',CONTAINER],capture_output=True,text=True)
|
||||||
|
if p.returncode:
|
||||||
|
if 'no such object' in p.stderr.lower() or 'no such container' in p.stderr.lower():return {'Running':False,'Status':'absent'}
|
||||||
|
raise RuntimeError('CONTAINER_STATE_UNKNOWN')
|
||||||
|
return json.loads(p.stdout)
|
||||||
|
|
||||||
|
def owned_pids(state):
|
||||||
|
if not state.get('Running'):return set()
|
||||||
|
lines=command(['docker','top',CONTAINER,'-eo','pid']).splitlines()[1:]
|
||||||
|
return {int(line.strip()) for line in lines if line.strip()}
|
||||||
|
|
||||||
|
def ensure_exclusive(active,owned):
|
||||||
|
if active-owned: raise RuntimeError('GPU1_HAS_OTHER_WORKLOAD: refusing to stop or replace it')
|
||||||
|
|
||||||
|
def idle_values(body):
|
||||||
|
values=[]
|
||||||
|
for name in ['vllm:num_requests_running','vllm:num_requests_waiting']:
|
||||||
|
rows=[float(l.rsplit(' ',1)[1]) for l in body.splitlines() if l.startswith(name+'{')]
|
||||||
|
if not rows:raise RuntimeError('ASR_DRAIN_METRICS_MISSING')
|
||||||
|
values.append(sum(rows))
|
||||||
|
return values
|
||||||
|
|
||||||
|
def drain():
|
||||||
|
zeros=0;deadline=time.monotonic()+600
|
||||||
|
while time.monotonic()<deadline:
|
||||||
|
with urllib.request.urlopen('http://127.0.0.1:8001/metrics',timeout=5) as r: values=idle_values(r.read().decode())
|
||||||
|
zeros=zeros+1 if values==[0,0] else 0
|
||||||
|
if zeros>=3:return
|
||||||
|
time.sleep(5)
|
||||||
|
raise RuntimeError('ASR_BUSY: unchanged; wait for requests to finish')
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('mode',choices=['status','asr','test']);args=p.parse_args()
|
||||||
|
lock=open(ROOT/'.gpu1-mode.lock','a');fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||||
|
state=container_state();active=gpu_pids();owned=owned_pids(state)
|
||||||
|
if args.mode=='status':
|
||||||
|
print(json.dumps({'asr_status':state['Status'],'asr_running':state['Running'],'gpu1_processes':sorted(active),'other_gpu1_processes':sorted(active-owned),'test_service_started':False}));return
|
||||||
|
ensure_exclusive(active,owned)
|
||||||
|
compose=['docker','compose','--project-directory',str(ROOT),'--env-file',str(ROOT/'.env'),'-f',str(ROOT/'compose.yaml')]
|
||||||
|
if args.mode=='test':
|
||||||
|
if state['Running']:
|
||||||
|
drain();ensure_exclusive(gpu_pids(),owned_pids(container_state()))
|
||||||
|
command(compose+['stop','--timeout','660','asr'])
|
||||||
|
if gpu_pids():raise RuntimeError('GPU1_NOT_FREE')
|
||||||
|
result={'mode':'test','asr_stopped':True,'gpu1_free':True,'comfyui_test_started':False}
|
||||||
|
else:
|
||||||
|
if not state['Running']:
|
||||||
|
if active:raise RuntimeError('GPU1_NOT_FREE')
|
||||||
|
command(compose+['up','-d','--no-build','--pull','never','asr'])
|
||||||
|
deadline=time.monotonic()+480
|
||||||
|
while time.monotonic()<deadline:
|
||||||
|
state=container_state()
|
||||||
|
if state.get('Health',{}).get('Status')=='healthy':break
|
||||||
|
if state['Status'] in ['exited','dead','restarting']:raise RuntimeError('ASR_START_FAILED')
|
||||||
|
time.sleep(5)
|
||||||
|
else:raise RuntimeError('ASR_READINESS_TIMEOUT')
|
||||||
|
result={'mode':'asr','asr_healthy':True,'gpu1_processes':sorted(gpu_pids()),'comfyui_test_started':False}
|
||||||
|
target=ROOT/'.gpu1-mode.json';stage=target.with_suffix('.tmp');stage.write_text(json.dumps(result)+'\n');stage.chmod(0o600);stage.replace(target)
|
||||||
|
print(json.dumps(result))
|
||||||
|
if __name__=='__main__':main()
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a new local secret without displaying it; never replace existing .env."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
root=Path(__file__).resolve().parent
|
||||||
|
image=(root/'image-id.txt').read_text().strip()
|
||||||
|
assert re.fullmatch(r'sha256:[0-9a-f]{64}', image), 'Invalid immutable image ID'
|
||||||
|
path=root/'.env'
|
||||||
|
fd=os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(fd,'w') as stream:
|
||||||
|
stream.write(f'ASR_IMAGE={image}\nASR_API_KEY={secrets.token_urlsafe(48)}\nASR_GPU_MEMORY_UTILIZATION=0.15\n')
|
||||||
|
print('ENV_CREATED mode=0600 secret=not-displayed image='+image)
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Acceptance probe: requires a local non-sensitive speech WAV argument."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('audio',type=Path,help='Non-sensitive public or synthetic speech WAV')
|
||||||
|
parser.add_argument('--base-url',default='http://127.0.0.1:8001')
|
||||||
|
args=parser.parse_args()
|
||||||
|
root=Path(__file__).resolve().parent
|
||||||
|
values=dict(line.strip().split('=',1) for line in (root/'.env').read_text().splitlines() if line and not line.startswith('#'))
|
||||||
|
secret=values['ASR_API_KEY']
|
||||||
|
base=args.base_url.rstrip('/')
|
||||||
|
assert base in ('http://127.0.0.1:8001','http://localhost:8001'), 'Probe only supports loopback'
|
||||||
|
|
||||||
|
def request(path,data=None,headers=None):
|
||||||
|
req=urllib.request.Request(base+path,data=data,headers=headers or {})
|
||||||
|
with urllib.request.urlopen(req,timeout=180) as response:
|
||||||
|
return response.status,response.read()
|
||||||
|
|
||||||
|
status,body=request('/health')
|
||||||
|
assert status==200
|
||||||
|
print(json.dumps({'event':'health_pass','http_status':status,'body':body.decode()}))
|
||||||
|
try:
|
||||||
|
status,body=request('/v1/models')
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
assert error.code==401,error.code
|
||||||
|
print(json.dumps({'event':'unauthenticated_denied','http_status':error.code}))
|
||||||
|
else:
|
||||||
|
raise AssertionError('Unauthenticated model request was not denied')
|
||||||
|
headers={'Authorization':'Bearer '+secret}
|
||||||
|
status,body=request('/v1/models',headers=headers)
|
||||||
|
models=json.loads(body)
|
||||||
|
assert any(row['id']=='Qwen/Qwen3-ASR-1.7B' for row in models['data']),models
|
||||||
|
print(json.dumps({'event':'models_pass','http_status':status,'body':models}))
|
||||||
|
audio=args.audio.read_bytes()
|
||||||
|
assert 44<len(audio)<=25*1024*1024, 'Speech fixture must be 44 bytes..25MiB'
|
||||||
|
boundary='asr-probe-'+uuid.uuid4().hex
|
||||||
|
prefix=(f'--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nQwen/Qwen3-ASR-1.7B\r\n'
|
||||||
|
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="fixture.wav"\r\nContent-Type: audio/wav\r\n\r\n').encode()
|
||||||
|
payload=prefix+audio+f'\r\n--{boundary}--\r\n'.encode()
|
||||||
|
headers['Content-Type']='multipart/form-data; boundary='+boundary
|
||||||
|
status,body=request('/v1/audio/transcriptions',data=payload,headers=headers)
|
||||||
|
transcript=json.loads(body)
|
||||||
|
assert status==200 and isinstance(transcript.get('text'),str) and transcript['text'].strip(),transcript
|
||||||
|
print(json.dumps({'event':'transcription_pass','http_status':status,'body':transcript},ensure_ascii=False))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import importlib.util,pathlib,unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from types import SimpleNamespace
|
||||||
|
s=importlib.util.spec_from_file_location('manager',pathlib.Path(__file__).with_name('gpu1-mode.py'));m=importlib.util.module_from_spec(s);s.loader.exec_module(m)
|
||||||
|
class Safety(unittest.TestCase):
|
||||||
|
def test_absent_container_case_insensitive(self):
|
||||||
|
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=1,stderr='error: no such object: followup-audio-asr',stdout='')):self.assertEqual(m.container_state()['Status'],'absent')
|
||||||
|
def test_daemon_error_not_absent(self):
|
||||||
|
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=1,stderr='permission denied',stdout='')):
|
||||||
|
with self.assertRaisesRegex(RuntimeError,'STATE_UNKNOWN'):m.container_state()
|
||||||
|
def test_foreign_process_refused(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError,'OTHER_WORKLOAD'):m.ensure_exclusive({1,2},{1})
|
||||||
|
def test_owned_allowed(self):m.ensure_exclusive({1},{1})
|
||||||
|
def test_empty_allowed(self):m.ensure_exclusive(set(),set())
|
||||||
|
def test_idle(self):self.assertEqual(m.idle_values('vllm:num_requests_running{engine="0"} 0\nvllm:num_requests_waiting{engine="0"} 0'),[0,0])
|
||||||
|
def test_busy(self):self.assertEqual(m.idle_values('vllm:num_requests_running{engine="0"} 1\nvllm:num_requests_waiting{engine="0"} 2'),[1,2])
|
||||||
|
def test_missing_metrics_refused(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError,'METRICS_MISSING'):m.idle_values('')
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.env
|
||||||
|
*.private.*
|
||||||
|
asr-key
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Isolated Dify ASR deployment — 2026-10-09
|
||||||
|
|
||||||
|
This directory adds only a CPU-only fixed-upstream bridge, a new speech2text model/default, and an independent Dify chat App. Do not apply its compose file to the old Dify or ASR project. The two networks are external and must not be deleted.
|
||||||
|
|
||||||
|
- AI host deployment root: `/home/www/qwen-vllm/dify-integration-20261009`
|
||||||
|
- Public API base: `https://ai.zhenyangtang.com.cn/v1`
|
||||||
|
- New App: `ea0d7293-3d89-425d-a236-015fac7e2470`
|
||||||
|
- App mode chat; public site disabled; API enabled; max active chat requests 1.
|
||||||
|
- ASR: `Qwen/Qwen3-ASR-1.7B`; extraction: existing `qwen3.6-35b`.
|
||||||
|
- App max_tokens 8192, temperature 0, enable_thinking false; JSON requested in the system/query prompts, not native JSON-schema enforcement. Existing Qwen model has strict compatibility/not_supported thinking; SDK may filter the explicit false parameter. The existing Qwen server's `--default-chat-template-kwargs {"enable_thinking":false}` is the effective server control, verified without changing it.
|
||||||
|
- Binding revision: `followup-dify-20261009-v1-99ebe159aef4dce6`. Any App/provider/default/model-serving/bridge change requires a new revision and new acceptance. This is a version binding, not an automatic remote drift monitor.
|
||||||
|
- Bridge: existing pinned Python 3.12 image, two Docker networks, no host port, no GPU, UID1000, read-only root, all capabilities dropped, no-new-privileges, 256MiB/0.5CPU/64PIDs; in-flight 1, token bucket 120/min burst4, 30MiB+64KiB multipart limit, 180s fixed-upstream timeout, no redirects or POST retry.
|
||||||
|
- Secrets exist only in private 0600 host files and encrypted Dify credentials. Never print `resources.private.json`, `asr-key`, `dify-runtime.env`, or `operator-error.private.log`.
|
||||||
|
- Provisioning uses deployed `ModelProviderService`, `AppService`, model-config and key controllers, including provider validation/encryption. It never handcrafts encrypted provider rows. Provision is single-use; inspect exact state after any failure before resuming.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run `python3 test_bridge.py` locally for eight synthetic mock-server tests. `public_probe.py PRIVATE_ENV SYNTHETIC_WAV [all|asr|chat] [EXPECTED_CANARIES_JSON]` always verifies HTTPS certificates/hostname, never emits keys, and sends canary strings only in audio, not in ASR form prompts. Optional expected canaries are verifier inputs only. Preserve failures; passing connection tests do not establish full transcription accuracy.
|
||||||
|
|
||||||
|
Initial ASR fixture reproduced a homophone error: `红色石榴` became `红色石流`; its strict four-canary result remains failed even though the other three unique canaries match and HTTPS ASR returns 200. This release is for synthetic/preview-only validation, not clinical writeback or an assertion of complete accuracy.
|
||||||
|
|
||||||
|
Webhost Python/OpenSSL validates this TLS endpoint. Webhost legacy PHP/cURL NSS certificate-key-usage behavior requires independent acceptance; do not disable TLS verification or infer PHP success from Python success.
|
||||||
|
|
||||||
|
## Exact rollback
|
||||||
|
|
||||||
|
On AI host, execute only this release's `/home/www/qwen-vllm/dify-integration-20261009/ROLLBACK.sh`:
|
||||||
|
|
||||||
|
- `--quiesce`: disable only the new App API and stop only its recorded bridge container; verified live.
|
||||||
|
- `--restore`: start the same new bridge, check health, re-enable only the new App API; verified live.
|
||||||
|
- `--remove-new-objects`: revoke the precise new key, delete the precise new App, remove this newly introduced non-secret speech2text default and credential through guarded ORM/service operations, and remove only this bridge. This final removal is packaged, not exercised against the live desired deployment. It aborts if another App now uses speech2text or resource IDs differ.
|
||||||
|
|
||||||
|
No command deletes shared networks, old providers/models/apps, model files, or old containers. Apply rollback before removing the private resource manifest; preserve it for identity checks. No old source files or global configuration are edited.
|
||||||
|
|
||||||
|
After live quiesce/restore, a separate 13.03075s synthetic fixture (`蓝色风筝/绿色森林/白色帆船/黑色雨伞`) passed all four audio-only canaries through the Webhost's fully verified HTTPS Python/OpenSSL route (ASR 200, 1.284s); the JSON chat canary also passed (200, 0.539s). This additional successful connection case does not overwrite the retained first fixture's homophone failure.
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Only this release. Default reversible quiesce; --remove-new-objects is permanent.
|
||||||
|
set -eu
|
||||||
|
BASE=/home/www/qwen-vllm/dify-integration-20261009
|
||||||
|
ID=7c3ca77d77cac063d6b98310e59d2a73b896a0f5171ba002d41eccab1c067a59
|
||||||
|
MODE=${1:---quiesce}
|
||||||
|
case "$MODE" in --quiesce|--restore|--remove-new-objects) ;; *) echo 'usage: ROLLBACK.sh [--quiesce|--restore|--remove-new-objects]' >&2; exit 2;; esac
|
||||||
|
python3 - "$BASE" <<'PY'
|
||||||
|
import json,pathlib,sys
|
||||||
|
r=json.loads((pathlib.Path(sys.argv[1])/'resources.private.json').read_text())
|
||||||
|
expected={'app_id':'ea0d7293-3d89-425d-a236-015fac7e2470','key_id':'ecfb7628-1369-41fa-b132-d24074af8470','asr_model_id':'e55a7374-a43e-4b86-9323-005a2a72458b','asr_credential_id':'01a11f7e-a148-7b9c-82c7-ffc1425b40ee','asr_default_id':'56ca4093-61fa-446b-ad77-b10deb9daaa4'}
|
||||||
|
assert all(r.get(k)==v for k,v in expected.items()),'RESOURCE_IDENTITY_MISMATCH'
|
||||||
|
print('EXACT_NEW_RESOURCES_VERIFIED')
|
||||||
|
PY
|
||||||
|
ACTUAL=$(docker inspect --format '{{.Id}}' followup-asr-bridge-20261009)
|
||||||
|
[ "$ACTUAL" = "$ID" ] || { echo 'BRIDGE_IDENTITY_MISMATCH' >&2; exit 3; }
|
||||||
|
[ "$(docker inspect --format '{{index .Config.Labels "com.zyt.release"}}' "$ID")" = followup-dify-20261009 ] || exit 4
|
||||||
|
if [ "$MODE" = --restore ]; then
|
||||||
|
docker start "$ID"
|
||||||
|
for n in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if docker exec "$ID" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker exec "$ID" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"
|
||||||
|
python3 "$BASE/host_operator.py" enable
|
||||||
|
echo RESTORED_NEW_ASR_APP_AND_BRIDGE
|
||||||
|
elif [ "$MODE" = --quiesce ]; then
|
||||||
|
python3 "$BASE/host_operator.py" disable
|
||||||
|
docker stop --time 10 "$ID"
|
||||||
|
echo NEW_ASR_APP_AND_BRIDGE_QUIESCED
|
||||||
|
else
|
||||||
|
python3 "$BASE/host_operator.py" remove
|
||||||
|
docker stop --time 10 "$ID"
|
||||||
|
docker rm "$ID"
|
||||||
|
echo NEW_APP_MODEL_DEFAULT_BRIDGE_REMOVED_SHARED_RESOURCES_PRESERVED
|
||||||
|
fi
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Fixed-upstream ASR gateway: stdlib only, no retry, no body/header logging."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import hmac
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAX_BODY = 30 * 1024 * 1024 + 65536 # Dify accepts 30 MiB file; multipart allowance.
|
||||||
|
MAX_RESPONSE = 4 * 1024 * 1024
|
||||||
|
UPSTREAM_HOST = 'followup-audio-asr'
|
||||||
|
UPSTREAM_PORT = 8000
|
||||||
|
RATE_PER_MINUTE = 120
|
||||||
|
RATE_BURST = 4
|
||||||
|
|
||||||
|
class Gate:
|
||||||
|
def __init__(self, rate=RATE_PER_MINUTE, burst=RATE_BURST):
|
||||||
|
self.rate, self.burst = rate, burst
|
||||||
|
self.tokens, self.at = float(burst), time.monotonic()
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.active = threading.BoundedSemaphore(1)
|
||||||
|
|
||||||
|
def take(self):
|
||||||
|
with self.lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
self.tokens = min(float(self.burst), self.tokens + (now - self.at) * self.rate / 60)
|
||||||
|
self.at = now
|
||||||
|
if self.tokens < 1:
|
||||||
|
return False
|
||||||
|
self.tokens -= 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
class Server(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
request_queue_size = 8
|
||||||
|
|
||||||
|
def __init__(self, address, handler, *, key, upstream=(UPSTREAM_HOST, UPSTREAM_PORT), rate=RATE_PER_MINUTE, burst=RATE_BURST, timeout=180):
|
||||||
|
super().__init__(address, handler)
|
||||||
|
self.key, self.upstream, self.timeout = key, upstream, timeout
|
||||||
|
self.gate = Gate(rate, burst)
|
||||||
|
self.connections = threading.BoundedSemaphore(8)
|
||||||
|
|
||||||
|
def process_request(self, request, client_address):
|
||||||
|
if not self.connections.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
request.sendall(b'HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
|
||||||
|
finally:
|
||||||
|
self.shutdown_request(request)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
super().process_request(request, client_address)
|
||||||
|
except BaseException:
|
||||||
|
self.connections.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def process_request_thread(self, request, client_address):
|
||||||
|
try:
|
||||||
|
super().process_request_thread(request, client_address)
|
||||||
|
finally:
|
||||||
|
self.connections.release()
|
||||||
|
|
||||||
|
def handle_error(self, request, client_address):
|
||||||
|
# Deliberately suppress tracebacks/request fragments from client errors.
|
||||||
|
print(json.dumps({'event': 'client_error'}), flush=True)
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = 'HTTP/1.1'
|
||||||
|
server_version = 'FollowupASRBridge/1'
|
||||||
|
sys_version = ''
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
super().setup()
|
||||||
|
self.connection.settimeout(30)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reply(self, code, body, content_type='application/json'):
|
||||||
|
self.close_connection = True
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', content_type)
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.send_header('Connection', 'close')
|
||||||
|
self.send_header('Cache-Control', 'no-store')
|
||||||
|
if code == 429:
|
||||||
|
self.send_header('Retry-After', '1')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def error_json(self, code, label):
|
||||||
|
self.reply(code, json.dumps({'error': label}).encode())
|
||||||
|
|
||||||
|
def authorized(self):
|
||||||
|
supplied = self.headers.get('Authorization', '')
|
||||||
|
return hmac.compare_digest(supplied.encode(), b'Bearer ' + self.server.key)
|
||||||
|
|
||||||
|
def upstream_call(self, method, path, body=None, content_type=None, health=False):
|
||||||
|
conn = http.client.HTTPConnection(*self.server.upstream, timeout=3 if health else self.server.timeout)
|
||||||
|
try:
|
||||||
|
headers = {} if health else {'Authorization': 'Bearer ' + self.server.key.decode()}
|
||||||
|
if content_type:
|
||||||
|
headers['Content-Type'] = content_type
|
||||||
|
conn.request(method, path, body=body, headers=headers)
|
||||||
|
response = conn.getresponse()
|
||||||
|
result = response.read(MAX_RESPONSE + 1)
|
||||||
|
if len(result) > MAX_RESPONSE:
|
||||||
|
return self.error_json(502, 'upstream_response_too_large')
|
||||||
|
if health:
|
||||||
|
return self.reply(200 if response.status == 200 else 503, b'{"status":"ok"}' if response.status == 200 else b'{"status":"unhealthy"}')
|
||||||
|
self.reply(response.status, result, response.getheader('Content-Type', 'application/json'))
|
||||||
|
except (OSError, http.client.HTTPException, TimeoutError):
|
||||||
|
self.error_json(503 if health else 502, 'upstream_unavailable')
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/health':
|
||||||
|
return self.upstream_call('GET', '/health', health=True)
|
||||||
|
if self.path != '/v1/models':
|
||||||
|
return self.error_json(404, 'not_found')
|
||||||
|
if not self.authorized():
|
||||||
|
return self.error_json(401, 'unauthorized')
|
||||||
|
return self.upstream_call('GET', '/v1/models')
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != '/v1/audio/transcriptions':
|
||||||
|
return self.error_json(404, 'not_found')
|
||||||
|
if not self.authorized():
|
||||||
|
return self.error_json(401, 'unauthorized')
|
||||||
|
if self.headers.get('Transfer-Encoding') or not self.headers.get('Content-Length', '').isdigit():
|
||||||
|
return self.error_json(411, 'content_length_required')
|
||||||
|
length = int(self.headers['Content-Length'])
|
||||||
|
if length > MAX_BODY:
|
||||||
|
return self.error_json(413, 'body_too_large')
|
||||||
|
if length <= 0:
|
||||||
|
return self.error_json(400, 'empty_body')
|
||||||
|
content_type = self.headers.get('Content-Type', '')
|
||||||
|
if not content_type.startswith('multipart/form-data;') or 'boundary=' not in content_type:
|
||||||
|
return self.error_json(415, 'multipart_required')
|
||||||
|
if not self.server.gate.active.acquire(blocking=False):
|
||||||
|
return self.error_json(429, 'asr_busy')
|
||||||
|
try:
|
||||||
|
if not self.server.gate.take():
|
||||||
|
return self.error_json(429, 'rate_limit')
|
||||||
|
try:
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
except (OSError, socket.timeout):
|
||||||
|
return self.error_json(408, 'upload_timeout')
|
||||||
|
if len(body) != length:
|
||||||
|
return self.error_json(400, 'incomplete_body')
|
||||||
|
return self.upstream_call('POST', '/v1/audio/transcriptions', body, content_type)
|
||||||
|
finally:
|
||||||
|
self.server.gate.active.release()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
key = Path('/run/secrets/asr-key').read_bytes().strip()
|
||||||
|
if len(key) < 16 or b'\n' in key or b'\r' in key:
|
||||||
|
raise SystemExit('invalid_secret_file')
|
||||||
|
print(json.dumps({'event': 'started', 'rate_per_minute': RATE_PER_MINUTE, 'inflight': 1, 'host_ports': False}), flush=True)
|
||||||
|
Server(('0.0.0.0', 8080), Handler, key=key).serve_forever()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
bridge:
|
||||||
|
image: sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
|
||||||
|
container_name: followup-asr-bridge-20261009
|
||||||
|
user: '1000:1000'
|
||||||
|
entrypoint: ['python', '-B', '/app/bridge.py']
|
||||||
|
working_dir: /app
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: ['no-new-privileges:true']
|
||||||
|
pids_limit: 64
|
||||||
|
cpus: 0.50
|
||||||
|
mem_limit: 256m
|
||||||
|
memswap_limit: 256m
|
||||||
|
tmpfs: ['/tmp:size=16m,noexec,nosuid,nodev']
|
||||||
|
volumes:
|
||||||
|
- './bridge.py:/app/bridge.py:ro'
|
||||||
|
- './asr-key:/run/secrets/asr-key:ro'
|
||||||
|
networks:
|
||||||
|
dify:
|
||||||
|
aliases: [followup-asr-bridge]
|
||||||
|
asr: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: 1m, max-file: '2'}
|
||||||
|
labels:
|
||||||
|
com.zyt.release: followup-dify-20261009
|
||||||
|
networks:
|
||||||
|
dify:
|
||||||
|
external: true
|
||||||
|
name: dify_v1110_eera_default
|
||||||
|
asr:
|
||||||
|
external: true
|
||||||
|
name: followup-audio-asr_default
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Execute in Dify API Python with PAYLOAD supplied by private host wrapper.
|
||||||
|
|
||||||
|
Uses deployed services/controllers for credential encryption and app config.
|
||||||
|
Never print this process output to public logs: create returns a new app key to
|
||||||
|
its private caller; the wrapper writes it 0600 and emits only public metadata.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
from app_factory import create_app
|
||||||
|
app = create_app()
|
||||||
|
from sqlalchemy import select
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from models.account import Account, Tenant, TenantAccountJoin
|
||||||
|
from models.model import App, ApiToken
|
||||||
|
from models.provider import ProviderModel, ProviderModelCredential, TenantDefaultModel
|
||||||
|
from services.app_service import AppService
|
||||||
|
from services.model_provider_service import ModelProviderService
|
||||||
|
|
||||||
|
PROVIDER = 'langgenius/openai_api_compatible/openai_api_compatible'
|
||||||
|
ASR = 'Qwen/Qwen3-ASR-1.7B'
|
||||||
|
APP_NAME = 'followup-audio-isolated-20261009'
|
||||||
|
ENDPOINT = 'http://followup-asr-bridge:8080/v1'
|
||||||
|
CONFIG = {
|
||||||
|
'model': {'provider': PROVIDER, 'name': 'qwen3.6-35b', 'mode': 'chat',
|
||||||
|
'completion_params': {'max_tokens': 8192, 'temperature': 0.0, 'top_p': 1.0, 'enable_thinking': False}},
|
||||||
|
'pre_prompt': '仅依据本次用户请求处理提供的文本。遵循用户给定的 JSON 格式,只返回最终 JSON,不输出思考过程、解释或 Markdown。不得把示例当成事实。',
|
||||||
|
'prompt_type': 'simple', 'user_input_form': [],
|
||||||
|
'speech_to_text': {'enabled': True}, 'text_to_speech': {'enabled': False},
|
||||||
|
'suggested_questions_after_answer': {'enabled': False},
|
||||||
|
'retriever_resource': {'enabled': False}, 'sensitive_word_avoidance': {'enabled': False},
|
||||||
|
'file_upload': {'enabled': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Anchor only to the existing default Qwen workspace. Fail if ambiguous.
|
||||||
|
tenant_ids = db.session.scalars(select(TenantDefaultModel.tenant_id).where(
|
||||||
|
TenantDefaultModel.model_type == 'text-generation', TenantDefaultModel.model_name == 'qwen3.6-35b')).all()
|
||||||
|
assert len(tenant_ids) == 1, 'AMBIGUOUS_WORKSPACE'
|
||||||
|
tenant_id = tenant_ids[0]
|
||||||
|
tenant = db.session.get(Tenant, tenant_id)
|
||||||
|
owner = db.session.scalars(select(Account).join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id).where(
|
||||||
|
TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == 'owner')).one()
|
||||||
|
owner.current_tenant = tenant
|
||||||
|
def current_app_owned():
|
||||||
|
resource = PAYLOAD['resources']
|
||||||
|
obj = db.session.get(App, resource['app_id'])
|
||||||
|
assert obj and obj.name == APP_NAME and obj.tenant_id == tenant_id, 'APP_IDENTITY_MISMATCH'
|
||||||
|
return obj
|
||||||
|
def public_state(obj):
|
||||||
|
model = db.session.scalars(select(ProviderModel).where(ProviderModel.tenant_id == tenant_id,
|
||||||
|
ProviderModel.provider_name == PROVIDER, ProviderModel.model_name == ASR, ProviderModel.model_type == 'speech2text')).one()
|
||||||
|
default = db.session.scalars(select(TenantDefaultModel).where(TenantDefaultModel.tenant_id == tenant_id,
|
||||||
|
TenantDefaultModel.model_type == 'speech2text')).one()
|
||||||
|
config = obj.app_model_config.to_dict()
|
||||||
|
return {'app_id':obj.id,'app_name':obj.name,'tenant_id':tenant_id,'app_mode':obj.mode,
|
||||||
|
'enable_api':obj.enable_api,'enable_site':obj.enable_site,'config_id':obj.app_model_config_id,
|
||||||
|
'speech_to_text':obj.app_model_config.speech_to_text_dict,
|
||||||
|
'model':obj.app_model_config.model_dict,'pre_prompt_sha256':hashlib.sha256(obj.app_model_config.pre_prompt.encode()).hexdigest(),
|
||||||
|
'config_sha256':hashlib.sha256(json.dumps(config,ensure_ascii=False,sort_keys=True,default=str).encode()).hexdigest(),
|
||||||
|
'asr_model_id':model.id,'asr_credential_id':model.credential_id,'asr_default_id':default.id,
|
||||||
|
'asr_model':model.model_name,'asr_default':default.model_name,'provider':PROVIDER,'endpoint':ENDPOINT,
|
||||||
|
'base_url':'https://ai.zhenyangtang.com.cn/v1','max_active_requests':obj.max_active_requests}
|
||||||
|
result = {}
|
||||||
|
with app.test_request_context('/', method='POST', json=CONFIG):
|
||||||
|
app.login_manager._update_request_context_with_user(owner)
|
||||||
|
svc = AppService()
|
||||||
|
action = PAYLOAD['action']
|
||||||
|
if action == 'create':
|
||||||
|
assert not db.session.scalar(select(App.id).where(App.name == APP_NAME)), 'APP_NAME_ALREADY_EXISTS'
|
||||||
|
assert not db.session.scalar(select(ProviderModel.id).where(ProviderModel.tenant_id == tenant_id,ProviderModel.model_type == 'speech2text')), 'SPEECH2TEXT_NOT_EMPTY'
|
||||||
|
assert not db.session.scalar(select(TenantDefaultModel.id).where(TenantDefaultModel.tenant_id == tenant_id,TenantDefaultModel.model_type == 'speech2text')), 'DEFAULT_NOT_EMPTY'
|
||||||
|
ModelProviderService().create_model_credential(tenant_id,PROVIDER,'speech2text',ASR,
|
||||||
|
{'api_key':PAYLOAD['asr_key'],'endpoint_url':ENDPOINT,'endpoint_model_name':ASR,'language':'zh','initial_prompt':'','display_name':'Followup ASR isolated'},
|
||||||
|
'followup-asr-isolated-20261009')
|
||||||
|
ModelProviderService().update_default_model_of_model_type(tenant_id,'speech2text',PROVIDER,ASR)
|
||||||
|
obj=svc.create_app(tenant_id,{'mode':'chat','name':APP_NAME,'description':'Isolated synthetic-verified ASR and JSON extraction. Preview only; no clinical writes.','icon_type':'emoji','icon':'🎙️','icon_background':'#E4FBCC'},owner)
|
||||||
|
svc.update_app_site_status(obj,False)
|
||||||
|
from controllers.console.app.model_config import ModelConfigResource
|
||||||
|
inspect.unwrap(ModelConfigResource.post)(ModelConfigResource(),obj)
|
||||||
|
svc.update_app(obj, {'name':APP_NAME,'description':obj.description,'icon_type':obj.icon_type,'icon':obj.icon,'icon_background':obj.icon_background,'use_icon_as_answer_icon':False,'max_active_requests':1})
|
||||||
|
svc.update_app_api_status(obj,True)
|
||||||
|
from controllers.console.apikey import AppApiKeyListResource, BaseApiKeyListResource
|
||||||
|
token,status=inspect.unwrap(BaseApiKeyListResource.post)(AppApiKeyListResource(),obj.id)
|
||||||
|
assert status == 201
|
||||||
|
result={'public':public_state(obj),'private':{'api_key':token.token,'key_id':token.id}}
|
||||||
|
elif action in ('status','disable','enable'):
|
||||||
|
obj=current_app_owned()
|
||||||
|
if action != 'status':svc.update_app_api_status(obj,action == 'enable')
|
||||||
|
result={'public':public_state(obj)}
|
||||||
|
elif action == 'remove':
|
||||||
|
resource=PAYLOAD['resources'];obj=current_app_owned()
|
||||||
|
credential=db.session.get(ProviderModelCredential,resource['asr_credential_id'])
|
||||||
|
recorded_default=db.session.get(TenantDefaultModel,resource['asr_default_id'])
|
||||||
|
assert credential and credential.tenant_id==tenant_id and credential.model_name==ASR and credential.model_type=='speech2text', 'CREDENTIAL_IDENTITY_MISMATCH'
|
||||||
|
assert recorded_default and recorded_default.tenant_id==tenant_id and recorded_default.model_name==ASR and recorded_default.model_type=='speech2text', 'DEFAULT_IDENTITY_MISMATCH'
|
||||||
|
for other in db.session.scalars(select(App).where(App.tenant_id==tenant_id,App.id!=obj.id)).all():
|
||||||
|
features=(other.workflow.features_dict if other.workflow else {}) if other.mode in ('advanced-chat','workflow') else ({'speech_to_text':other.app_model_config.speech_to_text_dict} if other.app_model_config else {})
|
||||||
|
assert not features.get('speech_to_text',{}).get('enabled'), 'ANOTHER_APP_NOW_USES_ASR'
|
||||||
|
svc.update_app_api_status(obj,False)
|
||||||
|
# Only the precise new app key, via the deployed API controller.
|
||||||
|
from controllers.console.apikey import AppApiKeyResource, BaseApiKeyResource
|
||||||
|
key_id=resource['key_id']
|
||||||
|
if db.session.get(ApiToken,key_id):
|
||||||
|
inspect.unwrap(BaseApiKeyResource.delete)(AppApiKeyResource(),obj.id,key_id)
|
||||||
|
svc.delete_app(obj)
|
||||||
|
default=db.session.get(TenantDefaultModel,resource['asr_default_id'])
|
||||||
|
assert default and default.tenant_id==tenant_id and default.model_type=='speech2text' and default.model_name==ASR, 'DEFAULT_IDENTITY_MISMATCH'
|
||||||
|
# No service offers reset-to-empty; remove only this recorded, new, non-secret default row.
|
||||||
|
db.session.delete(default);db.session.commit()
|
||||||
|
ModelProviderService().remove_model_credential(tenant_id,PROVIDER,'speech2text',ASR,resource['asr_credential_id'])
|
||||||
|
assert not db.session.scalar(select(App.id).where(App.id==resource['app_id']))
|
||||||
|
assert not db.session.scalar(select(ProviderModel.id).where(ProviderModel.id==resource['asr_model_id']))
|
||||||
|
result={'public':{'removed_app_id':resource['app_id'],'speech2text_restored_empty':True}}
|
||||||
|
else:raise ValueError('UNKNOWN_ACTION')
|
||||||
|
print('OPERATOR_RESULT='+json.dumps(result,ensure_ascii=False,default=str))
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Host-private Dify service invocation, emits no credentials."""
|
||||||
|
import json,os,pathlib,subprocess,sys
|
||||||
|
BASE=pathlib.Path(__file__).resolve().parent
|
||||||
|
os.umask(0o077)
|
||||||
|
action=sys.argv[1]
|
||||||
|
payload={'action':action}
|
||||||
|
resources=BASE/'resources.private.json'
|
||||||
|
if action=='create':
|
||||||
|
payload['asr_key']=(BASE/'asr-key').read_text().strip()
|
||||||
|
assert not resources.exists(), 'RESOURCE_FILE_ALREADY_EXISTS'
|
||||||
|
else:payload['resources']=json.loads(resources.read_text())
|
||||||
|
code='PAYLOAD='+repr(payload)+'\n'+(BASE/'dify_operator.py').read_text()
|
||||||
|
p=subprocess.run(['docker','exec','-i','dify_v1110_eera-api-1','python','-'],input=code,text=True,capture_output=True)
|
||||||
|
# Never print raw stdout/stderr: credentials may exist in structured output/errors.
|
||||||
|
lines=[s for s in p.stdout.splitlines() if s.startswith('OPERATOR_RESULT=')]
|
||||||
|
if p.returncode or len(lines)!=1:
|
||||||
|
# Private diagnostic file; callers must not copy it into public artifacts.
|
||||||
|
(BASE/'operator-error.private.log').write_text(p.stdout+'\n'+p.stderr)
|
||||||
|
print(json.dumps({'action':action,'exit_status':p.returncode,'result':'FAILED','diagnostic_private':True}))
|
||||||
|
sys.exit(p.returncode or 1)
|
||||||
|
data=json.loads(lines[0].split('=',1)[1])
|
||||||
|
if action=='create':
|
||||||
|
value={**data['public'],**data['private']}
|
||||||
|
resources.write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n');resources.chmod(0o600)
|
||||||
|
env='DIFY_ASR_APP_KEY='+value['api_key']+'\nDIFY_ASR_APP_ID='+value['app_id']+'\nDIFY_ASR_BASE_URL='+value['base_url']+'\nDIFY_ASR_EXPECTED_MODEL=Qwen/Qwen3-ASR-1.7B\nDIFY_EXTRACTION_EXPECTED_MODEL=qwen3.6-35b\nDIFY_BINDING_REVISION=followup-dify-20261009-v1-'+value['config_sha256'][:16]+'\n'
|
||||||
|
(BASE/'dify-runtime.env').write_text(env);(BASE/'dify-runtime.env').chmod(0o600)
|
||||||
|
print(json.dumps({'action':action,'exit_status':p.returncode,'result':'PASS',**data['public']},ensure_ascii=False))
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Synthetic-only HTTPS acceptance; Python 3.6+, never disables TLS checks."""
|
||||||
|
import hashlib,json,pathlib,re,ssl,sys,time,urllib.error,urllib.request,uuid
|
||||||
|
ENV=pathlib.Path(sys.argv[1])
|
||||||
|
WAV=pathlib.Path(sys.argv[2])
|
||||||
|
MODE=sys.argv[3] if len(sys.argv)>3 else 'all'
|
||||||
|
CANARIES=json.loads(pathlib.Path(sys.argv[4]).read_text(encoding='utf-8')) if len(sys.argv)>4 else ['紫色海豚','金色河流','银色树叶','红色石榴']
|
||||||
|
env=dict(line.split('=',1) for line in ENV.read_text(encoding='utf-8').splitlines() if '=' in line)
|
||||||
|
base=env['DIFY_ASR_BASE_URL'];key=env['DIFY_ASR_APP_KEY']
|
||||||
|
ctx=ssl.create_default_context()
|
||||||
|
def request(path,data,content_type,auth=True):
|
||||||
|
h={'Content-Type':content_type}
|
||||||
|
if auth:h['Authorization']='Bearer '+key
|
||||||
|
req=urllib.request.Request(base+path,data=data,headers=h,method='POST')
|
||||||
|
start=time.monotonic()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req,context=ctx,timeout=200) as response:
|
||||||
|
code=response.status;body=response.read().decode()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
code=error.code;body=error.read().decode()
|
||||||
|
return {'status':code,'elapsed_seconds':round(time.monotonic()-start,3),'body':json.loads(body)}
|
||||||
|
def audio():
|
||||||
|
boundary='synthetic'+uuid.uuid4().hex
|
||||||
|
body=('--'+boundary+'\r\nContent-Disposition: form-data; name="user"\r\n\r\nsynthetic-asr-acceptance-20261009\r\n--'+boundary+'\r\nContent-Disposition: form-data; name="file"; filename="synthetic.wav"\r\nContent-Type: audio/wav\r\n\r\n').encode()+WAV.read_bytes()+('\r\n--'+boundary+'--\r\n').encode()
|
||||||
|
return request('/audio-to-text',body,'multipart/form-data; boundary='+boundary)
|
||||||
|
r={'synthetic':True,'tls_verify':True,'check_hostname':ctx.check_hostname,'ssl':ssl.OPENSSL_VERSION,'mode':MODE,'app_id':env['DIFY_ASR_APP_ID'],'base_url':base,'binding_revision':env['DIFY_BINDING_REVISION']}
|
||||||
|
if MODE in ('all','asr'):
|
||||||
|
r['unauthorized']=request('/audio-to-text',b'','application/octet-stream',False)
|
||||||
|
r['asr']=audio()
|
||||||
|
text=r['asr']['body'].get('text','')
|
||||||
|
r['audio_only_canaries']={x:x in text for x in CANARIES}
|
||||||
|
r['asr_pass']=r['unauthorized']['status']==401 and r['asr']['status']==200 and all(r['audio_only_canaries'].values())
|
||||||
|
if MODE in ('all','chat'):
|
||||||
|
query='这是独立的合成 JSON 验收,不包含患者数据。请只输出 JSON 对象:{"canary":"BLUE_KITE_20261009","sum":42,"preview_only":true}。不要添加其他字段、解释或 Markdown。'
|
||||||
|
payload={'inputs':{},'query':query,'response_mode':'blocking','user':'synthetic-chat-acceptance-20261009','auto_generate_name':False}
|
||||||
|
r['chat']=request('/chat-messages',json.dumps(payload,ensure_ascii=False).encode(),'application/json')
|
||||||
|
answer=r['chat']['body'].get('answer','')
|
||||||
|
try:parsed=json.loads(answer)
|
||||||
|
except ValueError:parsed=None
|
||||||
|
r['chat_pass']=r['chat']['status']==200 and parsed=={'canary':'BLUE_KITE_20261009','sum':42,'preview_only':True} and bool(r['chat']['body'].get('message_id')) and '<think>' not in answer
|
||||||
|
r['passed']=all(r.get(k,True) for k in ['asr_pass','chat_pass'])
|
||||||
|
print(json.dumps(r,ensure_ascii=True,indent=2))
|
||||||
|
sys.exit(0 if r['passed'] else 1)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Local synthetic mock-server tests, no model calls or secrets."""
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import bridge
|
||||||
|
|
||||||
|
KEY = b'synthetic-only-unit-test-secret'
|
||||||
|
class Upstream(BaseHTTPRequestHandler):
|
||||||
|
calls = 0
|
||||||
|
slow = False
|
||||||
|
entered = threading.Event()
|
||||||
|
def log_message(self, *args): pass
|
||||||
|
def do_GET(self):
|
||||||
|
body = b'{}'; self.send_response(200); self.send_header('Content-Length','2'); self.end_headers(); self.wfile.write(body)
|
||||||
|
def do_POST(self):
|
||||||
|
type(self).calls += 1
|
||||||
|
type(self).entered.set()
|
||||||
|
self.rfile.read(int(self.headers['Content-Length']))
|
||||||
|
if type(self).slow: time.sleep(0.25)
|
||||||
|
assert self.headers['Authorization'] == 'Bearer ' + KEY.decode()
|
||||||
|
body = b'{"text":"synthetic-only-canary"}'
|
||||||
|
self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
|
||||||
|
|
||||||
|
class BridgeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
Upstream.calls=0;Upstream.slow=False;Upstream.entered.clear()
|
||||||
|
self.u=ThreadingHTTPServer(('127.0.0.1',0),Upstream)
|
||||||
|
self.b=bridge.Server(('127.0.0.1',0),bridge.Handler,key=KEY,upstream=self.u.server_address)
|
||||||
|
for s in [self.u,self.b]: threading.Thread(target=s.serve_forever,daemon=True).start()
|
||||||
|
def tearDown(self):
|
||||||
|
for s in [self.b,self.u]:s.shutdown();s.server_close()
|
||||||
|
def call(self,body=b'x',headers=None,path='/v1/audio/transcriptions',method='POST'):
|
||||||
|
h={'Authorization':'Bearer '+KEY.decode(),'Content-Type':'multipart/form-data; boundary=test'}
|
||||||
|
if headers: h.update(headers)
|
||||||
|
c=http.client.HTTPConnection(*self.b.server_address,timeout=2);c.request(method,path,body,h);r=c.getresponse();result=(r.status,r.read());c.close();return result
|
||||||
|
def test_valid_fixed_upstream(self):
|
||||||
|
self.assertEqual(self.call(),(200,b'{"text":"synthetic-only-canary"}'));self.assertEqual(Upstream.calls,1)
|
||||||
|
def test_auth_denied_without_upstream(self):
|
||||||
|
self.assertEqual(self.call(headers={'Authorization':'Bearer wrong'})[0],401);self.assertEqual(Upstream.calls,0)
|
||||||
|
def test_body_bound_before_read(self):
|
||||||
|
self.assertEqual(self.call(headers={'Content-Length':str(bridge.MAX_BODY+1)})[0],413);self.assertEqual(Upstream.calls,0)
|
||||||
|
def test_fixed_path_only(self):
|
||||||
|
self.assertEqual(self.call(path='/v1/chat/completions')[0],404);self.assertEqual(Upstream.calls,0)
|
||||||
|
def test_concurrency_exactly_one(self):
|
||||||
|
Upstream.slow=True;out=[];t=threading.Thread(target=lambda:out.append(self.call()[0]));t.start();self.assertTrue(Upstream.entered.wait(1));self.assertEqual(self.call()[0],429);t.join();self.assertEqual(out,[200]);self.assertEqual(Upstream.calls,1)
|
||||||
|
def test_rate_429_no_retry(self):
|
||||||
|
self.b.gate=bridge.Gate(rate=0,burst=1);self.assertEqual(self.call()[0],200);self.assertEqual(self.call()[0],429);self.assertEqual(Upstream.calls,1)
|
||||||
|
def test_upstream_down_no_retry(self):
|
||||||
|
self.b.upstream=('127.0.0.1',1);self.assertEqual(self.call()[0],502);self.assertEqual(Upstream.calls,0)
|
||||||
|
def test_wrong_content_type(self):
|
||||||
|
self.assertEqual(self.call(headers={'Content-Type':'application/json'})[0],415);self.assertEqual(Upstream.calls,0)
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Follow-up preview runtime (2026-10-09)
|
||||||
|
|
||||||
|
This is an opt-in, diagnosis-ID-scoped preview. It never permits clinical adoption; full audio/semantic verification remains false. Do not use a preview fingerprint as full verification. Preview-origin tasks remain non-adoptable after configuration changes.
|
||||||
|
|
||||||
|
## Dedicated media tools
|
||||||
|
|
||||||
|
The CentOS 7 Webhost needs a private FFmpeg/FFprobe path, not a system upgrade. Installed location is `/opt/followup-audio-tools/ffmpeg-9.0.2/` (no PATH or OS package changes). Source was downloaded from `https://ffmpeg.org/releases/ffmpeg-9.0.2.tar.xz`, SHA256 `8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e`, with detached PGP signature verified against the official release key `FCF986EA15E6E293A5644F10B4322F04D67658D8`.
|
||||||
|
|
||||||
|
It was built on the AI host using GCC 12, `nice -n 19 make -j1`, static libc, generic x86-64 / Linux 3.2 baseline, without network protocols or optional external codec libraries. Build flags:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./configure --prefix=/opt/followup-audio-tools/ffmpeg-9.0.2 \
|
||||||
|
--disable-autodetect --disable-shared --enable-static --extra-cflags=-O2 --extra-ldflags=-static \
|
||||||
|
--disable-x86asm --disable-doc --disable-debug --disable-network --disable-everything \
|
||||||
|
--enable-ffmpeg --enable-ffprobe --enable-avcodec --enable-avformat --enable-avfilter --enable-swresample \
|
||||||
|
--enable-protocol=file,pipe --enable-demuxer=mov,mp3,wav,amr,aac \
|
||||||
|
--enable-parser=aac,aac_latm,mpegaudio \
|
||||||
|
--enable-decoder=mp3,mp3float,mp3adu,mp3adufloat,mp3on4,mp3on4float,aac,aac_latm,amrnb,amrwb,pcm_s16le,pcm_s16be,pcm_u8,pcm_s24le,pcm_s32le,pcm_f32le,pcm_f64le \
|
||||||
|
--enable-encoder=pcm_s16le --enable-muxer=wav \
|
||||||
|
--enable-filter=aresample,pan,anull,aformat,atrim,asetpts,abuffer,abuffersink
|
||||||
|
nice -n 19 make -j1 ffmpeg ffprobe
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary SHA256: ffmpeg `a2c81c9049a5e919d8f5ce9c246580f9f6cf8a38aaece8c79ed0fd893c35453e`; ffprobe `af8f5eb67ea87beca9ec7fb27f3355cf918fabee0d88a7db628bf511c691a680`. Both executed successfully on the Webhost; synthetic stereo WAV was decoded and split. This minimal build is for the PCM two-stage driver, not an assertion that legacy MP3 re-encoding, all media encodings, or real hour-long ASR has passed.
|
||||||
|
|
||||||
|
## Dify and TLS
|
||||||
|
|
||||||
|
Use the isolated App provisioned by `../followup-audio-dify/`, HTTPS only. On this host PHP cURL/NSS rejects the current certificate; native PHP OpenSSL verifies it successfully. Set the feature's explicit `HTTP_TRANSPORT=openssl_stream`, not TLS verification off and not automatic fallback. The controlled child process maintains parent authorization/lease heartbeats. Other application HTTP clients are unchanged.
|
||||||
|
|
||||||
|
The Dify App owns model/generation parameters. Configure both explicit stage protocols, expected models and binding revisions; changing App/default/provider configuration requires a new revision and preview verification. Keep extraction thinking unset locally and `EXTRACTION_RESPONSE_FORMAT=prompt_json`; no claim that local OpenAI response_format/max_tokens are transmitted through Dify.
|
||||||
|
|
||||||
|
## Release constraints
|
||||||
|
|
||||||
|
- `ENABLED=true` only with `PREVIEW_ONLY=true`, explicit `TEST_DIAGNOSIS_IDS`, fresh preview fingerprint, stable private encryption key; `AUDIO_VERIFIED=false`.
|
||||||
|
- Use an independent `followup-audio-preview` consumer, concurrency 1. Do not restart prescription workers, PHP, Dify or GPU services.
|
||||||
|
- Back up source/env/static assets and affected schema/data. Additive DDL must use a bounded session lock budget; prefer explicit INSTANT columns and fail instead of silently table-copying.
|
||||||
|
- Keep existing untracked production overlays. Coordinate the two existing auto-pull lock files; never reset/clean the live repository.
|
||||||
|
- Build with `vite build` only. Copy new hashed assets additively, retain old assets for active browsers, then atomically replace index.html; do not invoke the destructive release.mjs on the live directory.
|
||||||
|
- Rollback stops only the new consumer and disables preview, restores changed source/index/env with hashes, and retains additive tables/columns/audit. Never roll back the entire business database or rewrite remote master to undo a release.
|
||||||
|
- Keep API credentials, raw recordings, clinical snapshots and runtime state outside Git. Application cleanup does not imply Dify copies were deleted.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 用甄养堂账号登录行知(AI 模块单点登录,2026-10-10)
|
||||||
|
|
||||||
|
行知(AI 工作助手)的登录页增加“使用甄养堂账号登录”:跳到甄养堂的登录确认页,确认后回到行知,行知首次登录自动开通成员账号,并同时完成“绑定甄养堂账号”(只读授权)。密码只在甄养堂输入和校验,不经过行知。
|
||||||
|
|
||||||
|
## 改动范围(都在 AI 模块 `server/app/mcp` 内,原有后台接口、Logic、中间件、配置未改)
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `controller/SsoController.php`(新增) | `GET /mcp/sso/authorize` 登录确认页;`POST /mcp/sso/whois`、`/approve`、`/login` 页面用;`POST /mcp/sso/token` 行知服务器换取结果 |
|
||||||
|
| `service/SsoService.php`(新增) | 客户端与回调地址校验、浏览器里已登录后台会话的识别、一次性授权码(2 分钟、只能用一次,文件锁防并发兑换) |
|
||||||
|
| `service/GrantService.php`(AI 模块自身) | `issue()` 拆成 `verifyPassword()` + `issueFor()`,`assertAdminUsable()` 改为 public;绑定接口 `/mcp/auth/grant` 的行为不变 |
|
||||||
|
| `service/McpConfig.php`(AI 模块自身) | 新增 `ssoEnabled / ssoClientId / ssoClientSecret / ssoRedirectUris` |
|
||||||
|
| `server/tests/AiMcpSsoTest.php`(新增) | 见“测试” |
|
||||||
|
|
||||||
|
不需要执行新的数据库迁移;不新增权限点(沿用 `ai.mcp/access`)。
|
||||||
|
|
||||||
|
## 流程与门禁
|
||||||
|
|
||||||
|
1. 行知把浏览器带到 `/mcp/sso/authorize?client_id&redirect_uri&state`。`client_id`、`redirect_uri` 必须与 .env 登记的完全一致,否则只显示原因、不显示登录框、不跳转。
|
||||||
|
2. 确认身份,两种方式:
|
||||||
|
- 浏览器里已登录甄养堂后台(localStorage `like_admin_token`):显示“你已登录甄养堂后台:某某”,点“以此账号登录”。只认电脑端、手机端的正常登录(terminal 1/2),AI 后台浏览器(8)、企微客服端(7/9)和过期会话一律不认。
|
||||||
|
- 输入账号密码:与“绑定甄养堂账号”同一套校验(按 IP 限流、连续错误锁定、停用、企微强制绑定、`ai.mcp/access` 权限点、未改初始密码)。
|
||||||
|
3. 带一次性授权码回到行知;行知服务器用客户端密钥调用 `/mcp/sso/token`(经过 `ALLOWED_IPS` / Origin 检查),得到账号信息和只读令牌(与绑定签发的授权相同,同一行知实例的旧授权自动作废)。
|
||||||
|
4. 页面:严格 CSP(脚本只认本页 nonce、`frame-ancestors 'none'`、`form-action 'none'`)、`X-Frame-Options: DENY`、`Cache-Control: no-store`、`Referrer-Policy: no-referrer`,并覆盖全局中间件的 `Access-Control-Allow-Origin: *`。
|
||||||
|
5. 审计:`zyt_ai_access_log` 的 `sso.approve`、`sso.login`(拒绝时)、`sso.token`。
|
||||||
|
|
||||||
|
## 上线步骤
|
||||||
|
|
||||||
|
1. 同步上面四个文件到生产(`SsoController.php`、`SsoService.php`、`GrantService.php`、`McpConfig.php`)。
|
||||||
|
2. 在行知管理端 → 组织连接器 → 甄养堂业务系统 → “账号登录行知” → “生成密钥”,把显示的四行加到生产 `.env` 的 `[AI_MCP]` 段(与 `ENABLED = true` 同一段),例如:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
SSO_ENABLED = true
|
||||||
|
SSO_CLIENT_ID = xingzhi
|
||||||
|
SSO_CLIENT_SECRET = (行知管理端生成,只显示一次)
|
||||||
|
SSO_REDIRECT_URIS = http://xz.zhenyangtang.cn:8787/api/auth/sso/zyt/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 重新加载 PHP(PHP-FPM / 宝塔里重载或重启 PHP),然后在行知管理端勾选“开启:登录页显示这个按钮”并保存。
|
||||||
|
4. 如设置了 `ALLOWED_IPS`,需包含行知服务器的出口 IP(`/mcp/sso/token` 由行知服务器调用);登录确认页本身面向员工浏览器,不受这项限制。
|
||||||
|
5. 停用:把 `SSO_ENABLED` 改为 false(或删掉这几行)并重载 PHP;行知侧也可在管理端取消勾选。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.cache/zyt-tools/scripts/zyt_env.sh # 本地 PHP 8.2 + MariaDB(zyt_mcp_test)
|
||||||
|
export PHP_AI_MCP_SSO_ENABLED=true PHP_AI_MCP_SSO_CLIENT_ID=xingzhi
|
||||||
|
export PHP_AI_MCP_SSO_CLIENT_SECRET=<至少 32 位> PHP_AI_MCP_SSO_REDIRECT_URIS=http://127.0.0.1:18787/api/auth/sso/zyt/callback
|
||||||
|
# 服务端与测试进程使用同一组 SSO 配置
|
||||||
|
AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 $PHP server/tests/AiMcpSsoTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:确认页只对登记的客户端显示(安全响应头、未登记地址不回显)、账号密码确认的各项门禁、一键确认只认电脑端/手机端会话、授权码要客户端密钥并与回调地址一致且只能用一次、确认后被停用的账号兑换失败、换来的令牌可直接使用并替换同一实例的旧授权、审计、原有绑定接口不受影响。原有 `AiMcpUnitTest`、`AiMcpHttpContractTest`、`AiMcpConsoleTest`、`AiMcpPerfTest` 回归通过。与行知的端到端联调(真实 zyt 代码 + 行知登录页,账号密码与一键两种方式)也已通过。
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
# 二中心医助复诊跟进体系方案:诊次接诊率 / 患者触达节点 / 到期标红 / 到期弹窗与延迟升级
|
||||||
|
|
||||||
|
日期:2026-10-12。状态:用户已确认实施(2026-10-13);P0、P1 代码已落地(工作区未提交),P2 开发中。
|
||||||
|
|
||||||
|
**实施记录(2026-10-14 更新)**
|
||||||
|
- P0 需求 2/3、P1 需求 4、P1 需求 1 均已落地并经多路静态复核修复;本机 phpstudy PHP 8.2 `php -l` 全部通过,单测 `RevisitDueCalculatorTest`(66) / `RevisitVisitSlotServiceTest`(20) / `RevisitDueLogicTest`(67) / `RevisitRateInheritSlotTest`(57) / `RevisitRateContractTest`(45) / `AiMcpUnitTest` 全部通过。
|
||||||
|
- 与本文差异:业务钩子集中在 `app\common\service\tcm\RevisitDueHook`(而非 adminapi 的 RevisitDueLogic);快照表增加事实列 `appointment_state / appointment_date`;`DiagnosisLogic::delete` 也接了钩子;`tcm:revisit-due-scan` 防重入用 Cache 标记、`--date` 与今天不同时强制演练;弹窗 level 3 用 el-dialog 实现;`PrescriptionOrderLists` 改用 `fetchOrderSeqRows` 一次取序列(而非 batchCurrentSlot)。
|
||||||
|
- **2026-10-14 用户决定:复诊接诊率保持原来的逻辑不变。** 处理:`revisit_rate.default_inherit_mode` 改为 `exclude`(页面、个人页卡片、MCP 查询默认均为原口径,数值与改动前逐值一致),「新口径(继承按继承时诊次计入)」仅作页面上的手动切换项;原口径下页面列名、各列、下钻弹窗与原来一致(仅多出「复诊合计」两列);撤回 D1-d(指派弹窗不再默认勾选继承、不再二次确认)与 D1-e(自动分配回退行仍写 `is_inherit=0`);业绩看板 tooltip 恢复原文案;自动分配分档继续使用原口径字段。
|
||||||
|
- **2026-10-14 本地真库验证**:SQL 01~10 已在本地库执行;全量快照 4721 行与独立 SQL 推算逐字段一致(锚点/天数/预计复诊日/挂号状态/诊次/关闭状态),读取侧 9 态推导一致。发现并修复一处口径 bug:原规则把「下单当天及之后」的挂号算作复诊,导致 1096 个周期被开药前的同日面诊误关闭;改为「下单日 + `appointment_min_gap_days`(默认 1,次日起)」之后的挂号才计入(`RevisitDueCalculator`、弹窗实时校验、配置、单测同步),重算后到期未挂号由 328 升至 1185,已面诊关闭 609(459 条在预计复诊日附近、150 条为提前复诊)。该次重算在流水表写入了 1096 条「重新打开(7)」事件,属规则修正产生的痕迹。
|
||||||
|
- P2 已落地:`tcm:revisit-due-notify`(daily/tick,`wecom_enabled` 默认关闭)、主管视图 `tcm.revisitDue/lists|logs` + `/stats/revisit_due` 页面、医助个人页「我的复诊接诊率」卡片(`stats.assistantPerformance/revisitRate`,缓存 600 秒)、SQL 08~10;`generated.php` 已重生成。单测 `RevisitDueNotifyTest`(44) 通过。P2 代码只经实现代理自检与 `php -l`/单测,未做独立复核。
|
||||||
|
- 尚未完成:follow 页处置待产品确认(D2-c);本机无 Node,`npm run lint` / `type-check` 未跑;真库验证(SQL 01~10 按序执行、`tcm:revisit-due-scan --mode=full --dry-run` 压测、`tcm:backfill-inherit-visit-slot --dry-run`、`tcm:auto-assign-pending --dry-run` 分档对照)待有环境后执行;`generated.php` 重生成顺带暴露了 4 个尚无审核条目的 `tcm.followupAudio/*` 只读接口(既有缺口,非本次范围)。
|
||||||
|
分支:`tongji-10-12`(所有行号以该分支 2026-10-12 快照为准)。文档路径:`docs/plans/assistant-revisit-reach-reminder-2026-10-12.md`。
|
||||||
|
SQL 迁移目录:`server/sql/1.9.20261012/`(新增;仓库最新为 `server/sql/1.9.20260928/`,无自动迁移器,按文件序号手工执行)。
|
||||||
|
|
||||||
|
> 本稿由多代理工作流产出:7 个子系统调研 → 3 份独立方案(口径一致性 / 医助体验 / 风险分期优先)→ 3 位评审打分 → 以「医助体验优先」方案为骨架综合,移植口径一致性方案的审计机制与风险分期方案的配置化/口径开关,按评审修正事实错误(见 §0.2),并吸收完整性批评的 9 项补充(快照事实列、二中心过滤、偏移量钩子、exclude 模式语义、自愈上限、轮询语义、过号 cron 钩子包裹、D1-h)。本稿尚未修改任何业务代码;「已核实」仅指源码行号核对,不代表线上已验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 需求原文与解读
|
||||||
|
|
||||||
|
### 0.1 逐条解读
|
||||||
|
|
||||||
|
| # | 产品原文 | 解读 | 本方案落地点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 医助的所有诊数接诊率、继承的患者继承的时候是几诊,就算几诊的接诊率 | 两件事:(a) 复诊接诊率页除逐诊次列外增加「复诊合计」(所有诊次之和的接诊率);(b) 继承指派当前只转移持有人、不进分母(`server/app/adminapi/logic/stats/RevisitRateLogic.php:380-383`),导致继承者「只得分不担责」。改为:继承时患者已完成第 k 诊 → 该患者从第 k+1 诊列起计入继承者分母(每列独立分母) | §1.1 口径、§3.1 `RevisitRateLogic` 改造、§4.1 复诊接诊率页;统计实时重算 + `assign_log.inherit_visit_slot` 快照列用于展示/对账 |
|
||||||
|
| 2 | 患者在我们医院的触达时间——开药天数、到几诊 | 在医助每天看的「中医诊单」列表上,直接显示患者当前第几诊(与接诊率同口径)、本轮开药天数与来源、预计复诊日、首次触达日/在管天数 | §1.2 口径、§2.1 快照表、§4.2 「复诊节点」列(`RevisitNodeCell` 共用组件) |
|
||||||
|
| 3 | 患者该挂号了,但是没有挂号、接诊,系统标红显示(按照开药天数 14、21、28、30) | 预计复诊日 = 用药起算日 + 实际开药天数;到期仍无有效挂号/未面诊 → 行级标红 + 状态 chip;14/21/28/30 理解为常见疗程档位(用于筛选/展示),不是只对这四档提醒,也不是逾期阈值 | §1.3 状态机、§4.2 行高亮/chip/筛选/排序、§5.1 cron |
|
||||||
|
| 4 | 到时间未面诊的会有一个弹窗功能,让员工去点一下延迟处理,越点越上心 | 持有医助登录后台即见右下角汇总卡片;可「去挂号 / 已联系(填承诺日) / 延迟」;延迟间隔随次数递减、卡片形态随次数升级到强制模态;延迟次数在列表与主管视图持续可见 | §1.4 升级规则、§3.3 接口、§4.3 全局组件、§5.2 企微(P2) |
|
||||||
|
|
||||||
|
### 0.2 方案来源与事实修正(评审意见已吸收)
|
||||||
|
|
||||||
|
| 评审指出的问题 | 本稿处理 |
|
||||||
|
|---|---|
|
||||||
|
| 三份方案均写 `.row-unconfirmed` 样式在 `index.vue:2742-2749` | 已核实:`:deep(.el-table__row.row-unconfirmed)` 在 `admin/src/views/tcm/diagnosis/index.vue:2771-2777`,`.row-no-appointment` 在 `:2778-2782`(`:2745` 为 `.video-watch-trigger`) |
|
||||||
|
| 方案 1 用 `operator_name='回退自动分配'` 豁免回退行 | 已核实不可行:`server/app/adminapi/logic/stats/AutoAssignLogLogic.php:167` 写 `$adminName !== '' ? $adminName : '回退自动分配'`,正常为操作人姓名。改为:回退写入时 `to>0` 则 `is_inherit=1`(§3.1),历史行用 `tcm_diagnosis_auto_assign_log.rollback_time`(列见 `server/sql/1.9.20260717/add_auto_assign_log_rollback.sql:6-7`)关联豁免 |
|
||||||
|
| 方案 0 回退改继承未限定 `to>0` | `AutoAssignLogLogic.php:165` 的 `to_assistant_id = $prevAssistantId` 可能为 0;本稿要求 `is_inherit = $prevAssistantId > 0 ? 1 : 0`,与 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:671` 约定一致 |
|
||||||
|
| 方案 2 仅用 `matchPermissionAlias` 别名、不登记菜单 | 已核实无效:`server/app/adminapi/http/middleware/AuthMiddleware.php:111-117` 对不在 `$allUri` 的 URI 直接 `return $next($request)`,`:124` 的别名判断永不执行。本稿全部新 action 登记 A 类菜单并用 `INSERT IGNORE INTO zyt_system_role_menu` 自动授予(模板 `server/sql/1.9.20260803/add_performance_dashboard_menu.sql:50-56`),别名只作兜底 |
|
||||||
|
| 方案 1 把 `assigned_count` 重定义为 `|NI∪IH|` | 保留 `assigned_count` = 新指派数(`RevisitRateLogic.php:129/148` 排序、`revisit_rate/index.vue:107`、`server/app/mcp/catalog/review/stats.php:190-197`、`YejiStatsLogic` 被指派数均依赖该语义),新增 `inherit_count`、`assigned_total_count`、`visit{N}_den`、`visit_all_*` |
|
||||||
|
| 方案 1 「Den_2 差异 = 继承时尚无成交历史的继承数」 | 修正:`entry=max(2,k+1) ≤ 2` 含 k=0 与 k=1 两类继承 |
|
||||||
|
| 行号偏差:POLL_INTERVAL、setInterval、UpdateAppointmentStatus 追加点、Db::commit、甘草回调 | 已核实:`admin/src/components/chat-notify-toast/index.vue:89`(`POLL_INTERVAL = 5000`)、`:127`(`setInterval`)、`:140-141`(`top:72px; right:24px`);`server/app/common/command/UpdateAppointmentStatus.php:55-56` 为两条 `Db::execute`(`:52` 是 dry 分支 return);`DiagnosisLogic.php:742` 为 `Db::commit()`;`server/app/api/controller/GancaoCallbackController.php:327-329` case 90 仅写 `gancao_remark`,`:331-341` case 91/92 置 `fulfillment_status=4`,`:380-383`/`:406-409` 推 5 |
|
||||||
|
| 方案 0 `visit2_rate_legacy decimal(6,2)` | 现有 `visit2_rate decimal(8,2)`(`server/sql/1.9.20260715/add_tcm_diagnosis_auto_assign_log.sql:20`),本稿同精度 |
|
||||||
|
| 方案 0 `idx_to_inherit_time` 对 `buildStatsCore` 无效 | `RevisitRateLogic.php:356-364` 仅按 `create_time<=月末` 过滤、按 `diagnosis_id` 排序;本稿不加该索引 |
|
||||||
|
| 方案 1 `RevisitRateLogic.php:362` 字段列表缺 `from_assistant_id` | 本稿要求 field 增 `lg.from_assistant_id`(用于统计时忽略 from==to) |
|
||||||
|
| 方案 2「exclude 模式逐字节一致」 | 修正表述:既有键的**值**一致,响应新增 `visit{N}_den/visit_all_*/inherit_count` 键 |
|
||||||
|
| 方案 1 H5 `formData :89-101` | 已核实 `admin/src/views/tcm/diagnosis/index_h5.vue:678`;复诊块 `:174-181`;`getCardClass :901-903`;`dh5-tag--warn :1762` |
|
||||||
|
| 方案 0/1 工期标题与正文不一致 | 本稿统一为 §8 的数字 |
|
||||||
|
|
||||||
|
### 0.3 现状关键事实(已核对源码)
|
||||||
|
|
||||||
|
| 事实 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| 分母 = 当月 `is_inherit=0 && to>0` 的医助×诊单去重;继承行进时间线与分子候选、不进分母 | `RevisitRateLogic.php:374-385`(`:380` 为 is_inherit 过滤) |
|
||||||
|
| 分母剔除名下任何时期存在 9/10 订单的诊单 | `RevisitRateLogic.php:387-395`、`fetchRefundOrRejectDiagnosisSet :687` |
|
||||||
|
| 分子:有效订单按 `(create_time,id)` 升序,`effectiveSlot = seq + offset`,持有人 = 时间线上 `t <= ct` 的最后一条 `to`;`slot<2 或 >50` 跳过 | `RevisitRateLogic.php:426-457`(`:438` seq++,`:444-447` 指针,`:448` slot 过滤,`:451` 月份过滤,`:454-456` 归属);`fetchOrderSeqRows :716`、`fetchRevisitSlotStartOffsetMap :736`、`resolveRevisitSlotStartOffset :760` |
|
||||||
|
| 单一分母 `buildMetricPack(int $assigned, array $slotCounts, array $slots)` | `RevisitRateLogic.php:805-817` |
|
||||||
|
| 列范围只看分子命中 `range(2, max(4, maxHitSlot))` | `RevisitRateLogic.php:71-80` |
|
||||||
|
| 自动分配读上月 `visit2_rate`,null 或 <50 跳过 | `server/app/command/AutoAssignPendingDiagnosis.php:224-233` |
|
||||||
|
| 业绩看板「被指派数」三处 `is_inherit=0`,自称与复诊接诊率同口径 | `server/app/adminapi/logic/stats/YejiStatsLogic.php:24, 1781, 1812, 1874, 1894, 2848`;`admin/src/views/fans/yeji.vue:401` |
|
||||||
|
| 业务订单有效口径常量 `[4,9,10]` 与统一过滤方法 | `YejiStatsLogic.php:61`、`:3886-3894` |
|
||||||
|
| 业务订单 `medication_days int(11) NULL`,创建/编辑不校验 | `server/database/migrations/2026_04_07_create_tcm_prescription_order.sql:12`;`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:1080-1104, 1898-1917`;`server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php:71-82` 场景不含该字段 |
|
||||||
|
| 处方 `usage_days` 默认 7;完成面诊自动生成系统代开处方 `usage_days=7, is_system_auto=1` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:373, 1114, 1214, 1226` |
|
||||||
|
| 挂号 `patient_id` 存诊单 id;status 1 已预约 / 2 已取消 / 3 已完成 / 4 已过号 | `server/sql/doctor_appointment.sql`;`server/app/common/model/doctor/Appointment.php:34-39` |
|
||||||
|
| 过号:status=1 超 35 分钟 → 4;status=4 超 8 小时 → 2 | `UpdateAppointmentStatus.php:38-44`,执行 `:55-56` |
|
||||||
|
| 面诊完成 = 人工点「完成」 | `server/app/adminapi/logic/doctor/AppointmentLogic.php:576`(通知 `:603`,系统代开 `:616`);创建 `:280`、取消 `:420`、后台编辑 `:978` |
|
||||||
|
| 指派:事务 + 行锁,写 `assign_log`(12 列,无 delete_time),`to=0` 强制 `is_inherit=0` | `DiagnosisLogic.php:665-746`(`:671`、`:697`、`:722-734`、`Db::commit :742`);基表 `server/database/migrations/2026_03_31_tcm_diagnosis_assign_log.sql`(索引仅 `idx_diagnosis_id`、`idx_create_time`);`server/sql/1.9.20260601/add_diagnosis_assign_log_is_inherit.sql` |
|
||||||
|
| 诊单列表:医助角色强制本人;筛选在 `lists()`/`count()` 双写;排序相关子查询 | `server/app/adminapi/lists/tcm/DiagnosisLists.php:70-173, 531-612`(`:88/:546` role 2;`:94/:551` DataScope;`:77/:539` pendingWideSearch);`resolveListOrderRaw :719`;`unservedAnchorExpr :754`;处方聚合 `:343-366`,`followup_prescription_count :406`;未服务天数 `:459` |
|
||||||
|
| 全局弹窗 `ChatNotifyToast`:5s 轮询、Cache 读即消费 | `admin/src/App.vue:11, 19-23, 60`;`chat-notify-toast/index.vue:89, 127, 140-141`;`server/app/api/logic/ChatNotifyLogic.php:106-117` |
|
||||||
|
| 定时任务调度器:命令抛异常即把 `dev_crontab.status` 置 ERROR 停跑 | `server/app/common/command/Crontab.php:81-87` |
|
||||||
|
| 缓存驱动 File,无分布式锁 | `server/config/cache.php:9` |
|
||||||
|
| 权限:未登记 perms 的接口对所有登录账号放行 | `AuthMiddleware.php:111-117`;`matchPermissionAlias :171`(dailyRecord 别名 `:202-206`) |
|
||||||
|
| 签收时间只在物流库,批量解析函数纯读库 | `server/app/common/service/ExpressTrackingService.php:1642`(`batchResolveSignUnixFromDbForOrders`);签收推 6 `:559`、`auto_sign` 日志 `:567` |
|
||||||
|
| 发货日志 `action='ship'` 仅人工发货写入;甘草回调推 5 不写日志 | `PrescriptionOrderLogic.php:2143`(ship);`GancaoCallbackController.php:380-383, 406-409` |
|
||||||
|
| 三套「几诊」口径并存 | 接诊率:订单序号+offset(`RevisitRateLogic.php:438`);诊单列表:处方张数(`DiagnosisLists.php:406`);一中心:已完成挂号−1(`server/app/adminapi/lists/firstvisit/MyPatientLists.php:283-284`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 口径定义
|
||||||
|
|
||||||
|
### 1.0 统一「诊次」语言(四项需求共用)
|
||||||
|
|
||||||
|
- `E(D)`:诊单 D 名下**有效业务订单** = `tcm_prescription_order` 中 `delete_time IS NULL` 且 `fulfillment_status IS NULL OR NOT IN (4,9,10)`(统一用 `YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery`,`YejiStatsLogic.php:3886-3894`),按 `(create_time, id)` 升序;
|
||||||
|
- `off(D)` = `tcm_diagnosis.revisit_slot_start_offset` 钳到 0~20(`RevisitRateLogic.php:760-771`;列定义 `server/sql/1.9.20260709/add_diagnosis_revisit_slot_start_offset.sql`);
|
||||||
|
- 第 n 笔有效订单的诊次 `slot = n + off(D)`;
|
||||||
|
- **患者当前已完成诊次** `current_slot(D) = |E(D)| + off(D)`;**待复诊诊次** `next_slot = current_slot + 1`(该笔订单产生时会落入复诊接诊率的 `visit{next_slot}_count`,列表与报表数字一致);
|
||||||
|
- **某时刻 t 患者处于第几诊** `slot_at(D, t) = |{o ∈ E(D) : ct(o) < t}| + off(D)`(严格小于,与持有人规则 `t <= ct`(`RevisitRateLogic.php:444`)对偶:同一秒的订单视为「指派之后」、归新持有人)。
|
||||||
|
|
||||||
|
以上全部由新服务 `RevisitVisitSlotService`(§3.1)单点实现;`PrescriptionOrderLists::appendDiagnosisEditVisitSeqFields`(`server/app/adminapi/lists/tcm/PrescriptionOrderLists.php:1471-1524`)改为调用它;`DeptLogic::fetchErCenterRevisitCandidateOrders`(`server/app/adminapi/logic/dept/DeptLogic.php:240-258`,看板复诊 2/3 列,区间内重排、创建人归属、无 offset)是另一套口径,**本次不改**。
|
||||||
|
|
||||||
|
### 1.1 需求 1:复诊接诊率 v2(定义 A,累计型,每列独立分母)
|
||||||
|
|
||||||
|
记月窗 `M=[startTs,endTs]`,诊单 D,医助 A,`R` = 名下存在履约 9/10 订单的诊单集合(不限月份,`RevisitRateLogic.php:687`)。
|
||||||
|
|
||||||
|
**集合**
|
||||||
|
|
||||||
|
- `NI(A)` = {D : ∃ 指派日志 l,`is_inherit=0`、`to=A`、`from≠to`(仅 slot 模式)、`t_l∈M`} \ R (现状分母,字段 `assigned_count` 语义不变)
|
||||||
|
- `IH(A)` = {D : ∃ 指派日志 l,`is_inherit=1`、`to=A`、`from≠to`、`t_l∈M`,且 D ∉ NI(A)} \ R (新增:继承分母)
|
||||||
|
- 同一 (A, D) 当月多条继承取**最早**一条 `t_l`;D 同月既被非继承又被继承指派给同一 A 时只算 NI;
|
||||||
|
- `from == to` 的日志(继承/指派给自己,`DiagnosisLogic::assign` 无校验)统计时忽略;
|
||||||
|
- 回退自动分配行(`AutoAssignLogLogic.php:162-174`):新数据按 §3.1 写 `is_inherit=1`;历史行通过 `tcm_diagnosis_auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id)` 与 `assign_log(diagnosis_id, create_time, operator_admin_id)` 匹配后按继承处理。
|
||||||
|
- **继承时诊次** `k_D = slot_at(D, t_l)`;**起算诊次** `entry_D = max(2, k_D + 1)`(患者已完成 k 诊,继承者只对第 k+1 诊及以后负责;k ≤ 1 的继承等同新指派,从二诊列起算,不产生一诊列);`entry_D > 50` 的继承行不进任何诊次列、只进合计分母。
|
||||||
|
|
||||||
|
**每列独立分母 / 分子 / 比率**(s = 2..S)
|
||||||
|
|
||||||
|
```
|
||||||
|
Den_s(A) = |NI(A)| + |{ D ∈ IH(A) : entry_D ≤ s }|
|
||||||
|
Num_s(A) = 现状不变:当月下单、slot = s、2 ≤ s ≤ 50、下单时点持有人为 A 的订单数(RevisitRateLogic.php:438-456)
|
||||||
|
Rate_s(A) = Num_s(A) / Den_s(A) × 100;Den_s = 0 → null(AutoAssignPendingDiagnosis.php:233 依赖 null 语义)
|
||||||
|
```
|
||||||
|
|
||||||
|
**复诊合计列(「所有诊数接诊率」)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Den_all(A) = |NI(A) ∪ IH(A)| (按诊单去重,输出 assigned_total_count)
|
||||||
|
Num_all(A) = Σ_{s=2}^{50} Num_s(A)
|
||||||
|
Rate_all(A) = Num_all / Den_all × 100 (可超过 100%,语义「人均复诊成交单数」)
|
||||||
|
```
|
||||||
|
|
||||||
|
**部门行 / 合计行**(按诊单去重)
|
||||||
|
|
||||||
|
- `NI_dept = ∪_A NI(A)`;`IH_dept = (∪_A IH(A)) \ NI_dept`,`entry_D` 取该部门内各继承者中的**最小值**;
|
||||||
|
- `Den_s(dept) = |NI_dept| + |{D ∈ IH_dept : entry_D ≤ s}|`;`Num_s(dept) = Σ_A Num_s(A)`(每笔订单唯一归属);
|
||||||
|
- 列范围 `S = min(50, max(4, 当月分子最大 slot, max entry_D))`(修复 `RevisitRateLogic.php:71-80` 只看分子导致继承行静默丢失)。
|
||||||
|
|
||||||
|
**旧口径开关**:请求参数 `inherit_mode=exclude|slot`(移植方案 2)。`exclude` 时完全复现 `RevisitRateLogic.php:380` 现状:`IH(A)=∅`,**不**忽略 `from==to` 行、**不**豁免历史回退行,`Den_s = assigned_count` 对所有 s 成立,`visit{N}_count/rate`、`assigned_count` 的值与线上一致(响应会多出 `visit{N}_den/visit_all_*/inherit_count` 键)。`from≠to` 忽略与回退行按继承处理**仅在 `slot` 模式生效**;`YejiStatsLogic` 三处被指派数与下文 `visit2_rate_new_assign` 均沿用 `:380` 口径(仅 `is_inherit=0`,不做 from≠to/回退豁免),保证「与新指派数同口径」文案成立、过渡期自动分配分档不变。页面默认取 `config('project.revisit_rate.default_inherit_mode')`(建议 `slot`)。
|
||||||
|
|
||||||
|
**供自动分配使用的旧口径字段**:无论模式,`overview` 都输出 `visit2_rate_new_assign = Num_2 / |NI_exclude| × 100`(`NI_exclude` 为 `:380` 现状口径的新指派集合;为 0 → null)。
|
||||||
|
|
||||||
|
**示例计算**(offset=0)
|
||||||
|
|
||||||
|
| 时间 | 事件 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 05-03 | D 第 1 笔有效订单(slot 1) | 持有人 A |
|
||||||
|
| 06-12 | 第 2 笔(slot 2) | A 名下,6 月 `visit2_count(A)+1` |
|
||||||
|
| 08-20 | 第 3 笔(slot 3) | A 名下 |
|
||||||
|
| 09-10 10:00 | A → B,勾选继承 | `k_D = 3`,`entry_D = 4` |
|
||||||
|
| 09-25 | B 名下第 4 笔(slot 4) | 9 月 `visit4_count(B)+1` |
|
||||||
|
|
||||||
|
9 月报表(slot 模式):B 的 `inherit_count=1`、`visit2_den/visit3_den` 不含 D、`visit4_den` 含 D、`visit4_count=1`(若 B 只有这一位患者则 `visit4_rate=100`、`visit2_rate=null`)、`assigned_total_count=1`、`visit_all_count=1`、`visit_all_rate=100`;A 的 9 月数字不变。exclude 模式:B `assigned_count=0`、`visit4_count=1`、`visit4_rate=null`(与线上现状一致)。
|
||||||
|
变体:若 09-10 继承时 D 尚无任何有效订单(k=0)→ `entry=2`,与新指派等价;若 09-25 的订单与继承同一秒 → 订单归 B 且不计入 k。
|
||||||
|
|
||||||
|
### 1.2 需求 2:触达节点字段
|
||||||
|
|
||||||
|
| 字段 | 定义 | 来源 / 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 最新有效订单 `O*` | `E(D)` 中 `(create_time, id)` 最大的一笔 | 退款/取消的订单自动退回上一笔有效单 |
|
||||||
|
| 开药天数 `medication_days` | `COALESCE(NULLIF(O*.medication_days,0), NULLIF(rx.usage_days,0))`,rx = `O*.prescription_id` 对应处方且 `is_system_auto=0 AND void_status=0 AND delete_time IS NULL`;两者皆空 → `days_src=0`(缺天数),**不兜底 7**,列表显示橙色「缺开药天数」促补填 | 配置 `revisit_reach.days_fallback_rx=true`、`days_default=0` |
|
||||||
|
| 天数来源 `days_src` | 0=缺失 1=订单 2=处方 | — |
|
||||||
|
| 档位 `rule_days` | `medication_days ∈ {14,21,28,30}` 取该值,否则 0=其它;仅用于筛选/展示,**到期按实际天数算**(15 天不归 14) | 配置 `revisit_reach.day_buckets` |
|
||||||
|
| 用药起算锚点 `anchor_ts` / `anchor_src` | `COALESCE(签收时间, 发货时间, O*.create_time)`,`anchor_src` 1 签收 / 2 发货 / 3 下单 | 签收:`ExpressTrackingService::batchResolveSignUnixFromDbForOrders`(`:1642`,纯读库);发货:`tcm_prescription_order_log.action='ship'` 最早 `create_time`(仅人工发货,`PrescriptionOrderLogic.php:2143`);下单:`create_time`(可被 `editTime :1152` 修改)。链顺序由 `revisit_reach.anchor_chain` 配置,可收窄为 `['create']` |
|
||||||
|
| 预计复诊日 `due_date` | `DATE(anchor_ts) + medication_days + grace_days(默认 0)` | 缺天数时 NULL |
|
||||||
|
| 当前诊次 / 待复诊诊次 | `current_slot = |E(D)| + off(D)`;`next_slot = current_slot + 1` | §1.0 |
|
||||||
|
| 首次触达日 `first_touch_date` / 在管天数 | `min(首笔有效订单日, 首条 to>0 指派日志日)`;`today − first_touch_date` | 展示在诊次 tag 的 tooltip |
|
||||||
|
| 累计开药天数 `total_medication_days` | Σ 有效订单的(订单天数 ?: 处方天数) | tooltip 可选 |
|
||||||
|
|
||||||
|
### 1.3 需求 3:到期判定状态机(读取时推导,列表 / 弹窗 / 筛选共用)
|
||||||
|
|
||||||
|
`A*` = 下单日之后(`appointment_date >= DATE(O*.create_time) + appointment_min_gap_days`,默认 1 即次日起;**2026-10-14 本地数据验证发现**同一天的面诊几乎都是产生这笔药的那次面诊而非复诊,若计入会把 1096/1705 个周期误关闭)的挂号中,按 `(appointment_date, appointment_time, id)` 最新一条、`status IN (1,3,4)`(`doctor_appointment.patient_id = 诊单 id`,`appointment_time` 空串用 `IFNULL(NULLIF(TRIM(...),''),'00:00:00')`)。`A*` 的状态与日期由 `RevisitDueService::refresh` 与挂号钩子写入快照表**事实列** `appointment_state`(0=无 1=已约 3=已面诊 4=过号)与 `appointment_date`;下表 `state` 由纯函数**只读快照行 + today** 推导,列表 / `pending()` / 主管视图不再各自 join 挂号表,避免口径分叉。
|
||||||
|
|
||||||
|
| 优先级 | `state` | 判定 | chip | 行高亮 | 进弹窗 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `visited` 已面诊 | `A*.status=3` | 绿「已面诊 MM-DD」 | 否 | 否(`closed_state=3`) |
|
||||||
|
| 2 | `booked` 待面诊 | `A*.status=1 AND appointment_date >= today` | 蓝「已约 MM-DD」 | 否 | 否(`closed_state=2`) |
|
||||||
|
| 3 | `missed` 过号未面诊 | `A*.status=4`,或 `status=1 AND appointment_date < today`(cron 未跑兜底) | 红「过号 MM-DD」 | **是** | **是** |
|
||||||
|
| 4 | `contacted` 已联系 | `promised_date >= today` | 蓝描边「已联系·约 MM-DD」 | 否 | 否(`next_remind_at = promised_date 09:00`) |
|
||||||
|
| 5 | `overdue` 到期未挂号 | 无 A*(或仅 status=2)且 `today >= due_date` | 红「超期 N 天」(N=0「今日应挂号」;N ≥ `severe_overdue_days(7)` 深红加粗) | **是** | **是** |
|
||||||
|
| 6 | `warning` 即将到期 | `0 < due_date − today <= warn_days(3)` | 橙「剩 N 天」 | 否 | 否 |
|
||||||
|
| 7 | `pending` 未到期 | `due_date − today > warn_days` | 灰「剩 N 天」 | 否 | 否 |
|
||||||
|
| 8 | `no_days` 缺天数 | 有有效订单但天数缺失 | 橙描边「缺开药天数」 | 否 | 否 |
|
||||||
|
| 9 | `none` | 无有效订单 / 无医助 / 诊单禁用 | — | 否 | 否(`closed_state=5`) |
|
||||||
|
|
||||||
|
补充规则:
|
||||||
|
- `missed`(过号)**不受** `due_date <= today` 限制:患者在到期日前挂号并过号(如 28 天疗程第 10 天过号)同样标红并进弹窗;重新打开(event=7)时 `next_remind_at = now`,当天即弹;
|
||||||
|
- 「已挂号」解除标红只认 `status IN (1,3)`;`2` 不算;`4` 算「过号未面诊」继续标红。过号 8 小时后被 cron 改 2(`UpdateAppointmentStatus.php:42-44`),因此「过号」只在当天可见,次日统一落入 `overdue`(患者确需重新挂号),`missed_count` 保留「曾过号」事实;
|
||||||
|
- `booked` 行的挂号后来变成 2/4 → 由钩子/cron **重新打开**(event=7),`missed_count+1`;
|
||||||
|
- 「接诊」闭环隐含:新有效订单成为新的 `O*`,锚点与到期日重算,旧周期关闭(event=6);
|
||||||
|
- 多笔疗程重叠以最新有效订单为准;
|
||||||
|
- 若产品实际意图是「逾期 14/21/28/30 天逐级加深」,只需把 `severe_overdue_days` 改为阶梯数组,不改结构(移植方案 2)。
|
||||||
|
|
||||||
|
**示例**:订单 09-20 下单、09-22 签收、28 天 → `anchor_src=1`、`due_date=10-20`;10-17~10-19 橙「剩 N 天」;10-20 红「今日应挂号」+ 红行;10-27 深红「超期 7 天」;10-21 挂 10-23 的号 → 蓝「已约 10-23」;10-23 过号 → 当天红「过号 10-23」,次日 cron 置 2 → 红「超期 N 天」、`missed_count=1`。
|
||||||
|
|
||||||
|
### 1.4 需求 4:延迟升级规则(「越点越上心」)
|
||||||
|
|
||||||
|
```
|
||||||
|
snooze():
|
||||||
|
n = snooze_count + 1
|
||||||
|
interval = snooze_intervals[n] ?? snooze_intervals.default // 4h → 2h → 1h → 30min
|
||||||
|
next_remind_at = now + interval,钳入 work_hours 09:00~20:00(落到 20:00 之后 → 次日 09:00)
|
||||||
|
escalation_level = f(n):n=0→0;1~2→1;3~4→2;≥5→3
|
||||||
|
```
|
||||||
|
|
||||||
|
| level | 延迟次数 | 卡片形态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 0 | 白底红色左边条,可最小化为右下角小气泡 |
|
||||||
|
| 1 | 1~2 | 橙色标题栏「已延迟 N 次」,可最小化 |
|
||||||
|
| 2 | 3~4 | 红色标题栏、出现时轻微抖动、不可最小化、「全部延迟」隐藏,只能逐条处理 |
|
||||||
|
| 3 | ≥5 | 该患者单独弹居中模态(`ElMessageBox`,`closeOnClickModal=false`、无 X),按钮仅 [去挂号][已联系][再延迟 30 分钟];P2 同时企微推本人、每日 1 次抄送主管 |
|
||||||
|
|
||||||
|
「已联系」(移植方案 1 并加约束):填患者承诺复诊日 `promised_date`(today ~ today+14),`next_remind_at = promised_date 09:00`,**不增加 `snooze_count`**;每个周期最多 2 次,第 3 次起按一次延迟计入 `snooze_count`;承诺日仍无挂号 → 再弹并清空 `promised_date`。
|
||||||
|
「人工关闭」:医助可填原因(`lost/other` + 备注必填)关闭,但在主管视图标记;新周期自动重开。
|
||||||
|
换医助(含继承):`snooze_count/escalation_level/promised_date` 清零、`next_remind_at=now`,写 event=9(新医助不背前任的账;决策点 D4-c)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据模型变更(SQL 草稿,目录 `server/sql/1.9.20261012/`,均为新增文件)
|
||||||
|
|
||||||
|
### 2.1 `01_add_prescription_order_diag_ct_index.sql`(P0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 诊次序号 / 每诊单最新有效订单 的批量查询覆盖索引(RevisitVisitSlotService、tcm:revisit-due-scan、RevisitRateLogic::fetchOrderSeqRows)
|
||||||
|
-- 现有仅 idx_diagnosis_id(2026_04_07_create_tcm_prescription_order.sql:33)。ADD INDEX 不幂等,重复执行报 1061 可忽略;执行前 SHOW INDEX 确认
|
||||||
|
ALTER TABLE `zyt_tcm_prescription_order`
|
||||||
|
ADD KEY `idx_diag_del_fs_ct` (`diagnosis_id`, `delete_time`, `fulfillment_status`, `create_time`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `02_add_tcm_diagnosis_revisit_due.sql`(P0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 复诊到期快照:每诊单一行(当前周期 = 最新有效业务订单)。
|
||||||
|
-- 写入:php think tcm:revisit-due-scan --mode=full(每日)/ --mode=incremental(每 30 分钟)+ 业务钩子(订单/挂号/指派)即时刷新单条。
|
||||||
|
-- 诊次口径与复诊接诊率一致:有效业务订单(剔 4/9/10、未软删)序号 + tcm_diagnosis.revisit_slot_start_offset(RevisitVisitSlotService)。
|
||||||
|
-- 时间相关状态(到期/预警/超期/过号/已约)在读取时由本表事实列(due_date、appointment_state、appointment_date、promised_date、closed_state)与 CURDATE() 推导,不再 join 挂号表。
|
||||||
|
-- 索引:uk_diagnosis 一诊单一行/幂等 upsert;idx_assistant_open 弹窗拉取与列表 chip 计数;idx_open_due cron 与主管视图;idx_source_order 订单变更钩子。
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_revisit_due` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`diagnosis_id` int unsigned NOT NULL DEFAULT 0 COMMENT '诊单ID(zyt_tcm_diagnosis.id)',
|
||||||
|
`assistant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '当前持有医助(随 tcm_diagnosis.assistant_id 同步,(int) 强转)',
|
||||||
|
`patient_name` varchar(64) NOT NULL DEFAULT '' COMMENT '患者姓名快照',
|
||||||
|
`source_order_id` int unsigned NOT NULL DEFAULT 0 COMMENT '本周期锚点业务订单 zyt_tcm_prescription_order.id(最新有效订单)',
|
||||||
|
`order_create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '锚点订单下单时间',
|
||||||
|
`valid_order_count` smallint unsigned NOT NULL DEFAULT 0 COMMENT '有效订单数',
|
||||||
|
`anchor_ts` int unsigned NOT NULL DEFAULT 0 COMMENT '用药起算时间戳',
|
||||||
|
`anchor_src` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '起算来源:1=签收 2=发货 3=下单',
|
||||||
|
`medication_days` smallint unsigned NOT NULL DEFAULT 0 COMMENT '开药天数(订单 medication_days,缺省回落处方 usage_days;0=缺失)',
|
||||||
|
`days_src` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '天数来源:0=缺失 1=订单 2=处方',
|
||||||
|
`rule_days` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '天数档位:14/21/28/30,0=其它',
|
||||||
|
`total_medication_days` smallint unsigned NOT NULL DEFAULT 0 COMMENT '累计开药天数',
|
||||||
|
`current_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '患者当前已完成诊次(有效订单数+偏移)',
|
||||||
|
`next_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '待复诊诊次 = current_slot+1',
|
||||||
|
`first_touch_date` date NULL COMMENT '首次触达日(首笔有效订单/首条指派取早)',
|
||||||
|
`due_date` date NULL COMMENT '预计复诊日 = DATE(anchor_ts)+medication_days+grace;缺天数为 NULL',
|
||||||
|
`closed_state` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0=进行中 2=已挂号(自动) 3=已面诊(自动) 4=人工关闭 5=失效(无医助/诊单删除或禁用/无有效订单);缺开药天数的行保持 0,由读取侧推导为 no_days',
|
||||||
|
`booked_appointment_id` int unsigned NOT NULL DEFAULT 0 COMMENT '触发关闭或当前待面诊的挂号单 id',
|
||||||
|
`appointment_state` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '锚点订单之后最新有效挂号状态(事实列):0=无 1=已约 3=已面诊 4=过号',
|
||||||
|
`appointment_date` date NULL COMMENT '上述挂号的预约日期(事实列)',
|
||||||
|
`missed_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '本周期过号/已约未到次数',
|
||||||
|
`contact_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '本周期「已联系」次数(上限 2,之后按延迟计)',
|
||||||
|
`contacted_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次「已联系」时间',
|
||||||
|
`promised_date` date NULL COMMENT '患者承诺复诊日期',
|
||||||
|
`snooze_count` smallint unsigned NOT NULL DEFAULT 0 COMMENT '延迟处理次数(越点越上心的依据)',
|
||||||
|
`escalation_level` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '升级等级 0~3 = f(snooze_count)',
|
||||||
|
`next_remind_at` int unsigned NOT NULL DEFAULT 0 COMMENT '下次弹窗时刻;弹窗只取 next_remind_at<=now',
|
||||||
|
`first_shown_at` int unsigned NOT NULL DEFAULT 0 COMMENT '首次弹出时间(仅写一次)',
|
||||||
|
`last_shown_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近弹出时间(统计用,不做节流)',
|
||||||
|
`last_snoozed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近延迟时间',
|
||||||
|
`last_snoozed_by` int unsigned NOT NULL DEFAULT 0 COMMENT '最近延迟人',
|
||||||
|
`handled_at` int unsigned NOT NULL DEFAULT 0 COMMENT '人工关闭时间',
|
||||||
|
`handled_by` int unsigned NOT NULL DEFAULT 0 COMMENT '人工关闭人',
|
||||||
|
`handle_reason` varchar(16) NOT NULL DEFAULT '' COMMENT '人工关闭原因 lost/other',
|
||||||
|
`handle_remark` varchar(200) NOT NULL DEFAULT '' COMMENT '人工关闭说明',
|
||||||
|
`wecom_self_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次企微推送本人(P2)',
|
||||||
|
`wecom_self_count_day` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '当日已推送本人次数(P2)',
|
||||||
|
`wecom_supervisor_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次企微抄送主管(P2)',
|
||||||
|
`computed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近重算时间',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_diagnosis` (`diagnosis_id`),
|
||||||
|
KEY `idx_assistant_open` (`assistant_id`, `closed_state`, `appointment_state`, `due_date`, `next_remind_at`),
|
||||||
|
KEY `idx_open_due` (`closed_state`, `due_date`),
|
||||||
|
KEY `idx_source_order` (`source_order_id`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单复诊到期快照(每诊单一行,列表标红/弹窗/延迟升级共用)';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 `03_add_tcm_diagnosis_revisit_due_log.sql`(P0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 复诊到期事件流水:供审计与「越点越上心」统计(主管视图可按延迟/升级次数排序)。
|
||||||
|
-- 注意:弹窗轮询不写流水(只更新 first/last_shown_at),避免日志膨胀。
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_revisit_due_log` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`due_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'zyt_tcm_diagnosis_revisit_due.id',
|
||||||
|
`diagnosis_id` int unsigned NOT NULL DEFAULT 0 COMMENT '诊单ID',
|
||||||
|
`assistant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '事件时持有医助',
|
||||||
|
`source_order_id` int unsigned NOT NULL DEFAULT 0 COMMENT '事件时周期订单',
|
||||||
|
`event` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=延迟 2=已联系 3=人工关闭 4=自动关闭-已挂号 5=自动关闭-已面诊 6=周期重置(新订单) 7=重新打开(挂号取消/过号) 8=企微推送 9=医助变更 10=形态升级 11=首次弹出',
|
||||||
|
`snooze_count_after` smallint unsigned NOT NULL DEFAULT 0 COMMENT '事件后延迟次数',
|
||||||
|
`level_after` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '事件后升级等级',
|
||||||
|
`overdue_days` smallint NOT NULL DEFAULT 0 COMMENT '事件时超期天数(负=未到期)',
|
||||||
|
`payload` varchar(500) NOT NULL DEFAULT '' COMMENT 'JSON:next_remind_at/promised_date/appointment_id/order_id/remark/from_assistant 等',
|
||||||
|
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作人 admin.id,0=系统',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_due_time` (`due_id`, `create_time`),
|
||||||
|
KEY `idx_assistant_event_time` (`assistant_id`, `event`, `create_time`),
|
||||||
|
KEY `idx_diagnosis_id` (`diagnosis_id`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单复诊到期事件流水';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 `04_add_revisit_due_crontab.sql`(P0,幂等,模板 `server/sql/1.9.20260805/upgrade_qywx_customer_acquisition_customers.sql:53-67`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 依赖:先执行 02/03;服务器 crontab 每分钟 php think crontab;首次 last_time 为空时调度器只记下次时间不执行(Crontab.php:46-54)
|
||||||
|
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
|
||||||
|
SELECT '复诊到期快照-每日全量', 1, 0, '全量重算 tcm_diagnosis_revisit_due(签收时间/过号变化/钩子漏刷兜底)', 'tcm:revisit-due-scan', '--mode=full', 1, '30 8 * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-scan' AND `params`='--mode=full' AND `delete_time` IS NULL);
|
||||||
|
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
|
||||||
|
SELECT '复诊到期快照-增量', 1, 0, '仅重算近 2 小时有订单/挂号/指派变更的诊单,并重评 closed_state=2 的行', 'tcm:revisit-due-scan', '--mode=incremental', 1, '*/30 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-scan' AND `params`='--mode=incremental' AND `delete_time` IS NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 `05_add_revisit_due_menu.sql`(P1,幂等;列清单照抄 `server/sql/1.9.20260505/add_diagnosis_todo_menu.sql:10-20`,授权照抄 `server/sql/1.9.20260803/add_performance_dashboard_menu.sql:50-56`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 复诊到期提醒接口按钮权限,挂在「中医诊单」(perms=tcm.diagnosis/lists) 下。
|
||||||
|
-- AuthMiddleware.php:111-117:未登记的接口对所有登录账号放行,因此 pending/summary/snooze/snoozeAll/contact/close 全部登记;
|
||||||
|
-- 末尾自动授予所有已拥有 tcm.diagnosis/lists 的角色,避免「登记后医助失权」。「全部菜单」角色若已是 * 需人工复核。
|
||||||
|
SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' AND type = 'C' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
|
||||||
|
SELECT @diag_menu_id, 'A', '复诊到期-待办', '', 70, 'tcm.revisitDue/pending', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/pending');
|
||||||
|
-- 同法插入:tcm.revisitDue/summary(71) / snooze(72) / snoozeAll(73) / contact(74) / close(75)
|
||||||
|
|
||||||
|
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
|
||||||
|
SELECT DISTINCT rm.role_id, m2.id
|
||||||
|
FROM zyt_system_role_menu rm
|
||||||
|
JOIN zyt_system_menu m1 ON m1.id = rm.menu_id AND m1.perms = 'tcm.diagnosis/lists'
|
||||||
|
JOIN zyt_system_menu m2 ON m2.perms IN ('tcm.revisitDue/pending','tcm.revisitDue/summary','tcm.revisitDue/snooze','tcm.revisitDue/snoozeAll','tcm.revisitDue/contact','tcm.revisitDue/close');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 `06_add_diagnosis_assign_log_inherit_visit_slot.sql`(P1,移植方案 0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 继承指派时患者所处诊次快照(RevisitVisitSlotService::slotAtTime 口径:继承时刻之前的有效实单数 + 偏移)。
|
||||||
|
-- 统计以实时重算为准(事后退款/取消/改 offset 会联动);本列用于指派记录面板展示与对账(php think tcm:backfill-inherit-visit-slot --verify)。
|
||||||
|
-- ALTER 不幂等,重复执行报列已存在可忽略。
|
||||||
|
ALTER TABLE `zyt_tcm_diagnosis_assign_log`
|
||||||
|
ADD COLUMN `inherit_visit_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '继承时患者已完成诊次(实单数+偏移);0=非继承/未计算' AFTER `is_inherit`,
|
||||||
|
ADD COLUMN `inherit_visit_slot_calc_time` int unsigned NOT NULL DEFAULT 0 COMMENT '快照计算/回填时间' AFTER `inherit_visit_slot`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 `07_alter_auto_assign_log_rate_version.sql`(P1,移植方案 0,精度对齐 `add_tcm_diagnosis_auto_assign_log.sql:20`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 自动分配日志同时记录两套口径的二诊率,便于回溯分档依据
|
||||||
|
ALTER TABLE `zyt_tcm_diagnosis_auto_assign_log`
|
||||||
|
ADD COLUMN `visit2_rate_new_assign` decimal(8,2) NULL DEFAULT NULL COMMENT '旧口径二诊率(分母仅新指派)' AFTER `visit2_rate`,
|
||||||
|
ADD COLUMN `rate_version` varchar(8) NOT NULL DEFAULT 'v1' COMMENT '分档所用口径:v1=仅新指派 v2=每列独立分母' AFTER `visit2_rate_new_assign`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 P2:`08_add_revisit_due_team_menu.sql`(C 类页面 `/stats/revisit_due`,perms `tcm.revisitDue/lists`、A 类 `tcm.revisitDue/logs|export`,模板 `server/sql/1.9.20260715/add_auto_assign_log_menu.sql`)、`09_add_revisit_due_notify_crontab.sql`(`tcm:revisit-due-notify --mode=daily` 09:00 与 `--mode=tick` 每 30 分钟)。
|
||||||
|
|
||||||
|
### 2.9 配置(`server/config/project.php`,追加两段,与 `data_scope`(`:146`)同风格)
|
||||||
|
|
||||||
|
```php
|
||||||
|
'revisit_rate' => [
|
||||||
|
'default_inherit_mode' => 'slot', // 页面默认口径:slot=继承按继承时诊次计入 | exclude=继承不计分母(旧)
|
||||||
|
'auto_assign_tier_field' => 'visit2_rate_new_assign', // AutoAssign 分档字段:过渡期用旧口径,产品确认后改 'visit2_rate'
|
||||||
|
'inherit_min_next_slot' => 2,
|
||||||
|
],
|
||||||
|
'revisit_reach' => [
|
||||||
|
'limit_er_center' => true, // 候选诊单限二中心子树医助
|
||||||
|
'anchor_chain' => ['sign', 'ship', 'create'],
|
||||||
|
'days_fallback_rx' => true,
|
||||||
|
'days_default' => 0, // 0=缺天数不计算不标红
|
||||||
|
'day_buckets' => [14, 21, 28, 30],
|
||||||
|
'grace_days' => 0,
|
||||||
|
'warn_days' => 3,
|
||||||
|
'severe_overdue_days' => 7, // 可改为阶梯数组
|
||||||
|
'row_highlight_priority' => 'overdue_first', // overdue_first | unconfirmed_first
|
||||||
|
'toast_enabled' => false, // 弹窗总开关(上线先 false,由主管用 chip 清存量)
|
||||||
|
'toast_limit' => 20,
|
||||||
|
'snooze_intervals' => [1 => 14400, 2 => 7200, 3 => 3600, 'default' => 1800],
|
||||||
|
'work_hours' => ['09:00', '20:00'],
|
||||||
|
'contact_max_per_cycle' => 2,
|
||||||
|
'promise_max_days' => 14,
|
||||||
|
'wecom_enabled' => false,
|
||||||
|
'wecom_daily_cap' => 2,
|
||||||
|
'supervisor_admin_ids' => [], // dept 表无 leader 字段(server/app/common/model/dept/Dept.php),P2 用配置过渡
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端改动(按文件)
|
||||||
|
|
||||||
|
### 3.1 需求 1 与诊次公共服务
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `server/app/common/service/tcm/RevisitVisitSlotService.php`(新增) | `declare(strict_types=1)`,无 likeadmin 头。从 `RevisitRateLogic` 搬出 `fetchOrderSeqRows`(`:716-729`)、`fetchRevisitSlotStartOffsetMap`(`:736-753`)、`resolveRevisitSlotStartOffset`(`:760-771`)为 public static(原方法保留为委托,减少 diff);新增 `batchCurrentSlot(array $diagIds): array<int,{seq,offset,current_slot,latest_order,first_order_time,valid_order_count,total_medication_days}>`(MySQL 5.7 无窗口函数:`GROUP BY diagnosis_id` 取 `COUNT/MAX(create_time)` 后按 `(diagnosis_id, create_time) IN (...)` 回表、PHP 按 id 取最大)、`slotAtTime(int $diagId, int $ts): int`、纯函数 `slotBefore(array $orderCreateTimes, int $offset, int $atTs): int`。 |
|
||||||
|
| `server/app/adminapi/logic/stats/RevisitRateCalculator.php`(新增) | 纯函数 `compute(array $logRows, array $orderRows, array $offsetMap, array $refundRejectSet, array $rollbackKeySet, int $startTs, int $endTs, string $inheritMode): array{diagsByAssistant, inheritEntryByAssistant, slotOrdersByAssistant, pairsRaw, inheritPairs, maxEntry}`,承接 `buildStatsCore :366-458` 的内存计算。继承诊次算法:订单循环中处理每笔订单(时间 ct)**之前**,把该诊单所有 `t_l <= ct` 且未定值的继承日志定为 `k = seq_before + offset`(即在 `:438` `$seq++` 之前取值);诊单切换/循环结束补 `k = seq_total + offset`;无任何有效订单的继承诊单循环后补 `k = offset`;`entry = max(inherit_min_next_slot, k+1)`。忽略 `from == to` 行;`rollbackKeySet`(`diagnosis_id|create_time|operator_admin_id`)命中的历史回退行按继承处理。 |
|
||||||
|
| `server/app/adminapi/logic/stats/RevisitRateLogic.php` | 类注释 `:10-31` 重写口径;`buildStatsCore` 查询字段 `:362` 增 `lg.from_assistant_id`;收集 `is_inherit=1 && aid>0 && t∈M` 行;`:387-395` 拒收/退款剔除集合并入继承诊单;查询 `tcm_diagnosis_auto_assign_log` 中 `rollback_time>0` 的 `(diagnosis_id, rollback_time, rollback_admin_id)` 组成 `rollbackKeySet`;调用 Calculator;`overview :71-80` slots 上界加入 `maxEntry`;`buildStatsCore :461` 的 `universeIds` 并集加入 `inheritEntryByAssistant` 的键,`:470-473` 二中心子树过滤时同步 `unset($inheritEntryByAssistant[$aid])`,返回体 `:477-488` 新增 `inheritEntryByAssistant / inheritPairs / maxEntry`,`assignLines` 的 `applyRowScope :176` 使用同一过滤后的集合(否则当月仅有继承行的医助会从 rows 静默丢失,或逃过二中心过滤);`:98-123/:125-153` 部门/合计行按 §1.1 去重;`buildMetricPack(int $newAssigned, array $inheritEntryByDiag, array $slotCounts, array $slots)` 输出 `assigned_count`(=新指派,语义不变)、`inherit_count`、`assigned_total_count`、`visit{N}_count/_den/_rate`、`visit_all_count/_den/_rate`、`visit2_rate_new_assign`;返回体增 `inherit_mode`;`assignLines :173-228` 遍历 `pairsRaw ∪ inheritPairs`,支持 `kind=new|inherit|all`,行增 `assign_type`、`inherit_visit_slot(k)`、`entry_slot`;`visitOrderLines :240` 允许 `slot=0` 表示全部 s≥2;`resolveInheritMode()` 白名单读参数/配置。排序 `:129/:148` 仍按 `assigned_count`。 |
|
||||||
|
| `server/app/adminapi/controller/stats/RevisitRateController.php` | 无新方法(`:20-50` 的 `$this->request->get()` 整体透传 `inherit_mode/kind/slot=0`)。 |
|
||||||
|
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php` | `assign :665-746`:`$isInherit===1` 时在事务内调用 `RevisitVisitSlotService::slotAtTime($id, $now)` 写 `inherit_visit_slot/inherit_visit_slot_calc_time`(`:722-734` insert 增两列);`from == to` **不硬拦**(避免打断批量指派流程;`assign :666` 返回 bool,`DiagnosisController::assign :296-315` 与 `MyPatientController::assign :69-93` 均不透传 data,故不改后端返回结构),由前端在指派弹窗内用当前行 `assistant_id` 与目标比较后提示;`:849-851` 指派记录列表输出 `inherit_visit_slot_text`(「继承时第 k 诊」);`Db::commit() :742` 之后调 `RevisitDueLogic::onAssistantChanged($id, $toAssistantId)`(try/catch);`setRevisitSlotStartOffset :756-795`(入口 `DiagnosisController.php:104-110`)成功后调 `RevisitDueLogic::onDiagnosisChanged($id, 'offset')`——偏移量会直接改变 `current_slot/next_slot`,否则列表「第 N 诊」要等次日全量 cron 才更新。 |
|
||||||
|
| `server/app/adminapi/logic/stats/AutoAssignLogLogic.php:162-174` | 回退写 `'is_inherit' => $prevAssistantId > 0 ? 1 : 0` 并写 `inherit_visit_slot`;跨消费者影响:业绩看板「被指派数」(`YejiStatsLogic.php:1812/1894/2848` 按 `is_inherit=0`)在回退月少计一条,需产品确认(D1-e)。回退后调 `RevisitDueLogic::onAssistantChanged`。 |
|
||||||
|
| `server/app/command/BackfillInheritVisitSlot.php`(新增) | 命令 `tcm:backfill-inherit-visit-slot`,选项 `--dry-run`、`--recalc`、`--verify`(输出快照 vs 实时漂移清单:log_id/diagnosis_id/snapshot/realtime/原因)、`--month=`;遍历 `is_inherit=1 AND to_assistant_id>0`,按诊单分块 2000 调 `RevisitVisitSlotService`;整体 try/catch 返回 0。 |
|
||||||
|
| `server/app/command/AutoAssignPendingDiagnosis.php` | `:224` 改为 `RevisitRateLogic::overview(['month'=>$statMonth, 'inherit_mode'=>'slot'])`,`:231` 读 `config('project.revisit_rate.auto_assign_tier_field')` 指定字段;写 `tcm_diagnosis_auto_assign_log` 时同时写 `visit2_rate`、`visit2_rate_new_assign`、`rate_version`;`--dry-run` 输出两套口径的分档名单对照;类注释 `:26-42` 更新。 |
|
||||||
|
| `server/app/adminapi/lists/stats/AutoAssignLogLists.php:50-51` | 输出 `visit2_rate_new_assign`、`rate_version_text`。 |
|
||||||
|
| `server/app/adminapi/logic/stats/YejiStatsLogic.php` | 仅改注释/文案 `:24, 858-861, 1781, 1874, 2620, 2912-2913`:「与复诊接诊率的『新指派数』(assigned_count) 同口径」;三处 `where('lg.is_inherit', 0)` 不动。 |
|
||||||
|
| `server/app/adminapi/lists/tcm/PrescriptionOrderLists.php:1471-1524` | `appendDiagnosisEditVisitSeqFields` 改用 `RevisitVisitSlotService::batchCurrentSlot`,消除逐诊单 N+1,`global_visit_seq/counts_for_revisit_rate` 规则不变。 |
|
||||||
|
| `server/config/project.php`、`server/config/console.php` | 配置段(§2.9);注册 `'tcm:backfill-inherit-visit-slot' => 'app\\command\\BackfillInheritVisitSlot'`(放 `:56-58` 旁)。 |
|
||||||
|
| `server/tests/RevisitRateInheritSlotTest.php`(新增) | 纯单元(模式 1):k 计算、同一秒对偶、from==to 忽略、多次继承取最早、NI 与 IH 同诊单只算 NI、k=0 归二诊、部门去重与 entry 取 min、slots 上界、exclude 模式退化。`server/tests/RevisitRateContractTest.php`(新增):ReflectionMethod 守住「分子过滤仍用 `applyPrescriptionOrderNotCancelledForPerformanceQuery`、`slot<2` 跳过、holder `t <= ct`」。 |
|
||||||
|
|
||||||
|
### 3.2 需求 2/3:快照与列表
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `server/app/common/service/tcm/RevisitDueCalculator.php`(新增) | 纯函数:输入 `latestOrder, validOrderCount, offset, rxUsageDays, signTs, shipTs, appointmentsAfterOrder[], firstTouchTs, today, cfg` → 输出 §1.2 事实字段 + §1.3 `state`。无 DB,可单测。 |
|
||||||
|
| `server/app/common/service/tcm/RevisitDueService.php`(新增) | `refreshDiagnoses(array $diagIds, int $operatorId = 0, string $reason = '')`:批量取最新有效订单(`batchCurrentSlot`)、处方 `usage_days`(`is_system_auto=0 AND void_status=0 AND delete_time IS NULL`)、签收(`batchResolveSignUnixFromDbForOrders`)、发货(`tcm_prescription_order_log action='ship' MIN(create_time)`,`anchor_chain` 不含 `ship` 时跳过)、挂号(`patient_id IN`,`status IN (1,3,4)`,`appointment_date >= 最小锚点日`),调用 Calculator,`Db::name('tcm_diagnosis_revisit_due')->duplicate([...])->insert()`(用法参照 `server/app/adminapi/logic/qywx/CustomerLogic.php:1058`);`source_order_id` 变化 → 重置延迟字段 + event=6;`assistant_id` 变化 → 重置延迟字段 + event=9;`to=0`/诊单删除/无有效订单 → `closed_state=5`;`closeByAppointment / reopenByAppointment / reevaluateBookedRows()`;所有写入 (int) 强转 `assistant_id`。 |
|
||||||
|
| `server/app/common/model/tcm/DiagnosisRevisitDue.php`、`DiagnosisRevisitDueLog.php`(新增) | 仿 `server/app/common/model/tcm/DiagnosisTodo.php:31, 37, 40-53`:`autoWriteTimestamp=true`、`dateFormat=false`、状态常量 + `*_text` 访问器。 |
|
||||||
|
| `server/app/adminapi/logic/tcm/RevisitDueLogic.php`(新增,需求 3/4 共用) | `extends BaseLogic`。统一钩子入口 `onDiagnosisChanged(int $diagnosisId, string $reason)`、`onAssistantChanged(int $diagnosisId, int $to)`、`onAppointmentChanged(int $diagnosisId, int $appointmentId)`,全部 `try { RevisitDueService::... } catch (\Throwable $e) { Log::warning(...) }`,不改变主流程返回值(模式同 `AppointmentLogic.php:597-611`);需求 4 的 `pending/snooze/contact/close` 业务(§3.3)。 |
|
||||||
|
| `server/app/adminapi/lists/tcm/DiagnosisLists.php` | ① `lists()` 在未服务天数段(`:431-461`)之后批量读 `tcm_diagnosis_revisit_due`(`whereIn diagnosis_id`),为每行追加 `revisit_due` 对象(`current_slot, next_slot, medication_days, days_src, rule_days, anchor_src, anchor_date, order_date, due_date, days_left, overdue_days, state, state_text, snooze_count, escalation_level, promised_date, missed_count, first_touch_date, in_care_days, closed_state`);**自愈**(移植方案 1/2):本页诊单若快照缺行、或 `batchCurrentSlot` 得到的最新有效订单 id ≠ 快照 `source_order_id`,仅当请求 `page_size ≤ 100` 时,对本页前 50 行内的不一致行调用 `RevisitDueService::refreshDiagnoses` 后再返回,其余交给增量 cron(`ListsValidate` 允许 `page_size` 直到 `project.lists.page_size_max`=25000,避免一次 GET 刷新成千行并逐批解析签收);比对条件同时包含 `batchCurrentSlot.current_slot ≠ 快照 current_slot`(覆盖偏移量修改)。替代「待计算」占位。② 新增私有方法 `applyRevisitDueFilters($query, bool $pendingWideSearch)`(移植方案 0),在 `lists()`(`:106-160` 段)与 `count()`(`:561-610` 段)各调用一次,参数:`revisit_state`(due=overdue∪missed / warning / booked / visited / contacted / no_days / pending)、`revisit_days_bucket`(14/21/28/30/other);实现为 `whereExists(SELECT 1 FROM zyt_tcm_diagnosis_revisit_due rd WHERE rd.diagnosis_id = {diag}.id AND ...)`,如 `due`(= overdue ∪ missed):`rd.closed_state=0 AND (rd.appointment_state=4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE())) AND (rd.promised_date IS NULL OR rd.promised_date < CURDATE())`。③ `resolveListOrderRaw :719-752` 新增 `sort_revisit_due=asc|desc` 分支,锚点 `(SELECT rd.due_date FROM zyt_tcm_diagnosis_revisit_due rd WHERE rd.diagnosis_id = {diag}.id AND rd.closed_state = 0 LIMIT 1)`,NULL 置后写法同 `:724-731`,与 `sort_unserved_days` 互斥。 |
|
||||||
|
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php` | 详情/只读接口附带同一 `revisit_due` 对象(供 `readonly.vue`)。 |
|
||||||
|
| 钩子接入(P0,均经 `RevisitDueLogic` 包裹) | `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`:`create :978`(成功后)、`editTime :1152`、`withdraw :2250`、`complete :3234`(目标状态 ∈ {9,10} 时)、`refund :3351`;`server/app/api/controller/GancaoCallbackController.php:331-341`(case 91/92 置 4 后);`server/app/adminapi/logic/doctor/AppointmentLogic.php`:`create :280`(写入后)、`cancel :420`、`complete :576`、`adminEdit :978`;`server/app/common/command/UpdateAppointmentStatus.php:55-56` 两条 `Db::execute` 之后调 `RevisitDueLogic::onAppointmentStatusCronDone()`(内部 try/catch + `Log::warning`;该命令 `execute()` 本身无 try/catch,若直接调 Service 抛异常会让 `Crontab::start` 把既有 `update_appointment_status` 任务置 ERROR 停跑);`DiagnosisLogic::assign :742` 之后;`DiagnosisLogic::setRevisitSlotStartOffset` 成功后;`server/app/command/AutoAssignPendingDiagnosis.php:507-518` 写日志后;`AutoAssignLogLogic.php:162-174` 回退后。甘草推 5(`:380-383, 406-409`)与签收推 6(`ExpressTrackingService.php:559`)不加钩子,由增量 cron 覆盖(锚点由下单日切到签收日最长滞后 30 分钟)。 |
|
||||||
|
| `server/app/command/RevisitDueScan.php`(新增) | 命令 `tcm:revisit-due-scan`,选项 `--mode=full|incremental`、`--dry-run`、`--diagnosis-id=`、`--date=`(基准日回放)。候选:`tcm_diagnosis.delete_time IS NULL AND status=1 AND CAST(assistant_id AS UNSIGNED) > 0`(`limit_er_center` 时再限 `DeptLogic::getErCenterSubtreeDeptIdSet`(`DeptLogic.php:214`)+ `admin_dept`);incremental 只取近 2 小时 `tcm_prescription_order.update_time`、`doctor_appointment.update_time`、`tcm_diagnosis_assign_log.create_time`、`tcm_diagnosis.update_time`(偏移量/医助变更)有变化的诊单 + `closed_state=2` 的行重评;分块 500 调 `RevisitDueService::refreshDiagnoses`;候选还并入 `closed_state<>5` 的既有快照行(诊单删除/禁用/释放医助后由全量重算推进为失效);防重入:按 mode 在 Cache 记录上次开始时间,5 分钟内跳过(钩子/自愈会持续刷新 `computed_at`,不能用它判断);`--date` 与今天不同时强制演练模式;**整体 try/catch 并 `return 0`、按块隔离异常**(`Crontab.php:81-87` 异常即停任务);输出刷新/新增/关闭/缺天数/耗时统计。注册到 `server/config/console.php`。 |
|
||||||
|
| `server/tests/RevisitDueCalculatorTest.php`(新增) | 状态机 9 态、锚点回退链三种、天数回落三种、15 天不归 14 档、承诺日/过号重开、新周期重置。 |
|
||||||
|
|
||||||
|
### 3.3 需求 4:接口
|
||||||
|
|
||||||
|
控制器 `server/app/adminapi/controller/tcm/RevisitDueController.php`(新增,URL 自动映射 `/adminapi/tcm.revisitDue/<action>`,权限串 `tcm.revisitDue/<action>`):
|
||||||
|
|
||||||
|
| 方法 | 接口 | 入参 | 行为 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `tcm.revisitDue/pending` | — | 本人 `assistant_id = adminId AND closed_state=0 AND (appointment_state=4 OR (due_date IS NOT NULL AND due_date <= CURDATE())) AND (promised_date IS NULL OR promised_date < CURDATE()) AND next_remind_at <= now`,≤ `toast_limit` 条,按 `escalation_level desc, overdue_days desc`;返回前对每条做轻量实时校验(EXISTS 新挂号 1/3 → 顺手关闭并剔除;最新有效订单 id ≠ `source_order_id` → refresh);`first_shown_at` 为 0 时写一次 + event=11,`last_shown_at` 每次更新;**不做服务端软节流**,每次返回当前全集,前端以响应为准整体替换卡片列表(被他人处理、换医助、cron 关闭的行自然消失,不需要 removed_ids);`toast_enabled=false` 返回空;`$this->data()`(show=0)。返回 `{items:[...], summary:{due_total, max_overdue_days, warning_total}}` |
|
||||||
|
| GET | `tcm.revisitDue/summary` | — | 仅计数(供右下角小气泡/顶栏),不更新 shown |
|
||||||
|
| POST | `tcm.revisitDue/snooze` | `id` | 条件更新 `UPDATE ... SET snooze_count=snooze_count+1, escalation_level=?, next_remind_at=?, last_snoozed_at=?, last_snoozed_by=? WHERE id=? AND assistant_id=? AND closed_state=0 AND next_remind_at<=?`;`affected=0` 也返回当前行(幂等);写 event=1,等级变化再写 event=10;`success('已延迟,HH:mm 再提醒', $data, 1, 1)` |
|
||||||
|
| POST | `tcm.revisitDue/snoozeAll` | `ids[]` | 仅 `escalation_level <= 1` 的项,逐项同 snooze |
|
||||||
|
| POST | `tcm.revisitDue/contact` | `id, promised_date, remark?` | `promised_date ∈ [today, today+promise_max_days]`;`contact_count < contact_max_per_cycle` 时不增 `snooze_count`,否则按一次延迟计;`next_remind_at = promised_date 09:00`;event=2 |
|
||||||
|
| POST | `tcm.revisitDue/close` | `id, reason in {lost,other}, remark(必填,≤200)` | `closed_state=4`(条件 `closed_state=0`),event=3;医助本人可操作,主管视图标记 |
|
||||||
|
| GET | `tcm.revisitDue/lists`(P2) | 分页 + `assistant_id/dept_id/state/days_bucket/min_snooze/keyword` | `RevisitDueLists`(`HasDataScopeFilter::applyDataScopeByOwner('assistant_id')`,`server/app/common/lists/Traits/HasDataScopeFilter.php:25`),支持导出 |
|
||||||
|
| GET | `tcm.revisitDue/logs`(P2) | `due_id` | 流水 |
|
||||||
|
|
||||||
|
配套:`server/app/adminapi/validate/tcm/RevisitDueValidate.php`(新增,`id require|number|gt:0`、`promised_date date`、`reason in:lost,other`、`remark max:200`,scene `snooze/snoozeAll/contact/close`,写法同 `server/app/adminapi/validate/tcm/DiagnosisTodoValidate.php`);`server/app/adminapi/lists/tcm/RevisitDueLists.php`(P2,新增)。所有写操作在 Logic 内校验 `assistant_id === adminId` 或超管(role_id=1)/数据范围可见,不依赖路由层。File 缓存无锁(`server/config/cache.php:9`),互斥全靠条件 UPDATE;cron 不改 `snooze_count/next_remind_at`(除新周期/换人),接口不改 cron 字段,列级隔离。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端改动
|
||||||
|
|
||||||
|
### 4.1 复诊接诊率页(需求 1)
|
||||||
|
|
||||||
|
`admin/src/views/stats/revisit_rate/index.vue`
|
||||||
|
- 筛选栏加 `el-radio-group`「口径:继承按继承时诊次计入(新)/ 继承不计分母(旧)」→ `inherit_mode`,默认取后端返回;切换即 `loadData`。
|
||||||
|
- 「当月被指派数」列(`:107`)标题改「新指派数」;新增「继承数」列(`inherit_count`,slot 模式显示,点击下钻 `kind=inherit`);新增「被指派合计」(`assigned_total_count`)。
|
||||||
|
- 每个 N 诊「接诊率」单元格(`:151-157`):主显示 `62.5%`,下方灰色小字 `5 / 8`(`visit{N}_count / visit{N}_den`);表头 tooltip 改为「分母 = 当月新指派数 + 起算诊次 ≤ N 的继承数」。
|
||||||
|
- 追加「复诊合计单数」「复诊合计接诊率」(`visit_all_count/visit_all_rate`),点击打开 `visitOrderLines(slot=0)`。
|
||||||
|
- KPI 卡(`:351-358`)增「当月复诊合计接诊率」;`getSummaries`(`:367-383`)正则扩为 `^visit(\d+|all)_(count|den|rate)$` 并加 `inherit_count/assigned_total_count`;`:402` 过滤 `s >= 2` 保留。
|
||||||
|
- 被指派明细弹窗(`:162-236`,列 `:196-197`)新增「指派类型」(新指派/继承 tag)、「继承时诊次 k」、「起算诊次」。
|
||||||
|
- 口径说明 popover(`:41-60`)按模式切换两段文案,注明「继承分母自 2026-06(is_inherit 列加入)起生效」。
|
||||||
|
- `admin/src/api/stats.ts:208-235`:参数类型加 `inherit_mode?: 'exclude'|'slot'`、`kind?`、`slot` 允许 0;注释更新。
|
||||||
|
- `admin/src/views/fans/yeji.vue:401` tooltip 改「与复诊接诊率『新指派数』同口径」;`admin/src/views/stats/auto_assign_log/index.vue:69,140` 显示口径版本与两套率。
|
||||||
|
- 指派弹窗 `admin/src/views/tcm/diagnosis/index.vue:520-521`、`admin/src/views/tcm/follow/index.vue:329-330`、`admin/src/views/first_visit/my_patients/index.vue:255-258`:当「当前医助>0 且 目标≠当前」时默认勾选「继承」并弹确认(文案:该患者当前第 k 诊,继承后从第 k+1 诊起计入您的接诊率;k 来自列表行 `revisit_due.current_slot`);目标 == 当前时提示「目标医助与当前相同」(D1-d,P1)。`admin/src/views/tcm/diagnosis/components/AssignLogPanel.vue:15-17` 继承列旁显示「继承时第 k 诊」。
|
||||||
|
|
||||||
|
### 4.2 诊单列表(需求 2/3)
|
||||||
|
|
||||||
|
**PC `admin/src/views/tcm/diagnosis/index.vue`**
|
||||||
|
- 「复诊」列(`:281`,min-width 120)改造为「复诊节点」列(min-width 180),吞掉「开方」列(`:304`,width 72),净增宽度≈0。单元格抽成共用组件 `admin/src/views/tcm/diagnosis/components/RevisitNodeCell.vue`(新增;PC / H5 / 只读共用),四行:
|
||||||
|
|
||||||
|
```
|
||||||
|
[第3诊] 已开方 · 王医生 ← 诊次 tag(tooltip:有效订单 3 笔 + 偏移 0;待复诊第 4 诊;首次触达 03-02 · 在管 224 天;累计开药 84 天)
|
||||||
|
09-20 开药 · 28 天 (签收起) ← 最新有效订单日 · 开药天数 · 锚点来源;缺天数时橙色「缺开药天数」
|
||||||
|
药尽 10-18 [超期 3 天] ← 预计复诊日 + 状态 chip(§1.3)
|
||||||
|
已延迟 2 次 · 已联系 约 10-15 ← 仅 snooze_count>0 或已联系时显示
|
||||||
|
```
|
||||||
|
- 状态文案/class 映射放 `admin/src/utils/revisitDue.ts`(新增);chip 色复用 `unserved-red #dc2626 / unserved-orange #ea580c / unserved-green #16a34a`(`:2665-2681`)与 `#4A5DFF`;el-tag 用法同 NEW 标签(`type="danger" size="small" effect="dark"`)。
|
||||||
|
- 行级标红:`getRowClassName`(`:1347-1350`)新增 `row-revisit-overdue`(state ∈ {overdue, missed})、`row-revisit-overdue-deep`(≥ `severe_overdue_days`),优先级按配置默认 **复诊超期 > 未确认 > 未挂号**;样式复制 `:deep(.el-table__row.row-unconfirmed)`(`:2771-2777`)把 `--el-color-warning` 换 `--el-color-danger`,deep 版本背景 10%。
|
||||||
|
- 顶部 chip 区(`:7-72`,「待预约」chip `:27`)后新增「复诊到期 N」(danger 色,样式仿 `.chip-pending-booking` `:2301`),点击设 `formData.revisit_state='due'` 并默认 `sort_revisit_due='asc'`;角标在 `fetchDateCounts`(`:1061-1082`)的 `Promise.all` 追加 `buildDateCountRequestPayload({ revisit_state: 'due' })`;chip active 互斥条件加入 `formData.revisit_state === ''`。
|
||||||
|
- 「更多筛选」(`:107-152`)新增「复诊状态」下拉 → `revisit_state`、「开药天数档」下拉(14/21/28/30/其它)→ `revisit_days_bucket`。
|
||||||
|
- 「复诊节点」列 `sortable="custom" prop="revisit_due_date"`;`handleTableSortChange`(`:1248`)改为按 prop 分派、互斥清空 `sort_unserved_days / sort_revisit_due`。
|
||||||
|
- `formData`(`:787`)新增 `revisit_state / revisit_days_bucket / sort_revisit_due`,同步 6 处:`handleReset :1302`、`clearSecondaryFiltersWhenPendingAssignWideSearch :938`、`buildSharedDiagnosisFilterPayload :889`、`buildDateCountRequestPayload :906`、chip active 判定、各 Tab 点击处理器(`:984-1057`)。
|
||||||
|
- `?id=` 处理(`:2232`)扩展 `action=appointment` → `appointmentRef.value?.open(row)`(`appointmentRef :1356`、`handleAppointment :1955-1961`),供弹窗「去挂号」深链。
|
||||||
|
|
||||||
|
**H5 `admin/src/views/tcm/diagnosis/index_h5.vue`**:复诊块(`:174-181`)替换为 `RevisitNodeCell` 紧凑模式;`formData`(`:678`)加 `revisit_state`;chip 区加「复诊到期」;`getCardClass`(`:901-903`)加 `dh5-card--overdue`(accent 色同 `:1676`);tag 复用 `dh5-tag--warn`(`:1762`)。
|
||||||
|
**只读页 `admin/src/views/tcm/diagnosis/readonly.vue:23-27`**:在「未服务」徽标旁加「第 N 诊 · 药尽 MM-DD · 超期 N 天」徽标,着色复用 `:200-205` 的 class。
|
||||||
|
**`admin/src/views/tcm/follow/index.vue`**(diagnosis 旧副本,组件同名 `tcmDiagnosis` `:548`,固定 `only_has_prescription=1` `:578`,「第 N 次复诊」= 处方张数 `:155-157`):待产品确认是否仍在用(D2-c);若在用则改用 `RevisitNodeCell` 并改组件名。
|
||||||
|
|
||||||
|
### 4.3 全局弹窗(需求 4)
|
||||||
|
|
||||||
|
新组件 `admin/src/components/revisit-due-toast/index.vue`(新增),在 `admin/src/App.vue:60` 与 `ChatNotifyToast` 并列挂载,复用 `showChatNotifyToast` 条件(`:19-23`,避免未绑企微 code=10 反复打断)。**不放诊单页内**(菜单 `is_cache=0`,`server/sql/tcm_diagnosis_menu.sql:13`,离开页面即卸载)。
|
||||||
|
|
||||||
|
- 位置:固定**右下角**(`bottom:24px; right:24px; z-index:3000`),避免与右上角 `ChatNotifyToast`(`chat-notify-toast/index.vue:140-141` `top:72px; right:24px`)叠放;`body.h5-mode` 下改为底部全宽。
|
||||||
|
- 形态:**一张汇总卡**(不是一人一张):标题「⚠ N 位患者到期未挂号」+ 每行「患者 · 第 N 诊·X 天 · 药尽 MM-DD · 超期 N 天 / 过号 · 曾过号 N 次」+ 按钮 [去挂号][已联系][延迟 Xh];超出 5 条折叠「还有 N 位 → 查看全部」(跳诊单页并激活「复诊到期」chip);底部「全部延迟 1 小时」仅当所有项 level ≤ 1 时显示;最小化仅 level ≤ 1 可用。
|
||||||
|
- 去挂号:`router.push({ path:'/tcm/diagnosis', query:{ id, action:'appointment' } })`,本地乐观移除,挂号成功由钩子关闭。
|
||||||
|
- 已联系:内联日期选择(默认今天+3,范围今天~+14)+ 备注 → `contact`。
|
||||||
|
- 延迟:`snooze` → 项消失,`ElMessage`「已延迟,14:30 再提醒(第 2 次)」。
|
||||||
|
- level 3:该患者单独 `ElMessageBox`(`admin/src/utils/feedback.ts` 已封装)。
|
||||||
|
- 轮询:60 秒(对比 chat 的 5 秒),首载立即一次,`visibilitychange` 回前台立即一次,`document.hidden` 跳过(参照 `index.vue:1117`);多标签页:`localStorage['revisit_due_toast:last_fetch']` 跨标签 30 秒节流 + `BroadcastChannel('revisit-due-toast')` 广播 snooze/contact/close 结果同步移除;按 `id` 去重。
|
||||||
|
- API:`admin/src/api/revisitDue.ts`(新增)`revisitDuePending/Summary/Snooze/SnoozeAll/Contact/Close/Lists/Logs`。
|
||||||
|
- P2 主管视图 `admin/src/views/stats/revisit_due/index.vue`(新增,菜单 `/stats/revisit_due`,component `/stats/revisit_due/index`):筛选 + 表格(患者/医助/部门/第 N 诊/开药天数/预计复诊/超期天数/延迟次数/已联系/承诺日/状态)+ 流水抽屉;`v-perms="['tcm.revisitDue/lists']"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 定时任务与通知
|
||||||
|
|
||||||
|
| 命令 | 频率 | 职责 | 期 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tcm:revisit-due-scan --mode=full`(`server/app/command/RevisitDueScan.php` 新增) | 每日 08:30 | 全量重算快照(签收时间落库、过号变化、钩子漏刷兜底) | P0 |
|
||||||
|
| `tcm:revisit-due-scan --mode=incremental` | 每 30 分钟 | 近 2 小时有订单/挂号/指派变更的诊单 + `closed_state=2` 行重评 | P0 |
|
||||||
|
| `tcm:backfill-inherit-visit-slot`(`server/app/command/BackfillInheritVisitSlot.php` 新增) | 一次性手工 + 需要时 `--verify` | 回填/校验 `inherit_visit_slot` | P1 |
|
||||||
|
| `tcm:revisit-due-notify --mode=daily|tick`(`server/app/command/RevisitDueNotify.php` 新增) | 09:00 / 每 30 分钟 | 企微推送(§5.2) | P2 |
|
||||||
|
| 既有 `update_appointment_status`(`UpdateAppointmentStatus.php`) | 线上频率需确认 | `:55-56` 之后追加 `RevisitDueLogic::onAppointmentStatusCronDone()`(try/catch 包裹),使当天过号即进标红/弹窗 | P0 |
|
||||||
|
| 既有 `tcm:auto-assign-pending` | 每日 09:00 | 读 `auto_assign_tier_field` 指定字段分档,日志双记录 | P1 |
|
||||||
|
|
||||||
|
命令约定:`setName/setDescription/addOption('dry-run')`,`execute()` 整体 try/catch 返回 0、按块隔离异常(`Crontab.php:81-87`);`dev_crontab.params` 仅 64 字符;注册于 `server/config/console.php`(`:56-58` 旁)。
|
||||||
|
|
||||||
|
### 5.2 企微联动(P2,`wecom_enabled=false` 默认关闭)
|
||||||
|
- daily:每位有 `overdue/missed` 行的医助一条汇总「您有 N 位患者到期未挂号,最久 X 天」;`WechatWorkAppMessageService::sendTextToUser`(`server/app/common/service/wechat/WechatWorkAppMessageService.php:130`),接收人 `admin.work_wechat_userid`(取法同 `server/app/command/DiagnosisTodoNotify.php:94-98`),未绑定跳过并写 event=8 payload 记录,不算失败。
|
||||||
|
- tick:`escalation_level>=3 AND next_remind_at<=now` 的行推本人(每条每日 ≤ `wecom_daily_cap`);`snooze_count>=5` 首次达到时抄送 `supervisor_admin_ids` 1 次/日。
|
||||||
|
- 推送只在命令内发(接口不同步 curl);逐条 `usleep(200ms)`;异常不得抛到 `Crontab::start`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 权限、菜单与 MCP 目录登记
|
||||||
|
|
||||||
|
1. **新 action 全部登记 A 类菜单**(§2.5),并用 `INSERT IGNORE INTO zyt_system_role_menu` 自动授予已拥有 `tcm.diagnosis/lists` 的角色;原因:`AuthMiddleware.php:111-117` 对未登记 URI 直接放行,`:124` 的 `matchPermissionAlias` 只对已登记 URI 生效。可在 `matchPermissionAlias :171-251` 增加「拥有 `tcm.diagnosis/lists` 即视为拥有 `tcm.revisitdue/pending|summary|snooze|snoozeall|contact|close`」作为兜底(写法同 dailyRecord 别名 `:202-206`)。
|
||||||
|
2. 需求 1 不新增接口(复用 `stats.revisitRate/overview|assignLines|visitOrderLines|deptOptions`,`server/sql/1.9.20260610/add_revisit_rate_menu.sql`)。
|
||||||
|
3. P2 主管视图 C 类菜单 `tcm.revisitDue/lists` + A 类 `logs/export`(§2.8);执行后 `AdminAuthCache::clearAuthCache`(`server/app/common/cache/AdminAuthCache.php:115`)或等缓存过期;「全部菜单」角色需复核。
|
||||||
|
4. MCP 目录:`server/app/mcp/catalog/review/stats.php:173-209` 四条 revisitRate 的 note/params_allow 加 `inherit_mode/kind/slot=0`,`:46-62` autoAssignLog 补口径说明;`server/app/mcp/catalog/review/tcm.php` 为 `tcm.revisitDue/lists|logs` 写 `status: pending`(含患者姓名,待评审),写接口/轮询端点 `excluded`;执行 `php app/mcp/cli/catalog.php --write` 重生成 `generated.php`(`server/app/mcp/catalog/review/README.md`);`php server/tests/AiMcpUnitTest.php` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 兼容、回填与灰度/回滚
|
||||||
|
|
||||||
|
| 项 | 做法 |
|
||||||
|
|---|---|
|
||||||
|
| 需求 1 历史数据 | 口径实时重算,历史月份切换 `inherit_mode` 即可对照;`is_inherit` 2026-06-01 加列前的转接全为 0,无法回溯(popover 注明)。`php think tcm:backfill-inherit-visit-slot --dry-run` → 正式回填 → `--verify` 漂移为 0。 |
|
||||||
|
| 自动分配分档 | `auto_assign_tier_field` 默认 `visit2_rate_new_assign`(旧口径),上线日分档不变;`--dry-run` 输出两套分档名单给产品对照,确认后改配置为 `visit2_rate`,不发版;历史 `auto_assign_log.visit2_rate` 不回溯,`rate_version='v1'` 标识。回滚 = 改回配置。 |
|
||||||
|
| 需求 1 页面回滚 | `default_inherit_mode='exclude'`,字段值回到现状。 |
|
||||||
|
| 快照首次初始化 | 部署后 `php think tcm:revisit-due-scan --mode=full`(分块 500,预计数千~数万诊单 1~5 分钟,需在测试库压测;可先 `anchor_chain=['create']` 跳过签收/发货查询);表为空或缺行时列表自愈(§3.2),不影响展示。 |
|
||||||
|
| 首日弹窗雪崩 | `toast_enabled=false` 上线 → 主管用「复诊到期」chip 清存量 → 开启;初始化时对 `due_date < 上线日−30 天` 的历史逾期行把 `next_remind_at` 置为上线日+1 09:00(移植方案 0)。 |
|
||||||
|
| `medication_days` 缺失 | 回落处方 `usage_days`(剔除系统代开/作废),仍缺不标红并显示「缺开药天数」;上线前核查 `SELECT COUNT(*) FROM zyt_tcm_prescription_order WHERE delete_time IS NULL AND (medication_days IS NULL OR medication_days=0)`;补填入口复用 `PrescriptionOrderDetailDrawer.vue:996` 的服用参数修补(`PrescriptionOrderLogic.php:5802-5869`)。 |
|
||||||
|
| `tcm_diagnosis.assistant_id` 列类型未知 | 候选查询用 `CAST(assistant_id AS UNSIGNED) > 0`,写入 (int) 强转;上线前 DESC 确认。 |
|
||||||
|
| P0/P1/P2 回滚 | P0:停两条 crontab、前端隐藏「复诊节点」新内容(恢复「复诊/开方」列),表保留;P1:`App.vue` 去掉挂载一行 + `toast_enabled=false`;P2:停 notify crontab。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 分期交付与工期(P0/P1/P2)
|
||||||
|
|
||||||
|
| 期 | 内容 | 工期(人日) | 上线步骤 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0 需求 2/3** | `RevisitVisitSlotService`、`RevisitDueCalculator/Service`、模型、快照表 + 流水表 + 索引、`tcm:revisit-due-scan`(full/incremental)、钩子接入、`DiagnosisLists` 字段/自愈/筛选/排序、`RevisitNodeCell` + PC 列改造 + 行标红 + chip + 筛选、H5/只读页同步、配置段、单测 | 7(后端 4、前端 2.5、联调 0.5) | SQL 01~04 → 部署后端 → `php think tcm:revisit-due-scan --mode=full` → 部署前端 → 抽样核对 20 个诊单(`--dry-run --diagnosis-id=X` 与页面一致) |
|
||||||
|
| **P1 需求 4 + 需求 1** | 接口 6 个 + Validate/Logic、全局组件(阶梯/升级/已联系/多标签去重)、`action=appointment`、菜单 SQL 05;需求 1:Calculator 重构、每列分母、合计列、下钻扩展、`inherit_mode`、快照列 + 回填命令、AutoAssign 可配字段 + 双记录、文案与 MCP 目录、指派弹窗默认勾继承 | 8(后端 4.5、前端 3、联调 0.5) | SQL 05~07 → 部署 → 清权限缓存 → `tcm:backfill-inherit-visit-slot` → `tcm:auto-assign-pending --dry-run` 对照 → `toast_enabled=false` 清存量 → 开启弹窗 |
|
||||||
|
| **P2 增强** | 企微 daily/tick + 主管抄送、主管团队视图页 + `RevisitDueLists`、触达时间线接口、follow 页处置、「补天数」快捷入口 | 4 | SQL 08~09 → 部署 → 角色勾权限 → `wecom_enabled` 观察一周后开启 |
|
||||||
|
|
||||||
|
合计 19 人日;含 15% 缓冲约 22 人日(前后端并行约 3 周)。P0 可独立上线;P1 依赖 P0 的服务与表;P2 依赖 P1 的接口。
|
||||||
|
|
||||||
|
每期前置自检:`php -l` 全部改动文件;`php server/tests/RevisitDueCalculatorTest.php`、`RevisitRateInheritSlotTest.php`、`RevisitRateContractTest.php`、`AiMcpUnitTest.php`;`admin` 下 `npm run type-check && npm run lint`;docker 环境跑一次 `php think tcm:revisit-due-scan --dry-run`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险
|
||||||
|
|
||||||
|
1. **口径变更连带**:`visit2_rate` 在 slot 模式下变化,影响 `AutoAssignPendingDiagnosis` 分档;过渡期用旧口径字段,页面与分档「对不上」需在自动分配日志页标注口径版本。
|
||||||
|
2. **继承勾选质量**:继承靠人工勾选、无默认推断(`DiagnosisLogic.php:671`),漏勾则仍按新指派计;P1 换人场景默认勾选 + 确认缓解;2026-06 前转接无法回溯。
|
||||||
|
3. **回退行改继承的跨消费者影响**:`AutoAssignLogLogic` 写 `is_inherit=1` 会使业绩看板「被指派数」(`is_inherit=0`)在回退月少计一条,需产品确认(D1-e)。
|
||||||
|
4. **快照与实时漂移**:事后退款/取消/改 offset 会让 `inherit_visit_slot` 与实时值不一致;统计以实时为准,`--verify` 定期跑、`--recalc` 可重算。
|
||||||
|
5. **`medication_days` 缺失**:不兜底 7 会有「缺天数」无 due_date 的行,依赖医助补填;上线前查分布。
|
||||||
|
6. **签收时间异步**:`express:auto-update` / `tcm:backfill-sign-time`(`server/config/console.php:52`)在线上是否启用未知;锚点由下单日切到签收日时 due_date 会后移 2~5 天;UI 标注来源,增量 cron 30 分钟内刷新。甘草自动发货不写 `ship` 日志,锚点会回退到下单日。
|
||||||
|
7. **过号 8 小时自动变取消**(`UpdateAppointmentStatus.php:42-44`),且该任务线上是否启用未知;「过号未面诊」只在当天可见,次日统一归「到期未挂号」。
|
||||||
|
8. **`DiagnosisLists` 性能与双写**:已有处方聚合 + 三表未服务天数 + 3 个相关子查询排序;新增筛选用 EXISTS 单表索引、排序用单行子查询;自愈最多刷新 25 行;`applyRevisitDueFilters` 单方法两处调用防遗漏。验收门槛:列表 P95 增幅 ≤150ms。
|
||||||
|
9. **全量 cron 耗时无压测依据**:含签收解析(`ExpressTrackingService.php:1642`,200/批)与 `tcm_prescription_order_log`(DDL/索引不在仓库)聚合;先在测试库压测,必要时 `anchor_chain=['create']`。
|
||||||
|
10. **权限模型**:未登记接口默认放行(`AuthMiddleware.php:111-117`);本稿全部登记 + 自动授予 + Logic 内归属校验;自建角色仍需人工复核。
|
||||||
|
11. **Crontab 异常即停**(`Crontab.php:81-87`):新命令必须吞异常返回 0;File 缓存无锁,一切并发控制靠条件 UPDATE。
|
||||||
|
12. **弹窗疲劳与「无脑点延迟」**:首发开关、限 20 条、「已联系」免计次但有上限、延迟次数主管可见;上线后一周调参。
|
||||||
|
13. **多标签页/多设备**:BroadcastChannel/localStorage 只覆盖同浏览器;跨设备靠 `next_remind_at` 去重,可能各弹一次。
|
||||||
|
14. **`tcm_diagnosis.assistant_id` 列类型未知**、`tcm_prescription_order_log` 无 DDL、MySQL 5.7 无窗口函数:DBA 上线前核对。
|
||||||
|
15. **follow 页**(`tcm/follow/index.vue`)为旧副本且组件同名 `tcmDiagnosis`,不同步会出现两页「复诊」口径不一致。
|
||||||
|
16. **钩子分散**(订单 5 处 + 甘草 1 处 + 挂号 4 处 + 过号 cron + 指派/自动分配/回退 3 处):任一漏接由 30 分钟增量 cron、列表自愈、`pending()` 实时校验三层兜底。
|
||||||
|
17. **MCP 目录滞后**:参数/接口变更后未重生成 `generated.php` 会导致 AI 查询参数校验落后,列入每期上线清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 需产品确认的决策点(每条给推荐选项)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 选项 | 推荐 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1-a | 需求 1 继承分母采用哪种定义 | A 累计型:继承行按继承时诊次 k 进入 slot ≥ k+1 的每一列分母,分子不变 / B2 单列型:只进 slot=k+1 列且分子只计继承后首单 / C 取消 is_inherit 剔除 | **A**。分子不动、历史 N 诊单数不变、可与订单明细逐笔对账;B2 改分子语义,C 不体现「几诊」 |
|
||||||
|
| D1-b | 「几诊」指已完成第 k 诊还是即将发生的第 k+1 诊 | 从第 k+1 诊列起计入 / 从第 k 诊列起计入 | **k+1**。第 k 诊订单归前任持有人,继承者只能产生 k+1 及以后成交;k ≤ 1 的继承与新指派等价 |
|
||||||
|
| D1-c | 「所有诊数接诊率」含义;是否展示一诊 | 新增复诊合计列、不展示一诊 / 合计列 + 一诊列 / 仅动态展示所有诊次列(现状) | **合计列、不展示一诊**。二中心患者多已在一中心下首单(offset 机制即为此) |
|
||||||
|
| D1-d | 换人(from>0 且 to>0)时前端是否默认勾选「继承」并确认;目标==当前是否拦截 | 默认勾选 + 二次确认,目标相同仅提示 / 保持手动勾选 / 后端硬拦截目标相同 | **默认勾选 + 确认;不硬拦**(避免打断批量指派流程,统计侧忽略 from==to) |
|
||||||
|
| D1-e | 自动分配回退行如何计 | 回退写 is_inherit=1(to>0 时)+ 历史行按 rollback_time 关联豁免 / 保持 is_inherit=0 / 单独排除 | **第一项**。原医助重新接手不是新增工作量;副作用是业绩看板被指派数少计一条 |
|
||||||
|
| D1-f | AutoAssign 分档是否立即跟随新口径 | 过渡期用 visit2_rate_new_assign,一个月后切换 / 立即切换 / 永久旧口径 | **过渡一个月**,`--dry-run` 对照后改配置 |
|
||||||
|
| D1-g | 业绩看板「被指派数」语义 | 保留仅新指派、改 tooltip 文案 / 同步纳入继承 | **保留并改文案** |
|
||||||
|
| D1-h | 医助本人能否看到「我的 N 诊接诊率」 | 在医助个人业绩页(`admin/src/views/stats/assistant-performance/index.vue`,后端 `AssistantPerformanceLogic`)加「我的 N 诊接诊率」卡片,复用 `RevisitRateLogic::overview` 按本人 assistant_id 过滤 / 仅管理层在复诊接诊率页查看 / 诊单页顶部展示 | **个人业绩页加卡片(P2)**。复诊接诊率页权限不按 DataScope 过滤(`RevisitRateLogic.php:460-475` 仅限二中心子树),不宜直接开放给医助 |
|
||||||
|
| D2-a | 「触达时间」展示字段 | 列内:第 N 诊/最新开药·天数·锚点/预计复诊;tooltip:首次触达日/在管天数/累计开药 / 全部进列 / 仅天数 + 诊次 | **列 + tooltip**(控制表格宽度) |
|
||||||
|
| D2-b | 用药起算锚点 | 签收 → 发货 → 下单 回退链并标注来源 / 只用下单日 / 只用签收日 | **回退链**,`anchor_chain` 可收窄 |
|
||||||
|
| D2-c | follow 页处置 | 仍在用则同步 RevisitNodeCell 并改组件名 / 下线合并 / 不处理 | **先确认线上菜单是否挂该页;无人用则下线** |
|
||||||
|
| D3-a | 「14/21/28/30」含义与缺天数处理 | 按实际天数算到期,四档仅作标签/筛选;缺天数回落处方 usage_days(剔除系统代开),仍缺显示「缺开药天数」不标红 / 只对四档提醒 / 缺天数兜底 7 / 四档为逾期阈值阶梯 | **第一项**;若实为阈值阶梯,仅改 `severe_overdue_days` 为数组 |
|
||||||
|
| D3-b | 预警/严重阈值 | 到期前 3 天橙、到期红、≥7 天深红(可配) / 无预警 / 其它数值 | **3/7 天**,走配置 |
|
||||||
|
| D3-c | 行高亮优先级 | 复诊超期 > 未确认 > 未挂号 / 未确认 > 复诊超期 > 未挂号 / 只标单元格 | **复诊超期优先**,`row_highlight_priority` 可切 |
|
||||||
|
| D3-d | 「已挂号」解除标红标准 | status∈{1,3} 且 appointment_date ≥ 锚点订单日即解除;4/2 不算 / 必须 status=3 / 1/3/4 均算 | **第一项** |
|
||||||
|
| D3-e | 提醒范围 | 限二中心子树且 assistant_id>0 / 全院有医助诊单 / 含未指派诊单 | **限二中心 + 有医助**,`limit_er_center` 可放开 |
|
||||||
|
| D4-a | 弹窗对象 | 仅持有医助本人;主管看 P2 列表 + 企微抄送 / 主管也弹所辖 / 其它部门纳入 | **仅本人** |
|
||||||
|
| D4-b | 延迟阶梯与形态 | 4h→2h→1h→30min,第 5 次起居中模态,工作时段 09:00~20:00 / 24h→12h→4h→1h / 固定 24h 不升级 | **第一项**,全部走配置 |
|
||||||
|
| D4-c | 换医助后延迟次数 | 清零并立即对新医助提醒 / 保留迁移 / 保留但不提醒 | **清零**(度量个人态度,新医助不背前任账),event=9 留痕 |
|
||||||
|
| D4-d | 「已联系」约束 | 每周期 ≤2 次、承诺日 ≤+14 天、第 3 次起按延迟计 / 不限制 / 已联系等同一次延迟 | **第一项** |
|
||||||
|
| D4-e | 医助能否自行关闭 | 可填原因关闭,主管视图标记 / 仅主管可关闭 / 可直接关闭 | **可填原因关闭并标记** |
|
||||||
|
| D4-f | 企微与「主管」定义 | P2 默认关闭,开启后 daily 推本人 + level≥3 推本人 + snooze≥5 每日抄送配置名单主管 / dept 表新增 leader 字段 / 不做企微 | **P2 配置方式起步**(`Dept.php` 无 leader 字段) |
|
||||||
|
| D5 | 同一诊单当月多条继承/既新指派又继承给同一医助 | 只算新指派;多次继承取最早 / 取最晚 / 各算一次 | **第一项**(与现有医助×诊单去重一致) |
|
||||||
|
| D6 | 同一秒指派与订单归属 | 沿用 `t<=ct` 归新持有人,k 用 `ct<t` 严格小于 / 订单先于指派 | **沿用** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 验收标准
|
||||||
|
|
||||||
|
### 需求 1
|
||||||
|
1. 示例(§1.1):9 月 slot 模式 B 的 `inherit_count=1`、`visit4_den` 含 D、`visit2_den/visit3_den` 不含、`visit4_count=1`、`assigned_total_count=1`;exclude 模式 B `assigned_count=0`、`visit4_rate=null`,且 `from==to` 行与历史回退行按现状计入,`assigned_count/visit{N}_count/visit{N}_rate` 与线上逐值一致。
|
||||||
|
2. 同一诊单当月既新指派又继承给同一医助 → 只计 NI;多次继承取最早。
|
||||||
|
3. 继承时无任何有效订单(k=0)→ 起算二诊,与新指派等价;继承与订单同一秒 → 订单归新持有人且 k 不含该单。
|
||||||
|
4. slots 覆盖 `max entry`:继承时第 6 诊的患者在只有二~四诊成交的月份也显示七诊列(分母 1、单数 0)。
|
||||||
|
5. 部门行 `visit{N}_den` ≤ 子行之和(去重);合计行同理;`visit_all_count = Σ visit{N}_count`。
|
||||||
|
6. 任一医助任一列:`visit{N}_den` = 被指派明细(`kind=all`)中「新指派行数 + entry≤N 的继承行数」;`visit{N}_count` = 该列下钻订单行数。
|
||||||
|
7. `php think tcm:auto-assign-pending --dry-run` 在默认配置下分档与上线前一致,并输出两套口径对照;日志行含 `visit2_rate_new_assign/rate_version`。
|
||||||
|
8. `tcm:backfill-inherit-visit-slot --verify` 漂移为 0;`AssignLogPanel` 显示「继承时第 k 诊」与下钻一致。
|
||||||
|
9. `RevisitRateInheritSlotTest`、`RevisitRateContractTest`、`AiMcpUnitTest` 通过;`generated.php` 重生成。
|
||||||
|
10. 当月仅有继承行的非二中心医助不出现在 overview/assignLines;仅有继承行的二中心医助出现且 `assigned_count=0、inherit_count≥1`。
|
||||||
|
|
||||||
|
### 需求 2
|
||||||
|
1. 诊单 3 笔有效订单(第 2 笔已退款)→ 「第 2 诊」,最新有效订单为第 3 笔;列表「第 N 诊」= 业务订单 tab `global_visit_seq` 最大值 = 接诊率该诊单最后一笔 slot。
|
||||||
|
2. `medication_days=NULL`、处方 `usage_days=14`(非系统代开)→ 天数 14、`days_src=2`;处方为系统代开 → 「缺开药天数」且无 due_date。
|
||||||
|
3. 有签收 → `anchor_src=1`,due = 签收日 + 天数;仅人工发货日志 → 2;都无 → 3 并标注「按下单日估算」。
|
||||||
|
4. 新建业务订单后刷新列表,节点即刻切到新周期(钩子/自愈),不等 cron。
|
||||||
|
|
||||||
|
### 需求 3
|
||||||
|
1. §1.3 示例全路径(剩 N 天 → 今日应挂号 → 超期 7 天深红 → 已约 → 过号 → 次日超期且 `missed_count=1`)行色与 chip 正确;患者在到期日前挂号并过号 → 当天红「过号」并进弹窗(不受 due_date 限制)。
|
||||||
|
2. 医生点「完成接诊」→ 绿「已面诊」;之后新订单 → 新周期灰「剩 N 天」。
|
||||||
|
3. 顶部「复诊到期 N」角标 = 筛选后列表 `count`(lists/count 双写一致);医助只看本人;组长按 DataScope;待分配宽搜下新筛选/排序被跳过。
|
||||||
|
4. 排序「复诊节点」升序 → 超期最多在前,NULL 在后;与「未服务天数」排序互斥。
|
||||||
|
5. 列表 P95 响应增幅 ≤150ms;`tcm:revisit-due-scan --dry-run --diagnosis-id=X` 输出与页面一致;命令抛 DB 异常时 `dev_crontab.status` 不被置 ERROR;`onAppointmentStatusCronDone` 抛异常时 `update_appointment_status` 任务同样不被置 ERROR。
|
||||||
|
6. 业务订单 tab 修改诊单偏移量后,刷新列表「第 N 诊」即时更新(钩子/自愈),与业务订单 tab `global_visit_seq` 一致。
|
||||||
|
|
||||||
|
### 需求 4
|
||||||
|
1. 医助 A 有 3 位超期患者 → 登录 2 秒内右下角一张汇总卡,按超期天数降序;主管 B 不弹;`toast_enabled=false` 时 `pending` 返回空。
|
||||||
|
2. 点「延迟」→ 项消失,`snooze_count=1`、`next_remind_at ≈ now+4h`(钳入 09:00~20:00,20:30 点则次日 09:00);再弹时标题变橙「已延迟 1 次」;第 3 次红色不可最小化;第 5 次居中模态仅三按钮。
|
||||||
|
3. 点「已联系」填 10-15 → 项消失、列表 chip「已联系·约 10-15」、`snooze_count` 不变;第 3 次已联系按延迟计;10-15 09:00 仍无挂号再弹。
|
||||||
|
4. 点「去挂号」→ 跳诊单页自动打开挂号弹窗;挂号成功后卡片与红行同时消失(`closed_state=2`)。
|
||||||
|
5. 两个标签页同时打开仅一处可见新项;一处延迟另一处同步消失;重复点击 `snooze` 不双计;另一浏览器挂号成功后,本浏览器下一轮轮询卡片消失。
|
||||||
|
6. 诊单改派给 C → A 不再弹、C 立即弹且 `snooze_count=0`,流水 event=9。
|
||||||
|
7. 非医助账号(无 `tcm.diagnosis/lists`)调 `pending` 返回权限不足;医助 B 对 A 的行调 `snooze` 失败。
|
||||||
|
8. `wecom_enabled=true` 时 level≥3 行每日 ≤2 条企微,未绑定账号记录不中断。
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 回访录音提炼:实现与发布门禁
|
||||||
|
|
||||||
|
## 当前交付边界
|
||||||
|
|
||||||
|
源码在隔离工作副本 `/Users/long/.codex/worktrees/followup-audio/zyt`。原目录 `/Users/long/Work/zyt` 的已有源码保持不变;功能位于 `codex/followup-audio` 分支,初始提交 `70be2fc70`。未推送、未迁移生产、未部署。入口默认关闭。
|
||||||
|
|
||||||
|
已完成网页患者编辑入口、诊单绑定私有分片上传、独立持久异步任务、Dify原始音频传递、可编辑审阅草稿、显式人工确认、多表原子写入、同日真实记录明细、历史跟踪备注、来源审核轨迹及90天清理。
|
||||||
|
|
||||||
|
**真实模型门禁仍未通过**:9月29日本地 Dify 探针返回 `CONFIG_MISSING`;10月7日已按用户指定兼容接口发起合成音频检查,返回 `AUDIO_NOT_PROCESSED`,详见文末。不能把 HTTP 200、上传成功或文本能力当成音频识别成功。媒体测试中的完整一小时WAV→MP3、HTTP回环模型和浏览器合成数据不是实际Dify识别验收;混合口音需代表性脱敏样例。
|
||||||
|
|
||||||
|
## 2026-10-08 双声道修订(优先于下方早先混音验收)
|
||||||
|
|
||||||
|
- `asr_then_llm` 对双声道默认按15秒原始时间窗分别转写左右声道,不再使用混音。`ASR_STEREO_CHUNK_SECONDS=15`(1..30)独立于mono的120秒设置;两声道总段数仍上限1000,>2声道明确拒绝。
|
||||||
|
- 新checkpoint v2和提供方指纹绑定声道数、`separate-fixed-windows-v1`策略、窗口长度与exact-zero策略。证据增加不可修改的`channel`、原始区间;mono/旧结果兼容读取,旧未知请求不能改绑/重放。
|
||||||
|
- 只有转换后的PCM16k/s16le逐字节全零,才以`local_silence`本地完成并保留区间,无上游请求ID。任何非零低音量仍送ASR;这不是VAD或按音量丢段。
|
||||||
|
- 页面增加左右声道片段、原音/左/右回听和人工角色映射:`unconfirmed`、`left_service`、`right_service`。患者侧标签是“患者/家属端”,不能只按声道推定本人。角色标注不自动重提炼;保存/切换角色取消全部选中,须重新逐项核对。新stereo未保存角色禁止apply,角色及审核人进入加密审计。
|
||||||
|
- 4份获准录音在私有一次性MySQL均通过完整Upload→queued→独立Worker→左右ASR→Qwen→review;338个声道时间窗全部覆盖,314次真实ASR、24个本地数字静音、4次提炼。原始文件不变,全部11个候选初始未选中且须复核。
|
||||||
|
- 按转写上下文核对,仍有约数变精确值、含混姓名/电话号码、未提及病史补阴性等错误,某样本还重复生成大量待核实文字。**分声道实现通过,不等于内容准确度通过;4份语义门槛仍FAIL**,也未取得逐字人工回听金标准。
|
||||||
|
- 隔离诊单实际测试13项通过:未确认角色不能写入、确认角色清选择、旧版本拒绝、伪造channel拒绝、切换角色重审、明确采纳1个有依据字段、审计带角色、重复新增0行;没有真实患者业务写入。
|
||||||
|
- 本轮后端26个脚本、前端64项、质量41项及独立复核通过;全量类型检查仍60项基线诊断,无本功能新增。完整前端构建通过,无发布脚本。原生浏览器WebAudio/实机听感验收仍缺,严格mock和源码审核不能替代实际试听。
|
||||||
|
- 所有生产开关继续关闭。本轮没有部署应用或改变模型服务/GPU;不会因为用户猜测一边客服一边患者,就预填已确认角色。下方早先混音、缓存导入与失败记录保留为历史证据。
|
||||||
|
|
||||||
|
## 2026-10-08 最新结论:两阶段链路可用,语义准确度门禁未通过
|
||||||
|
|
||||||
|
已增加 `asr_then_llm`:真实音频分片 → ASR 转写 → 可配置文本模型提炼 → 人工审阅。原件、转写、提炼响应分别留存,不把纯文本输入冒充模型听过音频;前端显示转写进度与失败后保留的原文。该结论优先于本文下方历史记录,**不等于功能已获准上线**。
|
||||||
|
|
||||||
|
- 用户授权的 4 段录音,独立转写共 23/23 片段成功,约 41.8 分钟。没有逐字人工听写金标准,不能据此报告 ASR 准确率,也不代表所有方言/一小时真实模型通过。
|
||||||
|
- 使用这些真实 ASR 缓存,当前 Qwen 的 4 份 V2 结果通过结构校验,但逐项对照原文发现提问当症状、约数精确化、未提及病史补阴性、说话人/机构归属错误。**4 份均未通过严格语义验收**;所有 13 个候选均需复核、未自动选中。身份可绑定任意本地测试诊单,不意味着可以忽略录音内的患者/家属区别。
|
||||||
|
- 完整原生 Upload → 持久队列 → 独立 Worker → 实际 ASR → 实际 Qwen → review 已用 1 段录音在隔离 MySQL 跑通;其他准确度评估复用缓存,不冒充重复执行完整链路。
|
||||||
|
- 只在一次性测试诊单明确审阅后采纳有支持的字段;含糊血糖、未经确认病史等不写入。另将已校验的真实缓存结果显式导入本地验收任务测试日常记录,不冒充新的 ASR 请求。冲突刷新会取消选择,必须重新审阅;重复确认不增行。
|
||||||
|
- 原生首轮有一个 ASR 片段超时终态不明,保留 `needs_reconciliation`,没有清除或盲目重发。独立缓存的成功不改变这个旧请求的状态。
|
||||||
|
- vLLM 对 `uniqueItems` 返回 HTTP400;生成 schema 已适配,服务端仍拒绝重复引用与重复枚举。开启 thinking 的 4 次请求耗尽 8192 输出预算而不完整;显式关闭 thinking 后结构输出完成,**但语义质量仍失败**,不可拿结构成功替代准确度。
|
||||||
|
- 所有生产开关保持关闭;未部署本功能、未执行生产迁移、未改真实患者记录。本轮本地 MySQL 使用专用随机 `fa_pipeline_*` 库和真实 Access/Store/Worker/Apply 服务,不是生产登录态浏览器验收。
|
||||||
|
|
||||||
|
### 两阶段配置与恢复约束
|
||||||
|
|
||||||
|
使用 `server/.env.followup-audio.example` 中 `QWEN_DRIVER=asr_then_llm` 及独立 `QWEN_ASR_*`、`QWEN_EXTRACTION_*` 配置;`openai` 槽位也支持同样结构。名称只是逻辑槽位,不自动更换供应商。模型地址、密钥、模型名、chunk策略、输出格式/schema/citation版本及 thinking 选项均绑定验证指纹。
|
||||||
|
|
||||||
|
- ASR 默认 120 秒 PCM16k 单声道分片,整段覆盖;未做说话人分离,片段边界不是逐字时间戳。小分片配置产生超过 1000 段时在首次网络请求前拒绝。
|
||||||
|
- `QWEN_EXTRACTION_RESPONSE_FORMAT=json_schema`、`QWEN_EXTRACTION_MAX_TOKENS=8192`;当前本地验收显式 `QWEN_EXTRACTION_ENABLE_THINKING=false`。这只是已测配置,不是质量通过凭证,不提供协议/模型自动回退。
|
||||||
|
- 服务端证据窗口由真实转写生成,模型只返回引用 ID。宽窗口不能证明每个事实;程序保留模型日期弃权,不把别句日期补给该事件,矛盾日期留空并强制审阅。
|
||||||
|
- 阶段检查点存入加密的 `pipeline_cipher`;先持久化 intent,再发请求。已完成 ASR 可复用;超时/未知结果锁定核验;已完成但不合格的提炼保留,不重新无限调用。
|
||||||
|
- 已部署旧表时补执行 `server/sql/2026_10_08_followup_audio_pipeline.sql`,**仍须正式发布授权和备份**。首次安装用更新后的主迁移,两个迁移均幂等。90 天清理覆盖新检查点全文,必要采纳证据仍按审核规则保留。
|
||||||
|
- HTTPS 为默认;仅经管理员明确建立的 SSH 隧道可启用 `QWEN_ALLOW_LOOPBACK_TUNNEL=true`,只接受字面 `127.0.0.1`/`[::1]`。不允许外网 HTTP、localhost 别名或跨地址重定向。不要把本地临时隧道地址直接当生产配置。
|
||||||
|
- 这次连接的是本单位 ASR 与 Qwen 服务。Dify 侧尚未配置/验收 speech2text 应用,不宣称已通过 Dify 音频链路。
|
||||||
|
|
||||||
|
下一质量门槛:取得人工回听/标注金标准,先解决主体、问答肯否、模糊数量和未提及字段问题,再测短/中/一小时及混合口音;门槛通过前不得打开真实客服入口。部署回退脚本恢复源码,不撤销任何已写业务记录。
|
||||||
|
|
||||||
|
## 2026-10-09 受控预览配置
|
||||||
|
|
||||||
|
本轮用户限定:仅测试患者“张三(系统占号)医生无需面诊”的现有诊单1;不新建诊单、不采纳任何录音资料。新增PREVIEW_ONLY、TEST_DIAGNOSIS_IDS/TEST_ADMIN_IDS及独立preview_verified_fingerprint;完整AUDIO_VERIFIED保持false。预览来源永久标记,即使将来切普通模式也禁止Apply。上传/转写/提炼/角色及草稿属于本功能资料,诊单、日常记录、跟踪备注及采纳审计均不得写入。
|
||||||
|
|
||||||
|
Dify独立App和ASR sidecar使用既有HTTPS域名;新增speech2text默认,不改旧App和其他类型默认。两阶段通过显式dify/dify_chat协议接入;配置、严格TLS兼容方式和受控部署约束见 `deployment/followup-audio-runtime/README.md` 及 `deployment/followup-audio-dify/README.md`。实际上线状态以本轮release验收账本为准,代码合并不等于部署成功。
|
||||||
|
|
||||||
|
## 使用与数据规则
|
||||||
|
|
||||||
|
- 入口:网页问诊列表→编辑患者→回访录音。PC/H5共用现有编辑组件;桌面客户端不增加入口。
|
||||||
|
- 每次一个文件,MP3/M4A/WAV/AMR,最多500MiB/3600秒,按服务端实际元数据检查,2MiB分片。
|
||||||
|
- 原件只在 `server/runtime/private/followup_audio/`;播放需登录令牌请求头及患者权限。没有公开录音URL,没有伪造医生通话记录。
|
||||||
|
- AMR或超出上游预算的录音生成私有临时32kbps/16kHz单声道MP3,校验完整时长,不截断、不自动分段改语义;默认上游预算20MiB。原件保留不改,处理副本在成功或失败后清理。
|
||||||
|
- 通话时间采用北京时间,模型解释今天/昨天等;模糊范围需客服确认,不能自动随便取一天。时段缺钟点时早上08:00、中午12:00、下午15:00、晚上20:00、睡前22:00,明确标记估算并要求审阅。
|
||||||
|
- 同值不同测量保留不同记录;重复提及同一事件才合并。默认钟点相同不自动去重。相同诊单+音频SHA256+模型复用原任务,换文件名、上传ID或通话时间不绕过未知请求防重发;日期错误在审阅中修正,已采纳任务不重新写入。
|
||||||
|
- 字段、字典、身份锁、phonePlain和dailyRecord权限均在服务端校验。未提及不等于正常/无,不自动生成诊断或处方。病历当前值与历史记录分开选择。
|
||||||
|
- 草稿有版本和不可变来源证据。冲突刷新最新快照并取消选择,保留用户编辑供重新确认;不静默覆盖。原病历表单有未保存修改时不允许被录音结果覆盖。
|
||||||
|
- 确认写入使用一个事务(单次READ COMMITTED,不更改连接默认),锁定任务与真实记录,审计同事务,重复确认幂等。存在不明确的值可不采纳,不要求全部接受。
|
||||||
|
|
||||||
|
## 配置与运行前检查
|
||||||
|
|
||||||
|
1. 核对实际数据库字段/表前缀,先在线备份;经发布授权后应用 `server/sql/2026_09_29_followup_audio.sql`。SQL使用现有 `zyt_` 前缀,可重复执行;新增四表及血糖时间来源字段,不改旧数据。不得把测试库模拟schema当作生产已核验。
|
||||||
|
2. 使用 `server/.env.followup-audio.example` 配置明确的驱动、模型和凭据。`dify` 可复用现有 `[prescription_ai]` base URL/profile凭据;`openai_audio` 必须显式配置自己的地址、密钥和模型。不会自动切换协议/供应商或回退为纯文本。不要把生产数据库凭据复制到本地测试环境,不要把API密钥放前端或提交Git。
|
||||||
|
3. PHP8.2已做本地验证;需要curl/fileinfo/openssl、可执行FFmpeg/FFprobe。Web/worker用户都必须可读写同一个私有录音目录,目录不能放在public或公开存储域名下。部署多节点需共享私有存储和稳定加密密钥(至少32字符,离线备份),不要依赖各节点自动生成不同runtime key。
|
||||||
|
4. 反向代理/PHP单请求上限至少容纳2MiB分片及表单开销;不要将500MiB整个请求交给PHP上传。短期处理副本也要计入磁盘容量。
|
||||||
|
5. Dify存储副本/缓存的保留与删除策略另行核验;本系统90天删除不代表Dify侧已删除。现实现没有通用Dify远端删除凭证或删除API,不能作此承诺。
|
||||||
|
|
||||||
|
## 合成音频能力门禁
|
||||||
|
|
||||||
|
本地生成器仅接收输出目录,不接受患者录音;使用macOS `say`(默认Tingting)和FFmpeg/FFprobe。Linux消费者不需要say;可安全转移已生成合成夹具及清单。不要重新生成已有manifest或重发状态未确认的探针。
|
||||||
|
|
||||||
|
现成合成夹具:`/private/tmp/zyt-followup-audio-synthetic-gate/manifest.json`(60/900/3598秒,独立首尾口令和事实;预期答案只在本地manifest,不能放进提示词)。需要重建时使用新的输出目录:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/long/.codex/worktrees/followup-audio/zyt
|
||||||
|
python3 scripts/generate_followup_audio_fixtures.py --output /private/tmp/followup-audio-new-synthetic-gate
|
||||||
|
cd server
|
||||||
|
php think followup-audio:probe --synthetic --manifest /private/tmp/zyt-followup-audio-synthetic-gate/manifest.json --profile qwen --case all
|
||||||
|
# 仅当另一个profile也准备供客服使用时,再单独验证openai。
|
||||||
|
```
|
||||||
|
|
||||||
|
必须核实真实音频被读取、首尾事实和数值/否定/时间关系、未截断长录音,再用获准的代表性脱敏口音样例人工回听评估。上传成功、HTTP200或纯文本回复都不是通过。探针有状态文件:已通过的case复用;结果未知先核对原Dify任务/账单,禁止自动重发。不要删除未知状态来强制重试。
|
||||||
|
|
||||||
|
只有验证通过的profile可加入 `VERIFIED_PROFILES`,且必须将本次报告的 `application_fingerprint` 写入对应 `QWEN_VERIFIED_FINGERPRINT` / `OPENAI_VERIFIED_FINGERPRINT`;人工核验通过后才设置 `AUDIO_VERIFIED=true`,经授权发布后再设 `ENABLED=true`。默认 `timestamp_verified=false`,不把LLM编出的毫秒位置当成ASR对齐。
|
||||||
|
|
||||||
|
## 独立消费者、清理与故障处理
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# cwd为部署目录的server;用Supervisor/systemd管理,不在网页请求内跑模型。
|
||||||
|
php think followup-audio:work
|
||||||
|
php think followup-audio:work --once
|
||||||
|
php think followup-audio:cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
- 初始全局并发1,可通过CONCURRENCY配置1–8;独立数据库租约/互斥,不占用原处方队列。
|
||||||
|
- 600秒租约高于240秒请求预算,处理过程中续租。权限撤销、患者重绑、旧worker令牌均不得继续上传/回写。
|
||||||
|
- `needs_reconciliation` 表示请求可能已经被接收,不能自动/手动再次调用;请核对已保存上游IDs、任务和账单。现版本不提供绕过此状态的“强制重试”按钮。
|
||||||
|
- 经确认且允许重试的本地失败保留已有成果。模型不支持音频、结构校验失败或能力未验证,不得成功落诊单。
|
||||||
|
- 将cleanup纳入目标环境已批准的定时任务(本轮未安装定时任务)。90天起点为上传创建时间;清除原件、完整转写及可还原全文的分析副本,保留正式记录和必要采纳审计;活动租约不被清理,清理部分失败退出非零,便于监控。
|
||||||
|
- 关注队列等待时间、needs_reconciliation、失败码、租约恢复、磁盘和清理errors;日志不得包含录音内容、完整病历或凭据。
|
||||||
|
|
||||||
|
## 本地检查与回退
|
||||||
|
|
||||||
|
已有验证:真正隔离MySQL并发claim/create/apply、权限撤回/转派、单事务故障回滚;真实HTTP multipart和媒体处理;实际Vue组件测试;浏览器合成数据完成任务详情→草稿→确认采纳→刷新。完整网页构建通过,未执行发布脚本。全量vue-tsc在同一生成声明环境下基线和修改后均60条诊断,本功能文件0条,新诊断签名0。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/long/.codex/worktrees/followup-audio/zyt
|
||||||
|
FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE=1 FOLLOWUP_AUDIO_TEST_MYSQL_PORT=23316 FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD="$TEST_MYSQL_PASSWORD" php server/tests/FollowupAudioCoreTest.php
|
||||||
|
node --test admin/tests/followup-audio.test.cjs
|
||||||
|
node admin/scripts/verify-daily-records.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库命令只允许一次性本地测试容器,禁止换为线上端口。测试容器密码通过专用环境变量注入,不读取业务 .env。
|
||||||
|
|
||||||
|
发布回退先关闭入口并停止本功能消费者,保留旧静态资源和数据库备份。新增表/列应保留以保护审计;源码回退脚本只恢复源码,**不撤销已写入病历**。正式记录若需恢复,必须按审核轨迹和当前版本另做受控补偿,不能整库回滚覆盖其他人的新数据。
|
||||||
|
|
||||||
|
主交付四角色及源码回退验证位于 `/Users/long/Work/zyt/artifacts/followup-audio-20260929/`;补充研究、局部检查与未通过的真实模型门禁保留在工作副本 `artifacts/followup-audio-implementation/`。
|
||||||
|
|
||||||
|
## 2026-09-29 页面重整与提炼补测
|
||||||
|
|
||||||
|
- 页面改为“紧凑录音列表 → 摘要和分类结果表 → 单项核对弹窗”。上传和原文默认收起,已写入记录用只读文本;窄屏固定核对操作列,日期/估算标记仍可见。
|
||||||
|
- 在真实诊单抽屉组件、1440×1000视口、相同4项合成数据下,常驻输入控件从25个减至4个复选框,内容滚动高度从2802px减至852px;上传、取消单项编辑、分类不丢选择、草稿与确认写入均做了浏览器合成接口验证。它不是生产/真实模型识别证据。
|
||||||
|
- 独立补测发现并修复:临床语义候选不再因空字段而默认采纳;对家属/提问/否定/纠正等上下文强制人工复核。该规则是保守防守,不证明语言语义识别准确。
|
||||||
|
- 饮食早餐/午餐/晚餐提炼别名映射到实际`*_foods`字段,审阅读回、审计、现有日常表单编辑和提交保持一致,未选餐食及图片不清空;CSV合法值`0`不再被`empty()`抹去;医院诊断选项使用现有页面的三个静态枚举。
|
||||||
|
- 补测通过30条规则、16条防守和5条跨层检查,以及真实隔离MySQL113项检查。9个模型语义案例仍标注NOT_VERIFIED;真实Dify最新探针仍CONFIG_MISSING。不得把这些本地测试描述为真实ASR、方言或一小时录音识别已通过。
|
||||||
|
- 本次完整补测账本见工作副本`artifacts/followup-audio-implementation/extraction-acceptance-20260929.md`和`.json`,保留修复前结果、修复后结果及独立回滚证据。主四角色路径保持不变,本次记录追加到原验证记录。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-10-07:真实样本、本地验收与可配置模型
|
||||||
|
|
||||||
|
### 本轮实际结果与边界
|
||||||
|
|
||||||
|
- 用户提供的4段MP3均可读取,长度分别约10分52秒、12分25秒、6分54秒、11分39秒。真实 Upload 服务完成分片、重复分片幂等、私有重组、全量 FFmpeg 解码;原始 SHA256 全部不变。验收用本地一次性 SQLite 和合成授权,不代表生产 HTTP/RBAC 验收。
|
||||||
|
- 新的 opt-in `server/tests/FollowupAudioSampleAcceptanceTest.php` 读取 Git 外0600私有manifest;无参数安全跳过。4段录音与44个独立合成日期候选合计287项检查通过。模拟通话日期为2026-10-07、2026-03-01、2026-01-01、2024-03-01,覆盖普通日、跨月、跨年、闰年和模糊日期;这些候选不是从患者录音识别出来的内容。
|
||||||
|
- 用户指定的兼容接口 `/models` 可访问。合成短音频按 `input_audio` 发给 `gpt-6.1-sol` 得到 HTTP200,但明确回复未收到可读取音频;`/audio/transcriptions` 请求返回404。新实现的原生能力探针再次以结构化输出契约执行,退出2、`AUDIO_NOT_PROCESSED`。未继续发送15分钟/一小时样本,没有发送4段真实患者录音,没有写入任何正式诊单。
|
||||||
|
- HTTPS 实测证书与域名不匹配。没有关闭证书校验。HTTP只用于明确获准的合成检查,正式患者处理强制HTTPS,不能靠开启布尔配置绕过。
|
||||||
|
- 上述结果说明目前所测入口/模型不能完成本功能的音频识别,不等于该服务所有未测模型均不支持。纯文本提炼不能替代语音转写。
|
||||||
|
- 音频请求格式参考[OpenAI官方音频输入说明](https://developers.openai.com/api/docs/guides/audio-chat-completions),实际服务能力以本轮请求证据为准。
|
||||||
|
|
||||||
|
### 模型配置(服务端环境变量,不在客服页面暴露凭据)
|
||||||
|
|
||||||
|
当前沿用 `qwen` / `openai` 两个稳定逻辑槽位;槽位不再决定固定品牌/模型,前端显示服务端配置的标签。示例:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[followup_audio]
|
||||||
|
ENABLED = false
|
||||||
|
AUDIO_VERIFIED = false
|
||||||
|
VERIFIED_PROFILES =
|
||||||
|
PROFILE = openai
|
||||||
|
OPENAI_DRIVER = openai_audio
|
||||||
|
OPENAI_BASE_URL = https://HOST/v1
|
||||||
|
OPENAI_API_KEY =
|
||||||
|
OPENAI_MODEL = MODEL
|
||||||
|
OPENAI_LABEL = 回访分析模型
|
||||||
|
OPENAI_VERIFIED_FINGERPRINT =
|
||||||
|
ALLOW_INSECURE_SYNTHETIC = false
|
||||||
|
```
|
||||||
|
|
||||||
|
- API_KEY只通过部署环境或Git忽略的本地`.env`注入;本轮本地`.env`权限0600,未打包/提交。正式配置不要使用文档占位值。
|
||||||
|
- `DRIVER=dify`选择Dify应用,内部使用哪个模型需在Dify端配置;本地MODEL字段不能改变Dify应用模型。`DRIVER=openai_audio`会将MODEL实际发送到Chat Completions,必须是该服务真正支持音频输入的模型。
|
||||||
|
- 地址、密钥、driver、model任一变化都会使验证指纹失效;修改配置后重启消费者并重新验证。标签变化不改变提供方身份。不要保留旧指纹作为新模型的验收凭证。
|
||||||
|
- 新任务将选择时的提供方指纹保存到现有`upstream_ids_json`,不增加数据库schema。消费者、心跳、checkpoint和完成均校验;旧任务缺少指纹或配置漂移不会静默改走新服务。已有结果不自动重分析,相同内容防重规则保持。
|
||||||
|
- 探针状态保存为 `probe-<profile>-<fingerprint>.json`,旧身份报告保留。未知/已开始的同身份请求不自动重发;失败先核对已有请求ID及响应。不能删除报告来强制重试。
|
||||||
|
- 需要先修复HTTPS并提供支持音频的模型/转写能力,然后完成短/中/长门禁,再对代表性真实录音人工回听、核对主体/否定/数值及时间。此步骤尚未完成,入口继续关闭。
|
||||||
|
|
||||||
|
### 本轮验证记录
|
||||||
|
|
||||||
|
ProviderConfig24、OpenAI音频传输47、Dify106、Media41、Probe14、隔离MySQL Core132、Worker20、Endpoint21、Access11、ProviderIntegration20均通过。音频传输单测采用受控响应/回环服务,不是外部ASR准确率;4个真实样本的287项检查只证明本地媒体与独立日期规则。网页未修改,38项录音组件测试回归通过。
|
||||||
|
|
||||||
|
完整证据在 `/Users/long/Work/zyt/artifacts/followup-audio-20260929/samples-20261007/`,主四角色路径沿用原交付;本地配置、真实音频和私有manifest都不纳入源码包。
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create synthetic-only speech fixtures. Never accepts a recording or sends data to a provider.
|
||||||
|
|
||||||
|
The random canaries and expected facts live in the local manifest only; the Dify
|
||||||
|
probe sends the audio, schema and recording time but never this ground truth.
|
||||||
|
Requires macOS say plus ffmpeg/ffprobe. Refuses to replace an existing manifest.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def run(command):
|
||||||
|
return subprocess.run(command, check=True, capture_output=True, text=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def duration(path):
|
||||||
|
return float(run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)]))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--output", type=pathlib.Path, required=True)
|
||||||
|
parser.add_argument("--voice", default="Tingting")
|
||||||
|
args = parser.parse_args()
|
||||||
|
output = args.output.resolve()
|
||||||
|
if (output / "manifest.json").exists() or any((output / (case + ".mp3")).exists() for case in ("short", "medium", "long")):
|
||||||
|
raise SystemExit("Fixture identity already exists; reuse it instead of regenerating or resubmitting.")
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.chmod(0o700)
|
||||||
|
manifest = {"generator": "followup-audio-synthetic-v1", "synthetic": True,
|
||||||
|
"recorded_at": "2026-09-29 10:00:00", "speech_revision": "ordinary-dialogue-v2", "fixtures": []}
|
||||||
|
words = ["紫色海豚", "水晶风车", "绿色蜻蜓", "银色树叶", "蓝色鲸鱼", "金色河流", "红色石榴", "白色云朵"]
|
||||||
|
with tempfile.TemporaryDirectory(prefix="followup-audio-synthetic-") as temporary:
|
||||||
|
temp = pathlib.Path(temporary)
|
||||||
|
# Synthetic provenance stays in the manifest. The speech must not contradict
|
||||||
|
# the expected task by declaring all reported measurements non-facts.
|
||||||
|
middle_text = "客服说,电话里的声音还清楚吗?患者说,很清楚。客服说,好的,我们继续聊。患者说,好的,您说吧。"
|
||||||
|
run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "middle.aiff"), middle_text])
|
||||||
|
for case, seconds, systolic, diastolic in (("short", 60, 131, 86), ("medium", 900, 139, 88), ("long", 3598, 147, 91)):
|
||||||
|
head_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
||||||
|
tail_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
||||||
|
while tail_canary == head_canary:
|
||||||
|
tail_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
||||||
|
head = f"患者说,我先读三个词,{head_canary}。昨天晚上九点,我测的血压是收缩压一百二十六,舒张压八十二。前天下午两点,我测的餐后血糖是七点二。"
|
||||||
|
tail = f"患者说,我再补充一次测量。今天早晨七点,我的收缩压是{systolic},舒张压是{diastolic}。最后再读三个词,{tail_canary}。客服说,好的,再见。"
|
||||||
|
run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "head.aiff"), head])
|
||||||
|
run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "tail.aiff"), tail])
|
||||||
|
filler_seconds = seconds - duration(temp / "head.aiff") - duration(temp / "tail.aiff")
|
||||||
|
if filler_seconds <= 0:
|
||||||
|
raise SystemExit("Speech is longer than short fixture; increase speaking rate or duration")
|
||||||
|
path = output / (case + ".mp3")
|
||||||
|
filters = f"[0:a]aresample=16000,aformat=channel_layouts=mono[h];[1:a]aresample=16000,aformat=channel_layouts=mono,atrim=duration={filler_seconds},asetpts=PTS-STARTPTS[m];[2:a]aresample=16000,aformat=channel_layouts=mono[t];[h][m][t]concat=n=3:v=0:a=1[out]"
|
||||||
|
run(["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-i", str(temp / "head.aiff"),
|
||||||
|
"-stream_loop", "-1", "-i", str(temp / "middle.aiff"), "-i", str(temp / "tail.aiff"),
|
||||||
|
"-filter_complex", filters, "-map", "[out]", "-t", str(seconds), "-codec:a", "libmp3lame", "-b:a", "32k", "-y", str(path)])
|
||||||
|
path.chmod(0o600)
|
||||||
|
actual = duration(path)
|
||||||
|
sha = hashlib.file_digest(path.open("rb"), "sha256").hexdigest()
|
||||||
|
manifest["fixtures"].append({"case": case, "file": path.name, "sha256": sha, "duration_seconds": actual,
|
||||||
|
"synthetic": True, "generator": manifest["generator"], "expected": {"canaries": [head_canary, tail_canary], "facts": [
|
||||||
|
{"kind": "blood", "record_date": "2026-09-28", "record_time": "21:00", "values": {"systolic_pressure": 126, "diastolic_pressure": 82}},
|
||||||
|
{"kind": "blood", "record_date": "2026-09-27", "record_time": "14:00", "values": {"postprandial_blood_sugar": 7.2}},
|
||||||
|
{"kind": "blood", "record_date": "2026-09-29", "record_time": "07:00", "values": {"systolic_pressure": systolic, "diastolic_pressure": diastolic}},
|
||||||
|
]}})
|
||||||
|
print(json.dumps({"case": case, "path": str(path), "duration_seconds": actual, "sha256": sha, "synthetic": True}))
|
||||||
|
manifest_path = output / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
manifest_path.chmod(0o600)
|
||||||
|
reopened = json.loads(manifest_path.read_text())
|
||||||
|
assert len(reopened["fixtures"]) == 3
|
||||||
|
print(json.dumps({"manifest": str(manifest_path), "synthetic": True, "fixtures": 3}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Merge this section into the target environment's existing .env. No credentials are provided here.
|
||||||
|
# Only dify may reuse existing [prescription_ai] base/key. openai_audio needs MODEL; asr_then_llm needs both stage model/key/base triples.
|
||||||
|
[followup_audio]
|
||||||
|
ENABLED = false
|
||||||
|
PREVIEW_ONLY = false
|
||||||
|
# Explicit human-reviewed business use, with an exact connection-ready provider fingerprint.
|
||||||
|
# This never marks AUDIO_VERIFIED true or removes original preview-task markers.
|
||||||
|
MANUAL_REVIEW_ENABLED = false
|
||||||
|
# IDs only. Empty or invalid diagnosis allowlist denies preview; admin list never replaces RBAC.
|
||||||
|
TEST_DIAGNOSIS_IDS =
|
||||||
|
TEST_ADMIN_IDS =
|
||||||
|
AUDIO_VERIFIED = false
|
||||||
|
VERIFIED_PROFILES =
|
||||||
|
PROFILE = qwen
|
||||||
|
CONCURRENCY = 1
|
||||||
|
REQUEST_TIMEOUT = 240
|
||||||
|
UPSTREAM_MAX_BYTES = 20971520
|
||||||
|
NORMALIZE_TIMEOUT = 120
|
||||||
|
FFMPEG = ffmpeg
|
||||||
|
FFPROBE = ffprobe
|
||||||
|
# At least 32 characters; keep secret, stable, backed up and identical on web/worker nodes.
|
||||||
|
# Empty uses the existing prescription_analysis key; never rotate without a data re-encryption plan.
|
||||||
|
ENCRYPTION_KEY =
|
||||||
|
|
||||||
|
# Never enable HTTP for real audio. This switch applies only to the synthetic CLI probe.
|
||||||
|
ALLOW_INSECURE_SYNTHETIC = false
|
||||||
|
# Supported drivers: dify, openai_audio, asr_then_llm. openai_audio MODEL is sent in the request.
|
||||||
|
# Dify selects an application with its key; MODEL does not change the model inside that app.
|
||||||
|
QWEN_DRIVER = dify
|
||||||
|
QWEN_BASE_URL =
|
||||||
|
QWEN_API_KEY =
|
||||||
|
QWEN_MODEL =
|
||||||
|
QWEN_LABEL = qwen
|
||||||
|
QWEN_VERIFIED_FINGERPRINT =
|
||||||
|
OPENAI_DRIVER = dify
|
||||||
|
OPENAI_BASE_URL =
|
||||||
|
OPENAI_API_KEY =
|
||||||
|
OPENAI_MODEL =
|
||||||
|
OPENAI_LABEL = openai
|
||||||
|
OPENAI_VERIFIED_FINGERPRINT =
|
||||||
|
# Fingerprints must come from all three passing synthetic gates for this exact driver/URL/model/key.
|
||||||
|
# Any identity change invalidates prior verification; unknown prior requests still require reconciliation.
|
||||||
|
|
||||||
|
# Two-stage is opt-in per slot. ASR transcript is canonical; extraction uses text only.
|
||||||
|
# Mono keeps120s; stereo always isolates both channels on shared fixed windows.
|
||||||
|
ASR_STEREO_CHUNK_SECONDS = 15
|
||||||
|
ASR_CHUNK_SECONDS = 120
|
||||||
|
ASR_REQUEST_TIMEOUT = 240
|
||||||
|
EXTRACTION_REQUEST_TIMEOUT = 240
|
||||||
|
# HTTPS required. Enable this only for an operator-established SSH tunnel on literal 127.0.0.1 or [::1].
|
||||||
|
# This loopback switch never permits other hosts; redirects remain disabled.
|
||||||
|
QWEN_ALLOW_LOOPBACK_TUNNEL = false
|
||||||
|
QWEN_ASR_BASE_URL =
|
||||||
|
QWEN_ASR_API_KEY =
|
||||||
|
QWEN_ASR_MODEL =
|
||||||
|
QWEN_EXTRACTION_BASE_URL =
|
||||||
|
QWEN_EXTRACTION_API_KEY =
|
||||||
|
QWEN_EXTRACTION_MODEL =
|
||||||
|
OPENAI_ALLOW_LOOPBACK_TUNNEL = false
|
||||||
|
OPENAI_ASR_BASE_URL =
|
||||||
|
OPENAI_ASR_API_KEY =
|
||||||
|
OPENAI_ASR_MODEL =
|
||||||
|
OPENAI_EXTRACTION_BASE_URL =
|
||||||
|
OPENAI_EXTRACTION_API_KEY =
|
||||||
|
OPENAI_EXTRACTION_MODEL =
|
||||||
|
|
||||||
|
# V2 structured extraction; explicit json_schema/json_object/prompt_json, never automatic fallback.
|
||||||
|
QWEN_EXTRACTION_RESPONSE_FORMAT = json_schema
|
||||||
|
QWEN_EXTRACTION_MAX_TOKENS = 8192
|
||||||
|
OPENAI_EXTRACTION_RESPONSE_FORMAT = json_schema
|
||||||
|
OPENAI_EXTRACTION_MAX_TOKENS = 8192
|
||||||
|
# Optional vendor extension; leave absent for portable providers. true/false are explicit Qwen choices.
|
||||||
|
# The local Qwen run exhausted 8192 tokens with thinking=true; false produced JSON, not a quality pass.
|
||||||
|
# QWEN_EXTRACTION_ENABLE_THINKING = false
|
||||||
|
# OPENAI_EXTRACTION_ENABLE_THINKING = false
|
||||||
|
|
||||||
|
# Synthetic connection readiness only; never sets AUDIO_VERIFIED or allows clinical adoption.
|
||||||
|
QWEN_PREVIEW_VERIFIED_FINGERPRINT =
|
||||||
|
OPENAI_PREVIEW_VERIFIED_FINGERPRINT =
|
||||||
|
|
||||||
|
# Explicit per-stage protocol. No automatic fallback. Dify uses dedicated App keys only.
|
||||||
|
QWEN_ASR_PROTOCOL = openai
|
||||||
|
QWEN_ASR_BINDING_REVISION =
|
||||||
|
QWEN_EXTRACTION_PROTOCOL = openai
|
||||||
|
QWEN_EXTRACTION_BINDING_REVISION =
|
||||||
|
OPENAI_ASR_PROTOCOL = openai
|
||||||
|
OPENAI_ASR_BINDING_REVISION =
|
||||||
|
OPENAI_EXTRACTION_PROTOCOL = openai
|
||||||
|
OPENAI_EXTRACTION_BINDING_REVISION =
|
||||||
|
# For Dify: ASR_PROTOCOL=dify, EXTRACTION_PROTOCOL=dify_chat, RESPONSE_FORMAT=prompt_json.
|
||||||
|
# Every Dify stage needs a nonempty BINDING_REVISION. MODEL is expected identity, not sent.
|
||||||
|
# Dify App owns generation parameters: local max_tokens/response_format/thinking are NOT forwarded.
|
||||||
|
# Leave EXTRACTION_ENABLE_THINKING absent for Dify; set generation limits in the dedicated App.
|
||||||
|
# Changing that App requires a new binding revision and fresh preview fingerprint acceptance.
|
||||||
|
|
||||||
|
# asr_then_llm only: curl (default) or openssl_stream (explicit verified-TLS child process).
|
||||||
|
# No automatic transport fallback; changing this requires new fingerprint readiness.
|
||||||
|
HTTP_TRANSPORT = curl
|
||||||
|
|
||||||
|
# Explicit operator authorization for a fixed plaintext text-extraction channel only.
|
||||||
|
# Copy the exact normalized http://HOST[/path]/v1 base here; no wildcards, encoded/dot paths or redirects.
|
||||||
|
# Only asr_then_llm + extraction protocol=openai accepts it. ASR and all HTTPS verification stay unchanged.
|
||||||
|
# HTTP carries transcript and API key without TLS. Leave empty unless that channel was expressly authorized.
|
||||||
|
# Changes bind a new provider fingerprint; preserve old profiles/tasks rather than replacing their identity.
|
||||||
|
QWEN_EXTRACTION_HTTP_BASE_OPT_IN =
|
||||||
|
OPENAI_EXTRACTION_HTTP_BASE_OPT_IN =
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
; 企业微信客服(桌面软件 / 手机远程控制台)对接配置
|
||||||
|
; 把下面的 [WECOM_RPA] 段追加到 server/.env 末尾使用(勿提交真实 .env)。
|
||||||
|
; php-fpm 每个请求都会重新读取 .env,保存即生效;常驻进程(如 php think run)需重启。
|
||||||
|
;
|
||||||
|
; 背景:员工在自己电脑上用客服桌面软件登录 ZYT(终端 7),令牌交给客服后台,
|
||||||
|
; 由客服后台服务器代查患者、订单。后台令牌默认绑定登录 IP,服务器出口 IP 与员工
|
||||||
|
; 电脑不同,会被判为「ip地址发生变化,请重新登录」,桌面端表现为「ZYT 患者查询
|
||||||
|
; 登录已过期」,重新验证后下一次查询仍然失败。
|
||||||
|
;
|
||||||
|
; SERVER_IPS:客服后台服务器的公网出口 IP,多个用英文逗号或空格分隔,逐个精确匹配
|
||||||
|
; (IPv4/IPv6 字面量,不支持网段)。以 ZYT 看到的来源 IP(REMOTE_ADDR)为准,
|
||||||
|
; 可在 ZYT 访问日志里核对客服后台请求的来源地址;出口 IP 变更后须同步修改。
|
||||||
|
; 只有终端 7(桌面)/ 9(手机控制台)的令牌、且请求来自这些 IP 时才跳过登录 IP
|
||||||
|
; 绑定;网页后台、APP 等其他终端令牌不受影响,菜单权限照常校验。
|
||||||
|
; 留空或不配置 = 保持原有 IP 绑定行为。
|
||||||
|
;
|
||||||
|
; 生产环境客服后台出口 IP(2026-09 核对):123.14.175.249
|
||||||
|
; 即生产 .env 中填写:SERVER_IPS = 123.14.175.249
|
||||||
|
;
|
||||||
|
; 注意:本文件和 .env 都按 INI 解析,注释请用分号开头;以 # 开头且带等号的行
|
||||||
|
; 会被当成配置键读入。也可以不写分区,在所有 [分区] 之前写顶层键
|
||||||
|
; WECOM_RPA_SERVER_IPS,效果相同。
|
||||||
|
|
||||||
|
[WECOM_RPA]
|
||||||
|
SERVER_IPS =
|
||||||
@@ -10,6 +10,7 @@ use app\adminapi\logic\stats\AssistantPerformanceLogic;
|
|||||||
* 医助个人业绩
|
* 医助个人业绩
|
||||||
*
|
*
|
||||||
* - GET stats.assistantPerformance/overview 个人业绩概览
|
* - GET stats.assistantPerformance/overview 个人业绩概览
|
||||||
|
* - GET stats.assistantPerformance/revisitRate 我的复诊接诊率(仅本人行,需在菜单登记:server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql)
|
||||||
*/
|
*/
|
||||||
class AssistantPerformanceController extends BaseAdminController
|
class AssistantPerformanceController extends BaseAdminController
|
||||||
{
|
{
|
||||||
@@ -18,4 +19,13 @@ class AssistantPerformanceController extends BaseAdminController
|
|||||||
$params = $this->request->get();
|
$params = $this->request->get();
|
||||||
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
|
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 我的复诊接诊率:按月返回本人在复诊接诊率报表中的行(方案 §10 D1-h)
|
||||||
|
*/
|
||||||
|
public function revisitRate()
|
||||||
|
{
|
||||||
|
$month = (string) $this->request->get('month', '');
|
||||||
|
return $this->data(AssistantPerformanceLogic::revisitRate($this->adminId, $month));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use app\adminapi\logic\tcm\DiagnosisLogic;
|
|||||||
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
||||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
use app\adminapi\service\AssistantSseProtocol;
|
use app\adminapi\service\AssistantSseProtocol;
|
||||||
|
use app\adminapi\service\AssistantSseTiming;
|
||||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||||
use app\common\model\Order;
|
use app\common\model\Order;
|
||||||
use app\common\model\WechatChatRecord;
|
use app\common\model\WechatChatRecord;
|
||||||
@@ -930,6 +931,7 @@ class DiagnosisController extends BaseAdminController
|
|||||||
*/
|
*/
|
||||||
public function aiAssistantStream()
|
public function aiAssistantStream()
|
||||||
{
|
{
|
||||||
|
$timing = new AssistantSseTiming();
|
||||||
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
|
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
|
||||||
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
|
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
|
||||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||||
@@ -938,17 +940,18 @@ class DiagnosisController extends BaseAdminController
|
|||||||
(string) $params['task'],
|
(string) $params['task'],
|
||||||
(string) ($params['prompt'] ?? ''),
|
(string) ($params['prompt'] ?? ''),
|
||||||
$this->adminId,
|
$this->adminId,
|
||||||
$this->adminInfo
|
$this->adminInfo,
|
||||||
|
$timing
|
||||||
);
|
);
|
||||||
if ($prepared === null) {
|
if ($prepared === null) {
|
||||||
return $this->fail(DiagnosisAiLogic::getError());
|
return $this->fail(DiagnosisAiLogic::getError());
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->runAssistantSse($prepared);
|
$this->runAssistantSse($prepared, $timing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string,mixed> $prepared */
|
/** @param array<string,mixed> $prepared */
|
||||||
private function runAssistantSse(array $prepared): void
|
private function runAssistantSse(array $prepared, AssistantSseTiming $timing): void
|
||||||
{
|
{
|
||||||
while (ob_get_level() > 0) {
|
while (ob_get_level() > 0) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
@@ -984,7 +987,7 @@ class DiagnosisController extends BaseAdminController
|
|||||||
return !connection_aborted();
|
return !connection_aborted();
|
||||||
};
|
};
|
||||||
|
|
||||||
$emit('start', [
|
$startEmitted = $emit('start', [
|
||||||
'task' => (string) ($prepared['task'] ?? ''),
|
'task' => (string) ($prepared['task'] ?? ''),
|
||||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||||
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
||||||
@@ -995,14 +998,37 @@ class DiagnosisController extends BaseAdminController
|
|||||||
: [],
|
: [],
|
||||||
'message' => '已连接,正在生成…',
|
'message' => '已连接,正在生成…',
|
||||||
]);
|
]);
|
||||||
|
$sseStartedAt = hrtime(true);
|
||||||
|
$firstDeltaMs = null;
|
||||||
|
$timing->log('sse_start', ['status' => $startEmitted ? 'ok' : 'disconnected']);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = DiagnosisAiLogic::streamPreparedAssistant(
|
$result = DiagnosisAiLogic::streamPreparedAssistant(
|
||||||
$prepared,
|
$prepared,
|
||||||
static fn (string $delta): bool => $emit('delta', ['text' => $delta]),
|
static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs): bool {
|
||||||
static fn (): bool => connection_aborted() === 1
|
$observedAtMs = $firstDeltaMs === null && $delta !== ''
|
||||||
|
? AssistantSseTiming::elapsedMs($sseStartedAt)
|
||||||
|
: null;
|
||||||
|
$accepted = $emit('delta', ['text' => $delta]);
|
||||||
|
if ($accepted && $observedAtMs !== null) {
|
||||||
|
$firstDeltaMs = $observedAtMs;
|
||||||
|
$timing->log('first_delta', [
|
||||||
|
'duration_ms' => $firstDeltaMs,
|
||||||
|
'delta_bytes' => strlen($delta),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $accepted;
|
||||||
|
},
|
||||||
|
static fn (): bool => connection_aborted() === 1,
|
||||||
|
$timing
|
||||||
);
|
);
|
||||||
if (connection_aborted()) {
|
if (connection_aborted()) {
|
||||||
|
$timing->log('sse_done', [
|
||||||
|
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
|
||||||
|
'first_delta_observed' => $firstDeltaMs !== null,
|
||||||
|
'first_delta_ms' => $firstDeltaMs,
|
||||||
|
'status' => 'disconnected',
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($result === null) {
|
if ($result === null) {
|
||||||
@@ -1013,14 +1039,22 @@ class DiagnosisController extends BaseAdminController
|
|||||||
} else {
|
} else {
|
||||||
$emit('done', $result);
|
$emit('done', $result);
|
||||||
}
|
}
|
||||||
|
$timing->log('sse_done', [
|
||||||
|
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
|
||||||
|
'first_delta_observed' => $firstDeltaMs !== null,
|
||||||
|
'first_delta_ms' => $firstDeltaMs,
|
||||||
|
'status' => $result === null ? 'error' : 'ok',
|
||||||
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([
|
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([
|
||||||
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
'trace_id' => $timing->id(),
|
||||||
'profile' => (string) ($prepared['profile'] ?? ''),
|
|
||||||
'task' => (string) ($prepared['task'] ?? ''),
|
|
||||||
'admin_id' => (int) ($prepared['admin_id'] ?? 0),
|
|
||||||
'exception_class' => get_class($e),
|
|
||||||
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||||
|
$timing->log('sse_done', [
|
||||||
|
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
|
||||||
|
'first_delta_observed' => $firstDeltaMs !== null,
|
||||||
|
'first_delta_ms' => $firstDeltaMs,
|
||||||
|
'status' => 'exception',
|
||||||
|
]);
|
||||||
$emit('error', [
|
$emit('error', [
|
||||||
'code' => 'AI_ASSISTANT_FAILED',
|
'code' => 'AI_ASSISTANT_FAILED',
|
||||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioAccess as Access;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioStream;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
final class FollowupAudioController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function capabilities()
|
||||||
|
{
|
||||||
|
return $this->handle(false, ['diagnosis_id'], fn (array $p): array =>
|
||||||
|
Logic::capabilities($this->positive($p, 'diagnosis_id'), $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadSession()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['diagnosis_id', 'file_name', 'total_bytes'], function (array $p): array {
|
||||||
|
Logic::requireEnabled(true);
|
||||||
|
return Upload::createSession($this->positive($p, 'diagnosis_id'), $this->textValue($p, 'file_name', 240),
|
||||||
|
$this->positive($p, 'total_bytes'), $this->adminId, $this->adminInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadChunk()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['upload_id', 'index'], function (array $p): array {
|
||||||
|
Logic::requireEnabled(true);
|
||||||
|
$index = $p['index'] ?? null;
|
||||||
|
if (!(is_int($index) || is_string($index)) || !preg_match('/^\d{1,5}$/D', (string) $index)) {
|
||||||
|
throw new DomainException('录音分片序号无效');
|
||||||
|
}
|
||||||
|
$file = $this->request->file('file');
|
||||||
|
if (!$file instanceof \think\file\UploadedFile || !$file->isValid()) {
|
||||||
|
throw new DomainException('录音分片上传失败,请检查文件大小限制');
|
||||||
|
}
|
||||||
|
return Upload::putChunk($this->textValue($p, 'upload_id', 64), (int) $index,
|
||||||
|
$file->getPathname(), $this->adminId, $this->adminInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadComplete()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['upload_id'], function (array $p): array {
|
||||||
|
Logic::requireEnabled(true);
|
||||||
|
return Upload::complete($this->textValue($p, 'upload_id', 64), $this->adminId, $this->adminInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['diagnosis_id', 'upload_id', 'recorded_at', 'model_key', 'channel_roles'], fn (array $p): array =>
|
||||||
|
Logic::create([
|
||||||
|
'diagnosis_id' => $this->positive($p, 'diagnosis_id'),
|
||||||
|
'upload_id' => $this->textValue($p, 'upload_id', 64),
|
||||||
|
'recorded_at' => $this->textValue($p, 'recorded_at', 19),
|
||||||
|
'model_key' => $this->textValue($p, 'model_key', 16),
|
||||||
|
// Preserve raw JSON type and exact enum, including null/whitespace rejection.
|
||||||
|
'channel_roles' => array_key_exists('channel_roles', $p) ? $p['channel_roles'] : 'unconfirmed',
|
||||||
|
], $this->adminId, $this->adminInfo), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
return $this->handle(false, ['diagnosis_id'], fn (array $p): array =>
|
||||||
|
Logic::lists($this->positive($p, 'diagnosis_id'), $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
return $this->handle(false, ['id'], fn (array $p): array =>
|
||||||
|
Logic::detail($this->positive($p, 'id'), $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveDraft()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['id', 'version', 'items', 'channel_roles'], fn (array $p): array =>
|
||||||
|
Logic::saveDraft($this->positive($p, 'id'), $this->positive($p, 'version'),
|
||||||
|
$this->items($p), $this->adminId, $this->adminInfo,
|
||||||
|
array_key_exists('channel_roles', $p) ? $this->textValue($p, 'channel_roles', 20) : null), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['id', 'version', 'items'], fn (array $p): array =>
|
||||||
|
Logic::apply($this->positive($p, 'id'), $this->positive($p, 'version'),
|
||||||
|
$this->items($p), $this->adminId, $this->adminInfo), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retry()
|
||||||
|
{
|
||||||
|
return $this->handle(true, ['id'], fn (array $p): array =>
|
||||||
|
Logic::retry($this->positive($p, 'id'), $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function audio()
|
||||||
|
{
|
||||||
|
return $this->handle(false, ['id'], function (array $p): FollowupAudioStream {
|
||||||
|
$task = Access::task($this->positive($p, 'id'), $this->adminId, $this->adminInfo);
|
||||||
|
$upload = Upload::session((string) $task['upload_id']);
|
||||||
|
return new FollowupAudioStream(Upload::path($upload), (string) $upload['extension']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handle(bool $post, array $allowed, callable $handler, bool $typedJson = false)
|
||||||
|
{
|
||||||
|
if ($post ? !$this->request->isPost() : !$this->request->isGet()) {
|
||||||
|
return $this->fail('请求方式错误');
|
||||||
|
}
|
||||||
|
// Review JSON carries strict booleans/integers and exact source evidence. Global trim
|
||||||
|
// would stringify scalars and alter quotations; keep all explicit validators below.
|
||||||
|
$params = $post ? $this->request->post('', null, $typedJson ? null : '') : $this->request->get();
|
||||||
|
if (array_diff(array_keys($params), $allowed) !== []) {
|
||||||
|
return $this->fail('请求包含不支持的字段');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->adminInfo = Access::actor($this->adminId);
|
||||||
|
$result = $handler($params);
|
||||||
|
return $result instanceof \think\Response ? $result : $this->data($result);
|
||||||
|
} catch (DomainException $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'FOLLOWUP_AUDIO_STALE_REVIEW')) {
|
||||||
|
return $this->fail('病历或日常记录已发生变化,请重新审阅差异后确认', [
|
||||||
|
'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $this->fail($e->getMessage());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never expose raw SQL, audio paths, transcripts, provider responses or keys.
|
||||||
|
return $this->fail('回访录音服务暂不可用,请联系管理员检查部署');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positive(array $p, string $key): int
|
||||||
|
{
|
||||||
|
$raw = $p[$key] ?? null;
|
||||||
|
if (!(is_int($raw) || is_string($raw)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $raw)) {
|
||||||
|
throw new DomainException('记录标识或版本无效');
|
||||||
|
}
|
||||||
|
return (int) $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function textValue(array $p, string $key, int $max): string
|
||||||
|
{
|
||||||
|
$raw = $p[$key] ?? '';
|
||||||
|
if (!is_string($raw) || strlen($raw) > $max) {
|
||||||
|
throw new DomainException('文本参数无效');
|
||||||
|
}
|
||||||
|
return trim($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function items(array $p): array
|
||||||
|
{
|
||||||
|
$items = $p['items'] ?? null;
|
||||||
|
if (!is_array($items) || !array_is_list($items) || count($items) > 500
|
||||||
|
|| strlen(json_encode($items, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)) > 2097152) {
|
||||||
|
throw new DomainException('审阅项目无效或数量超限');
|
||||||
|
}
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\lists\tcm\RevisitDueLists;
|
||||||
|
use app\adminapi\logic\tcm\RevisitDueLogic;
|
||||||
|
use app\adminapi\validate\tcm\RevisitDueValidate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊到期弹窗控制器(需求 4:到期未面诊弹窗 + 延迟处理;P2 主管团队视图 lists / logs)
|
||||||
|
*
|
||||||
|
* URL 自动映射 /adminapi/tcm.revisitDue/<action>,权限串 tcm.revisitDue/<action>,
|
||||||
|
* 弹窗六个 action 在菜单登记于 server/sql/1.9.20261012/05_add_revisit_due_menu.sql,
|
||||||
|
* 主管视图 lists(C 类页面 /stats/revisit_due)/ logs(A 类)登记于 08_add_revisit_due_team_menu.sql,
|
||||||
|
* 否则 AuthMiddleware 对未登记接口放行。归属校验在 RevisitDueLogic 内完成,lists 的数据范围由 RevisitDueLists 处理。
|
||||||
|
*
|
||||||
|
* @package app\adminapi\controller\tcm
|
||||||
|
*/
|
||||||
|
class RevisitDueController extends BaseAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @notes 主管团队视图:到期跟进列表(分页 / 筛选 / 导出 export=1|2),数据范围按角色 data_scope
|
||||||
|
*/
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
return $this->dataLists(new RevisitDueLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 主管团队视图:某条提醒的事件流水(需在当前账号数据范围内)
|
||||||
|
*/
|
||||||
|
public function logs()
|
||||||
|
{
|
||||||
|
$params = (new RevisitDueValidate())->get()->goCheck('logs');
|
||||||
|
$result = RevisitDueLogic::logs((int) $params['due_id'], $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(RevisitDueLogic::getError() ?: '流水读取失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 弹窗待办(轮询):本人到期未挂号 / 过号且已到提醒时刻的项 + 汇总计数
|
||||||
|
*/
|
||||||
|
public function pending()
|
||||||
|
{
|
||||||
|
return $this->data(RevisitDueLogic::pending($this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 仅计数(不更新弹出时间)
|
||||||
|
*/
|
||||||
|
public function summary()
|
||||||
|
{
|
||||||
|
return $this->data(RevisitDueLogic::summary($this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 延迟一次(幂等:affected=0 也返回当前行)
|
||||||
|
*/
|
||||||
|
public function snooze()
|
||||||
|
{
|
||||||
|
$params = (new RevisitDueValidate())->post()->goCheck('snooze');
|
||||||
|
$result = RevisitDueLogic::snooze((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(RevisitDueLogic::getError() ?: '延迟失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($result['message'], $result['item'], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 批量延迟(仅 escalation_level<=1 的项)
|
||||||
|
*/
|
||||||
|
public function snoozeAll()
|
||||||
|
{
|
||||||
|
$params = (new RevisitDueValidate())->post()->goCheck('snoozeAll');
|
||||||
|
$result = RevisitDueLogic::snoozeAll((array) $params['ids'], $this->adminId, $this->adminInfo);
|
||||||
|
|
||||||
|
return $this->success($result['message'], $result, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 已联系:记录患者承诺复诊日
|
||||||
|
*/
|
||||||
|
public function contact()
|
||||||
|
{
|
||||||
|
$params = (new RevisitDueValidate())->post()->goCheck('contact');
|
||||||
|
$result = RevisitDueLogic::contact(
|
||||||
|
(int) $params['id'],
|
||||||
|
(string) $params['promised_date'],
|
||||||
|
(string) ($params['remark'] ?? ''),
|
||||||
|
$this->adminId,
|
||||||
|
$this->adminInfo
|
||||||
|
);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(RevisitDueLogic::getError() ?: '记录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($result['message'], $result['item'], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 人工关闭(原因 lost|other + 说明必填)
|
||||||
|
*/
|
||||||
|
public function close()
|
||||||
|
{
|
||||||
|
$params = (new RevisitDueValidate())->post()->goCheck('close');
|
||||||
|
$result = RevisitDueLogic::close(
|
||||||
|
(int) $params['id'],
|
||||||
|
(string) $params['reason'],
|
||||||
|
(string) ($params['remark'] ?? ''),
|
||||||
|
$this->adminId,
|
||||||
|
$this->adminInfo
|
||||||
|
);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(RevisitDueLogic::getError() ?: '关闭失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($result['message'], $result['item'], 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
|
|||||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||||
use app\common\{
|
use app\common\{
|
||||||
cache\AdminAuthCache,
|
cache\AdminAuthCache,
|
||||||
|
enum\AdminTerminalEnum,
|
||||||
service\JsonService
|
service\JsonService
|
||||||
};
|
};
|
||||||
use think\helper\Str;
|
use think\helper\Str;
|
||||||
@@ -47,7 +48,13 @@ class AuthMiddleware
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->adminInfo['login_ip'] != request()->ip()) {
|
// 登录 IP 绑定;客服后台服务器代查患者/订单的例外见 isTrustedRpaServerRequest
|
||||||
|
if ($request->adminInfo['login_ip'] != request()->ip()
|
||||||
|
&& !self::isTrustedRpaServerRequest(
|
||||||
|
(array) $request->adminInfo,
|
||||||
|
request()->ip(),
|
||||||
|
env('wecom_rpa.server_ips', '')
|
||||||
|
)) {
|
||||||
return JsonService::fail('ip地址发生变化,请重新登录', [], -1);
|
return JsonService::fail('ip地址发生变化,请重新登录', [], -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +80,18 @@ class AuthMiddleware
|
|||||||
// 当前访问路径
|
// 当前访问路径
|
||||||
$accessUri = strtolower($request->controller() . '/' . $request->action());
|
$accessUri = strtolower($request->controller() . '/' . $request->action());
|
||||||
|
|
||||||
|
// This group must never use the legacy unregistered-route bypass.
|
||||||
|
if (str_starts_with($accessUri, 'tcm.followupaudio/')) {
|
||||||
|
$allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete',
|
||||||
|
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio'];
|
||||||
|
$action = substr($accessUri, strlen('tcm.followupaudio/'));
|
||||||
|
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
||||||
|
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)) {
|
||||||
|
return $next($request); // The service also checks the current patient scope and daily permissions.
|
||||||
|
}
|
||||||
|
return JsonService::fail('权限不足,无法访问或操作');
|
||||||
|
}
|
||||||
|
|
||||||
// 获客助手的子接口多数不是独立菜单权限。整组动作统一绑定页面权限,
|
// 获客助手的子接口多数不是独立菜单权限。整组动作统一绑定页面权限,
|
||||||
// 共享操作人的动态页面权限也必须先经过这一层,再由业务层校验具体方案。
|
// 共享操作人的动态页面权限也必须先经过这一层,再由业务层校验具体方案。
|
||||||
if (str_starts_with($accessUri, 'firstvisit.wecompromotion/')) {
|
if (str_starts_with($accessUri, 'firstvisit.wecompromotion/')) {
|
||||||
@@ -122,6 +141,29 @@ class AuthMiddleware
|
|||||||
}, $data);
|
}, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信客服桌面软件(7)/ 手机控制台(9)的令牌会交给客服后台,由客服后台服务器
|
||||||
|
* 代查患者、订单;服务器出口 IP 与员工电脑登录时绑定的 IP 不同,按原规则每次都会被判为
|
||||||
|
* 「ip地址发生变化」。DesktopController::session 出于同一原因已免登录。
|
||||||
|
* 这里只在「RPA 终端令牌 + 请求来自 .env [WECOM_RPA] SERVER_IPS 配置的客服后台服务器 IP」
|
||||||
|
* 时跳过 IP 绑定,网页端/APP 等其他终端令牌仍保留 IP 保护;未配置时与原逻辑完全一致。
|
||||||
|
* 只跳过 IP 校验,后续菜单权限校验照常执行,客服后台拿到的仍只是该账号角色允许的数据。
|
||||||
|
*
|
||||||
|
* @param mixed $configured 逗号/空白分隔的 IP 列表,逐个精确匹配(IPv4/IPv6 字面量)
|
||||||
|
*/
|
||||||
|
public static function isTrustedRpaServerRequest(array $adminInfo, string $ip, $configured): bool
|
||||||
|
{
|
||||||
|
if (!in_array((int) ($adminInfo['terminal'] ?? 0), AdminTerminalEnum::wecomRpaTerminals(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($ip === '' || !is_string($configured)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$trustedIps = preg_split('/[\s,]+/', trim($configured), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
|
||||||
|
return in_array($ip, $trustedIps, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord,
|
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord,
|
||||||
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
|
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
|
|||||||
'50_60' => '50%~60%',
|
'50_60' => '50%~60%',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 分档口径版本(auto_assign_log.rate_version)文案 */
|
||||||
|
private const RATE_VERSION_LABELS = [
|
||||||
|
'v1' => '旧口径·分母仅新指派',
|
||||||
|
'v2' => '新口径·每列独立分母',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 设置搜索条件
|
* @notes 设置搜索条件
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +55,12 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
|
|||||||
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
|
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
|
||||||
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
|
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
|
||||||
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
|
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
|
||||||
|
// 两套口径:visit2_rate=新口径 v2(每列独立分母),visit2_rate_new_assign=旧口径 v1(分母仅新指派);rate_version=本批次分档所用口径
|
||||||
|
$newAssignRate = $row['visit2_rate_new_assign'] ?? null;
|
||||||
|
$row['visit2_rate_new_assign'] = $newAssignRate !== null ? (float) $newAssignRate : null;
|
||||||
|
$rateVersion = trim((string) ($row['rate_version'] ?? ''));
|
||||||
|
$row['rate_version'] = $rateVersion !== '' ? $rateVersion : 'v1';
|
||||||
|
$row['rate_version_text'] = self::RATE_VERSION_LABELS[$row['rate_version']] ?? $row['rate_version'];
|
||||||
|
|
||||||
$rbTs = (int) ($row['rollback_time'] ?? 0);
|
$rbTs = (int) ($row['rollback_time'] ?? 0);
|
||||||
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
|
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ use app\common\model\auth\Admin;
|
|||||||
use app\common\model\auth\AdminDept;
|
use app\common\model\auth\AdminDept;
|
||||||
use app\common\model\auth\AdminRole;
|
use app\common\model\auth\AdminRole;
|
||||||
use app\common\model\dict\DictData;
|
use app\common\model\dict\DictData;
|
||||||
|
use app\common\model\tcm\DiagnosisRevisitDue;
|
||||||
use app\common\lists\ListsSearchInterface;
|
use app\common\lists\ListsSearchInterface;
|
||||||
use app\common\lists\Traits\HasDataScopeFilter;
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use app\common\service\tcm\RevisitDueService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,6 +145,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
|
|
||||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyRevisitDueFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||||
|
|
||||||
@@ -460,6 +463,19 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
unset($item);
|
unset($item);
|
||||||
|
|
||||||
|
// 复诊到期(契约 C):按快照表批量读取,缺行/不一致时由服务内部自愈(仅小页前 50 行);读取异常由服务降级为 none 占位
|
||||||
|
$revisitDueMap = [];
|
||||||
|
if (!empty($diagIdsForUnserved)) {
|
||||||
|
$revisitDueMap = RevisitDueService::buildListPayload($diagIdsForUnserved, [
|
||||||
|
'self_heal' => true,
|
||||||
|
'page_size' => (int) $this->limitLength,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$item['revisit_due'] = $revisitDueMap[(int) $item['id']] ?? RevisitDueService::emptyPayload();
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
|
||||||
return $lists;
|
return $lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +614,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
|
|
||||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyRevisitDueFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
// 仅已开方(待分配+关键词检索时不限制)
|
// 仅已开方(待分配+关键词检索时不限制)
|
||||||
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||||
@@ -714,11 +731,120 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
|
* 复诊到期筛选(方案 §3.2 ②):以快照表 tcm_diagnosis_revisit_due 的 EXISTS 子查询实现。
|
||||||
|
*
|
||||||
|
* revisit_state:due(超期 ∪ 过号,排除已承诺未到期)| warning(到期前 warn_days 内)| booked(已约且日期未过)
|
||||||
|
* | visited(已面诊)| contacted(已承诺复诊日未过)| no_days(有有效订单但缺开药天数)| pending(到期日在预警窗之外)
|
||||||
|
* revisit_days_bucket:14|21|28|30 精确命中档位;other = 未命中档位且有开药天数
|
||||||
|
* 待分配宽搜不套此过滤;lists() 与 count() 各调用一次保证分页总数一致。
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyRevisitDueFilters($query, bool $pendingWideSearch): void
|
||||||
|
{
|
||||||
|
if ($pendingWideSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = strtolower(trim((string) ($this->params['revisit_state'] ?? '')));
|
||||||
|
$bucket = strtolower(trim((string) ($this->params['revisit_days_bucket'] ?? '')));
|
||||||
|
if ($state === '' && $bucket === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rdTbl = (new DiagnosisRevisitDue())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$conditions = ["rd.diagnosis_id = {$diagTbl}.id"];
|
||||||
|
|
||||||
|
if ($state !== '') {
|
||||||
|
$warnDays = (int) (RevisitDueService::config()['warn_days'] ?? 3);
|
||||||
|
if ($warnDays < 0) {
|
||||||
|
$warnDays = 0;
|
||||||
|
}
|
||||||
|
$warnEdge = "DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)";
|
||||||
|
switch ($state) {
|
||||||
|
case 'due':
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = '(rd.appointment_state = 4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE()))';
|
||||||
|
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
// 与读取侧 deriveState 对齐:过号(4)/已约已过期/已联系(承诺日未到) 的行优先级更高,不落入预警
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||||
|
$conditions[] = 'rd.due_date > CURDATE()';
|
||||||
|
$conditions[] = "rd.due_date <= {$warnEdge}";
|
||||||
|
$conditions[] = 'rd.appointment_state <> 4';
|
||||||
|
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||||
|
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||||
|
break;
|
||||||
|
case 'booked':
|
||||||
|
$conditions[] = 'rd.appointment_state = 1';
|
||||||
|
$conditions[] = 'rd.appointment_date IS NOT NULL';
|
||||||
|
$conditions[] = 'rd.appointment_date >= CURDATE()';
|
||||||
|
break;
|
||||||
|
case 'visited':
|
||||||
|
$conditions[] = '(rd.closed_state = 3 OR rd.appointment_state = 3)';
|
||||||
|
break;
|
||||||
|
case 'contacted':
|
||||||
|
$conditions[] = 'rd.promised_date IS NOT NULL';
|
||||||
|
$conditions[] = 'rd.promised_date >= CURDATE()';
|
||||||
|
break;
|
||||||
|
case 'no_days':
|
||||||
|
// 与 deriveState 的 no_days 分支对齐:进行中、有有效订单但算不出预计复诊日(失效行不计)
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = 'rd.valid_order_count > 0';
|
||||||
|
$conditions[] = 'rd.due_date IS NULL';
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||||
|
$conditions[] = "rd.due_date > {$warnEdge}";
|
||||||
|
$conditions[] = 'rd.appointment_state <> 4';
|
||||||
|
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||||
|
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 未知状态值:不做任何收窄(与其它未知筛选参数一致,避免误清空列表)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bucket !== '') {
|
||||||
|
if ($bucket === 'other') {
|
||||||
|
$conditions[] = 'rd.rule_days = 0';
|
||||||
|
$conditions[] = 'rd.medication_days > 0';
|
||||||
|
} elseif (ctype_digit($bucket) && (int) $bucket > 0) {
|
||||||
|
$conditions[] = 'rd.rule_days = ' . (int) $bucket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($conditions) <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereExists("SELECT 1 FROM {$rdTbl} rd WHERE " . implode(' AND ', $conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表默认排序;支持 sort_revisit_due=asc|desc 按复诊到期日排序(优先),
|
||||||
|
* 或 sort_unserved_days=asc|desc 按未服务天数排序;两者互斥,前者存在时忽略后者。
|
||||||
*/
|
*/
|
||||||
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
||||||
{
|
{
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
|
||||||
|
// 复诊到期日排序(方案 §3.2 ③):单行子查询取进行中快照的 due_date,无快照 / 无到期日的行置后
|
||||||
|
$sortRevisitDue = strtolower(trim((string) ($this->params['sort_revisit_due'] ?? '')));
|
||||||
|
if (in_array($sortRevisitDue, ['asc', 'desc'], true)) {
|
||||||
|
$rdTbl = (new DiagnosisRevisitDue())->getTable();
|
||||||
|
$dueExpr = "(SELECT rd.due_date FROM {$rdTbl} rd WHERE rd.diagnosis_id = {$diagTbl}.id AND rd.closed_state = 0 LIMIT 1)";
|
||||||
|
$nullLast = "CASE WHEN {$dueExpr} IS NULL THEN 1 ELSE 0 END ASC";
|
||||||
|
$dir = $sortRevisitDue === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
return "{$nullLast}, {$dueExpr} {$dir}, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
||||||
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
||||||
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
|
|||||||
use app\common\model\ExpressTracking;
|
use app\common\model\ExpressTracking;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
use app\common\service\gancao\GancaoScmRecipelService;
|
||||||
use app\common\service\pharmacy\EjPharmacyClient;
|
use app\common\service\pharmacy\EjPharmacyClient;
|
||||||
|
use app\common\service\tcm\RevisitVisitSlotService;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\db\Query;
|
use think\db\Query;
|
||||||
@@ -1464,7 +1465,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
|
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)。
|
||||||
|
* 诊次口径单点在 RevisitVisitSlotService:有效订单序列(fetchOrderSeqRows,一次 SQL 覆盖全部诊单,消除逐诊单 N+1)
|
||||||
|
* + 诊单偏移(fetchRevisitSlotStartOffsetMap / resolveRevisitSlotStartOffset 钳 0~20);global_visit_seq / counts_for_revisit_rate 规则不变。
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>> $lists
|
* @param array<int, array<string, mixed>> $lists
|
||||||
*/
|
*/
|
||||||
@@ -1490,26 +1493,23 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
|
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIdList);
|
||||||
->whereNull('delete_time')
|
|
||||||
->column('revisit_slot_start_offset', 'id');
|
|
||||||
$offsetMap = [];
|
|
||||||
foreach ($offsetRows as $id => $offset) {
|
|
||||||
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
|
/** @var array<int, int> $seqByOrderId order_id => 诊单内实单全局序号 */
|
||||||
$seqByOrderId = [];
|
$seqByOrderId = [];
|
||||||
foreach ($diagIdList as $did) {
|
foreach (array_chunk($diagIdList, 2000) as $chunk) {
|
||||||
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
|
// 已按 (diagnosis_id, create_time, id) 升序,同一诊单连续
|
||||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
|
$orderRows = RevisitVisitSlotService::fetchOrderSeqRows($chunk, ['o.id', 'o.diagnosis_id']);
|
||||||
$orderIds = $q
|
$curDid = 0;
|
||||||
->order(['create_time' => 'asc', 'id' => 'asc'])
|
|
||||||
->column('id');
|
|
||||||
$seq = 0;
|
$seq = 0;
|
||||||
foreach ($orderIds as $oid) {
|
foreach ($orderRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($did !== $curDid) {
|
||||||
|
$curDid = $did;
|
||||||
|
$seq = 0;
|
||||||
|
}
|
||||||
$seq++;
|
$seq++;
|
||||||
$seqByOrderId[(int) $oid] = $seq;
|
$seqByOrderId[(int) ($r['id'] ?? 0)] = $seq;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1517,7 +1517,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$oid = (int) ($row['id'] ?? 0);
|
$oid = (int) ($row['id'] ?? 0);
|
||||||
$did = (int) ($row['diagnosis_id'] ?? 0);
|
$did = (int) ($row['diagnosis_id'] ?? 0);
|
||||||
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
||||||
$offset = (int) ($offsetMap[$did] ?? 0);
|
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||||
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
||||||
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
||||||
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
|
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\lists\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\adminapi\logic\tcm\RevisitDueLogic;
|
||||||
|
use app\common\lists\ListsExcelInterface;
|
||||||
|
use app\common\lists\ListsSearchInterface;
|
||||||
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\auth\AdminDept;
|
||||||
|
use app\common\model\dept\Dept;
|
||||||
|
use app\common\model\tcm\DiagnosisRevisitDue;
|
||||||
|
use app\common\service\tcm\RevisitDueCalculator;
|
||||||
|
use app\common\service\tcm\RevisitDueService;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊到期跟进 - 主管团队视图列表(P2,方案 §3.3 tcm.revisitDue/lists)
|
||||||
|
*
|
||||||
|
* 数据来源:tcm_diagnosis_revisit_due 快照表(别名 rd),一诊单一行;时间相关状态由 RevisitDueCalculator::deriveState 按 today 推导。
|
||||||
|
* 筛选:assistant_id / dept_id(经 admin_dept 子查询,含下级部门)/ state / days_bucket / min_snooze / keyword(患者姓名);默认排除失效行(closed_state<>5)。
|
||||||
|
* state 的 SQL 条件与 DiagnosisLists::applyRevisitDueFilters 完全一致,另加 closed = closed_state=4(人工关闭,主管视图标记)。
|
||||||
|
* 数据范围:HasDataScopeFilter::applyDataScopeByOwner('rd.assistant_id'),主管按角色 data_scope 看下属。
|
||||||
|
* 排序:escalation_level desc(越点越上心优先),due_date asc(NULL 置后),id asc。
|
||||||
|
* 导出:export=1/2,字段见 setExcelFields。
|
||||||
|
*/
|
||||||
|
class RevisitDueLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExcelInterface
|
||||||
|
{
|
||||||
|
use HasDataScopeFilter;
|
||||||
|
|
||||||
|
/** 支持的 state 筛选值 */
|
||||||
|
public const STATES = ['due', 'warning', 'booked', 'visited', 'contacted', 'no_days', 'pending', 'closed'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 设置搜索条件(精确匹配列走框架 createWhere,其余在 buildQuery 内处理)
|
||||||
|
*/
|
||||||
|
public function setSearch(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'=' => ['rd.assistant_id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 获取列表
|
||||||
|
*/
|
||||||
|
public function lists(): array
|
||||||
|
{
|
||||||
|
$rows = $this->buildQuery()
|
||||||
|
->orderRaw('rd.escalation_level DESC, rd.due_date IS NULL ASC, rd.due_date ASC, rd.id ASC')
|
||||||
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
if ($rows === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = RevisitDueService::config();
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
|
||||||
|
$nameMap = $this->fetchAssistantNames($assistantIds);
|
||||||
|
$deptMap = $this->fetchAssistantDeptNames($assistantIds);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[] = $this->formatRow($row, $today, $cfg, $nameMap, $deptMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 获取数量
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return $this->buildQuery()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 导出文件名
|
||||||
|
*/
|
||||||
|
public function setFileName(): string
|
||||||
|
{
|
||||||
|
return '复诊到期跟进';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 导出字段
|
||||||
|
*/
|
||||||
|
public function setExcelFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'patient_name' => '患者',
|
||||||
|
'assistant_name' => '医助',
|
||||||
|
'dept_name' => '部门',
|
||||||
|
'export_slot' => '第N诊',
|
||||||
|
'medication_days' => '开药天数',
|
||||||
|
'due_date' => '预计复诊',
|
||||||
|
'export_overdue_days' => '超期天数',
|
||||||
|
'state_text' => '状态',
|
||||||
|
'snooze_count' => '延迟次数',
|
||||||
|
'contact_count' => '已联系',
|
||||||
|
'promised_date' => '承诺日',
|
||||||
|
'missed_count' => '过号次数',
|
||||||
|
'export_close_reason' => '关闭原因',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 内部
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \think\db\Query
|
||||||
|
*/
|
||||||
|
private function buildQuery()
|
||||||
|
{
|
||||||
|
$query = Db::name(RevisitDueService::TABLE)->alias('rd')->where($this->searchWhere);
|
||||||
|
|
||||||
|
// 默认排除失效行(无医助 / 诊单删除或禁用 / 无有效订单)
|
||||||
|
$query->where('rd.closed_state', '<>', RevisitDueCalculator::CLOSED_INVALID);
|
||||||
|
|
||||||
|
// 数据范围:主管按角色 data_scope 看下属医助
|
||||||
|
$this->applyDataScopeByOwner($query, 'rd.assistant_id');
|
||||||
|
|
||||||
|
// 部门:医助 ∈ admin_dept 且部门在所选部门子树内
|
||||||
|
$deptId = (int) ($this->params['dept_id'] ?? 0);
|
||||||
|
if ($deptId > 0) {
|
||||||
|
$deptIds = array_values(array_filter(array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)), static fn (int $v): bool => $v > 0));
|
||||||
|
if ($deptIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
} else {
|
||||||
|
$adTbl = (new AdminDept())->getTable();
|
||||||
|
$query->whereExists('SELECT 1 FROM `' . $adTbl . '` ad WHERE ad.admin_id = rd.assistant_id AND ad.dept_id IN (' . implode(',', $deptIds) . ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 患者姓名
|
||||||
|
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$query->whereLike('rd.patient_name', '%' . $keyword . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟次数下限
|
||||||
|
$minSnooze = (int) ($this->params['min_snooze'] ?? 0);
|
||||||
|
if ($minSnooze > 0) {
|
||||||
|
$query->where('rd.snooze_count', '>=', $minSnooze);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyStateFilter($query, strtolower(trim((string) ($this->params['state'] ?? ''))));
|
||||||
|
$this->applyBucketFilter($query, strtolower(trim((string) ($this->params['days_bucket'] ?? ''))));
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* state 筛选:SQL 条件与 DiagnosisLists::applyRevisitDueFilters 完全一致(表别名 rd),另加 closed。
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyStateFilter($query, string $state): void
|
||||||
|
{
|
||||||
|
if ($state === '' || !in_array($state, self::STATES, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$warnDays = (int) (RevisitDueService::config()['warn_days'] ?? 3);
|
||||||
|
if ($warnDays < 0) {
|
||||||
|
$warnDays = 0;
|
||||||
|
}
|
||||||
|
$warnEdge = "DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)";
|
||||||
|
$conditions = [];
|
||||||
|
switch ($state) {
|
||||||
|
case 'due':
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = '(rd.appointment_state = 4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE()))';
|
||||||
|
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||||
|
$conditions[] = 'rd.due_date > CURDATE()';
|
||||||
|
$conditions[] = "rd.due_date <= {$warnEdge}";
|
||||||
|
$conditions[] = 'rd.appointment_state <> 4';
|
||||||
|
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||||
|
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||||
|
break;
|
||||||
|
case 'booked':
|
||||||
|
$conditions[] = 'rd.appointment_state = 1';
|
||||||
|
$conditions[] = 'rd.appointment_date IS NOT NULL';
|
||||||
|
$conditions[] = 'rd.appointment_date >= CURDATE()';
|
||||||
|
break;
|
||||||
|
case 'visited':
|
||||||
|
$conditions[] = '(rd.closed_state = 3 OR rd.appointment_state = 3)';
|
||||||
|
break;
|
||||||
|
case 'contacted':
|
||||||
|
$conditions[] = 'rd.promised_date IS NOT NULL';
|
||||||
|
$conditions[] = 'rd.promised_date >= CURDATE()';
|
||||||
|
break;
|
||||||
|
case 'no_days':
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = 'rd.valid_order_count > 0';
|
||||||
|
$conditions[] = 'rd.due_date IS NULL';
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
$conditions[] = 'rd.closed_state = 0';
|
||||||
|
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||||
|
$conditions[] = "rd.due_date > {$warnEdge}";
|
||||||
|
$conditions[] = 'rd.appointment_state <> 4';
|
||||||
|
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||||
|
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||||
|
break;
|
||||||
|
case 'closed':
|
||||||
|
$conditions[] = 'rd.closed_state = ' . RevisitDueCalculator::CLOSED_MANUAL;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($conditions as $cond) {
|
||||||
|
$query->whereRaw($cond);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* days_bucket:14|21|28|30 精确命中档位;other = 未命中档位且有开药天数。
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyBucketFilter($query, string $bucket): void
|
||||||
|
{
|
||||||
|
if ($bucket === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($bucket === 'other') {
|
||||||
|
$query->where('rd.rule_days', 0)->where('rd.medication_days', '>', 0);
|
||||||
|
} elseif (ctype_digit($bucket) && (int) $bucket > 0) {
|
||||||
|
$query->where('rd.rule_days', (int) $bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行格式化:事实列 + deriveState 推导 + 医助/部门名 + 导出列。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @param array<string, mixed> $cfg
|
||||||
|
* @param array<int, string> $nameMap
|
||||||
|
* @param array<int, string> $deptMap
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatRow(array $row, string $today, array $cfg, array $nameMap, array $deptMap): array
|
||||||
|
{
|
||||||
|
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
|
||||||
|
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||||
|
$anchorTs = (int) ($row['anchor_ts'] ?? 0);
|
||||||
|
$closed = (int) ($row['closed_state'] ?? 0);
|
||||||
|
$slot = (int) ($row['current_slot'] ?? 0);
|
||||||
|
$reason = (string) ($row['handle_reason'] ?? '');
|
||||||
|
$remark = (string) ($row['handle_remark'] ?? '');
|
||||||
|
$reasonText = RevisitDueLogic::CLOSE_REASON_TEXT[$reason] ?? $reason;
|
||||||
|
$name = trim((string) ($row['patient_name'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'diagnosis_id' => (int) ($row['diagnosis_id'] ?? 0),
|
||||||
|
'patient_name' => $name !== '' ? $name : '患者',
|
||||||
|
'assistant_id' => $aid,
|
||||||
|
'assistant_name' => (string) ($nameMap[$aid] ?? ''),
|
||||||
|
'dept_name' => (string) ($deptMap[$aid] ?? ''),
|
||||||
|
'current_slot' => $slot,
|
||||||
|
'next_slot' => (int) ($row['next_slot'] ?? 0),
|
||||||
|
'medication_days' => (int) ($row['medication_days'] ?? 0),
|
||||||
|
'rule_days' => (int) ($row['rule_days'] ?? 0),
|
||||||
|
'anchor_date' => $anchorTs > 0 ? date('Y-m-d', $anchorTs) : '',
|
||||||
|
'due_date' => self::dateStr($row['due_date'] ?? null),
|
||||||
|
'overdue_days' => $st['overdue_days'],
|
||||||
|
'days_left' => $st['days_left'],
|
||||||
|
'state' => $st['state'],
|
||||||
|
'state_text' => $st['state_text'],
|
||||||
|
'severe' => (int) $st['severe'],
|
||||||
|
'appointment_state' => (int) ($row['appointment_state'] ?? 0),
|
||||||
|
'appointment_date' => self::dateStr($row['appointment_date'] ?? null),
|
||||||
|
'snooze_count' => (int) ($row['snooze_count'] ?? 0),
|
||||||
|
'escalation_level' => (int) ($row['escalation_level'] ?? 0),
|
||||||
|
'contact_count' => (int) ($row['contact_count'] ?? 0),
|
||||||
|
'promised_date' => self::dateStr($row['promised_date'] ?? null),
|
||||||
|
'missed_count' => (int) ($row['missed_count'] ?? 0),
|
||||||
|
'closed_state' => $closed,
|
||||||
|
'closed_state_text' => DiagnosisRevisitDue::CLOSED_STATE_TEXT_MAP[$closed] ?? '未知',
|
||||||
|
'handle_reason' => $reason,
|
||||||
|
'handle_remark' => $remark,
|
||||||
|
'first_shown_at_text' => self::tsText($row['first_shown_at'] ?? 0),
|
||||||
|
'last_snoozed_at_text' => self::tsText($row['last_snoozed_at'] ?? 0),
|
||||||
|
'computed_at_text' => self::tsText($row['computed_at'] ?? 0),
|
||||||
|
// 导出列
|
||||||
|
'export_slot' => $slot > 0 ? '第' . $slot . '诊' : '',
|
||||||
|
'export_overdue_days' => $st['overdue_days'] === null ? '' : (string) $st['overdue_days'],
|
||||||
|
'export_close_reason' => $closed === RevisitDueCalculator::CLOSED_MANUAL
|
||||||
|
? trim($reasonText . ($remark !== '' ? ':' . $remark : ''))
|
||||||
|
: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $ids
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function fetchAssistantNames(array $ids): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_filter($ids, static fn (int $v): bool => $v > 0));
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$map = [];
|
||||||
|
foreach (Admin::whereIn('id', $ids)->column('name', 'id') as $id => $n) {
|
||||||
|
$map[(int) $id] = (string) $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医助部门名:admin_dept 取最小 dept_id,再查 dept.name。
|
||||||
|
*
|
||||||
|
* @param list<int> $ids
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function fetchAssistantDeptNames(array $ids): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_filter($ids, static fn (int $v): bool => $v > 0));
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$minDept = [];
|
||||||
|
$adRows = AdminDept::whereIn('admin_id', $ids)->field(['admin_id', 'dept_id'])->select()->toArray();
|
||||||
|
foreach ($adRows as $ad) {
|
||||||
|
$aid = (int) ($ad['admin_id'] ?? 0);
|
||||||
|
$did = (int) ($ad['dept_id'] ?? 0);
|
||||||
|
if ($aid <= 0 || $did <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($minDept[$aid]) || $did < $minDept[$aid]) {
|
||||||
|
$minDept[$aid] = $did;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($minDept === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$deptNames = [];
|
||||||
|
foreach (Dept::whereIn('id', array_values(array_unique($minDept)))->column('name', 'id') as $id => $n) {
|
||||||
|
$deptNames[(int) $id] = (string) $n;
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($minDept as $aid => $did) {
|
||||||
|
$out[$aid] = (string) ($deptNames[$did] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间戳 → 'Y-m-d H:i' 或 '' */
|
||||||
|
private static function tsText(mixed $ts): string
|
||||||
|
{
|
||||||
|
$ts = (int) $ts;
|
||||||
|
|
||||||
|
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
|
||||||
|
private static function dateStr(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$s = substr(trim((string) $value), 0, 10);
|
||||||
|
|
||||||
|
return ($s === '' || $s === '0000-00-00') ? '' : $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ use app\adminapi\logic\tcm\TrackingNoteLogic;
|
|||||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use app\common\model\dict\DictData;
|
use app\common\model\dict\DictData;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
@@ -404,6 +405,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
$summary
|
$summary
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(新挂号 → 已约自动关闭;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged((int) $params['patient_id'], $newId, 'create');
|
||||||
|
|
||||||
return ['id' => $newId];
|
return ['id' => $newId];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -467,6 +471,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
$summary
|
$summary
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 刷新复诊到期快照(取消挂号 → 已约行重开;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged($diagId, $aptId, 'cancel');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -618,6 +625,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
|
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新复诊到期快照(面诊完成 → 已面诊关闭;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged((int) $appointment->patient_id, (int) $appointment->id, 'complete');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -1161,6 +1171,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
);
|
);
|
||||||
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
|
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(日期/状态变更影响已约/过号/已面诊判定;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged($patientId, $id, 'admin_edit');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ class AssistantPerformanceLogic
|
|||||||
/** 履约完成 */
|
/** 履约完成 */
|
||||||
private const FULFILLMENT_COMPLETED = 3;
|
private const FULFILLMENT_COMPLETED = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「我的复诊接诊率」卡片(方案 §10 D1-h):仅返回当前登录医助本人的复诊接诊率行。
|
||||||
|
* 口径与复诊接诊率页一致(inherit_mode 取配置 project.revisit_rate.default_inherit_mode,默认 slot:
|
||||||
|
* 继承患者按继承时诊次从第 k+1 诊起计入分母);非二中心医助或当月无被指派患者时 row=null。
|
||||||
|
*
|
||||||
|
* @param string $month 统计月份 YYYY-MM,空 / 非法时回退当前月
|
||||||
|
*
|
||||||
|
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
|
||||||
|
*/
|
||||||
|
public static function revisitRate(int $adminId, string $month): array
|
||||||
|
{
|
||||||
|
$mode = (string) config('project.revisit_rate.default_inherit_mode', 'slot');
|
||||||
|
|
||||||
|
return RevisitRateLogic::assistantOverview($adminId, $month, $mode);
|
||||||
|
}
|
||||||
|
|
||||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||||
{
|
{
|
||||||
// 时间范围解析
|
// 时间范围解析
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
|||||||
namespace app\adminapi\logic\stats;
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 待分配诊单自动指派日志:回退已自动分配的医助
|
* 待分配诊单自动指派日志:回退已自动分配的医助
|
||||||
|
*
|
||||||
|
* 回退写入的指派日志保持原逻辑 is_inherit=0(复诊接诊率原口径不变,用户要求);
|
||||||
|
* 复诊接诊率「新口径」下,回退行由 RevisitRateLogic 按 (diagnosis_id, rollback_time, rollback_admin_id) 匹配后按继承处理,不依赖本字段。
|
||||||
*/
|
*/
|
||||||
class AutoAssignLogLogic extends BaseLogic
|
class AutoAssignLogLogic extends BaseLogic
|
||||||
{
|
{
|
||||||
@@ -183,6 +187,9 @@ class AutoAssignLogLogic extends BaseLogic
|
|||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(回退 = 换回原医助或释放 to=0;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAssistantChanged($diagnosisId, $prevAssistantId, 'auto_assign_rollback');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\common\service\tcm\RevisitVisitSlotService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊接诊率 v2 纯计算器(不连库,承接 RevisitRateLogic::buildStatsCore 的内存计算;单测可直接驱动)。
|
||||||
|
*
|
||||||
|
* 口径(方案 §1.1,定义 A 累计型、每列独立分母):
|
||||||
|
* - NI(A):新指派集合 = 当月 is_inherit=0、to=A 的指派日志对应诊单(slot 模式下忽略 from==to 行)\ R;
|
||||||
|
* - IH(A):继承集合 = 当月 is_inherit=1(或 slot 模式下命中历史回退行)、to=A、from≠to 的诊单,且 D ∉ NI(A),\ R;
|
||||||
|
* 同一 (A, D) 当月多条继承取最早一条;
|
||||||
|
* - R:名下存在履约拒收(9)/退款(10) 订单的诊单(由调用方查库后传入);
|
||||||
|
* - 继承时诊次 k_D = slotBefore(该诊单有效订单 create_time 列表, offset, t_l)(严格小于,与持有人规则 t <= ct 对偶);
|
||||||
|
* 起算诊次 entry_D = max(inherit_min_next_slot, k_D + 1);entry_D > MAX_VISIT_SLOT 的继承行只进合计分母、不进任何诊次列;
|
||||||
|
* - 分子 Num_s(A):当月下单、slot = s、2 <= s <= 50、下单时点持有人为 A 的订单(与原实现完全一致,不受模式影响);
|
||||||
|
* - 无论模式都另行输出 NI_exclude(A)(:380 现状口径:仅 is_inherit=0,不忽略 from==to、不豁免回退行),
|
||||||
|
* 供 visit2_rate_new_assign 使用。
|
||||||
|
*
|
||||||
|
* inherit_mode:
|
||||||
|
* - exclude:完全复现旧口径,IH = ∅,NI = NI_exclude;
|
||||||
|
* - slot :继承按继承时诊次计入每列独立分母。
|
||||||
|
*/
|
||||||
|
final class RevisitRateCalculator
|
||||||
|
{
|
||||||
|
public const MODE_EXCLUDE = 'exclude';
|
||||||
|
public const MODE_SLOT = 'slot';
|
||||||
|
|
||||||
|
/** 复诊序号统计上限(与 RevisitRateLogic::MAX_VISIT_SLOT 一致) */
|
||||||
|
public const MAX_VISIT_SLOT = 50;
|
||||||
|
|
||||||
|
/** 继承起算诊次下限默认值(config project.revisit_rate.inherit_min_next_slot) */
|
||||||
|
public const DEFAULT_MIN_NEXT_SLOT = 2;
|
||||||
|
|
||||||
|
/** 旧口径 :380 的字段名(供自动分配过渡期分档) */
|
||||||
|
public const FIELD_RATE_NEW_ASSIGN = 'visit2_rate_new_assign';
|
||||||
|
|
||||||
|
/** 口径白名单归一化;非法值回退 $default */
|
||||||
|
public static function normalizeMode(mixed $raw, string $default = self::MODE_SLOT): string
|
||||||
|
{
|
||||||
|
$mode = strtolower(trim((string) $raw));
|
||||||
|
if ($mode === self::MODE_EXCLUDE || $mode === self::MODE_SLOT) {
|
||||||
|
return $mode;
|
||||||
|
}
|
||||||
|
$default = strtolower(trim($default));
|
||||||
|
|
||||||
|
return $default === self::MODE_EXCLUDE ? self::MODE_EXCLUDE : self::MODE_SLOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史回退行匹配键:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) ↔ assign_log(diagnosis_id, create_time, operator_admin_id) */
|
||||||
|
public static function rollbackKey(int $diagnosisId, int $time, int $operatorAdminId): string
|
||||||
|
{
|
||||||
|
return $diagnosisId . '|' . $time . '|' . $operatorAdminId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当月被指派(to>0、t∈M,含继承 / from==to 行)的诊单 id 去重列表,供查询拒收/退款剔除集合。
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $logRows
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function monthAssignedDiagIds(array $logRows, int $startTs, int $endTs): array
|
||||||
|
{
|
||||||
|
$set = [];
|
||||||
|
foreach ($logRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||||
|
$t = (int) ($r['create_time'] ?? 0);
|
||||||
|
if ($did > 0 && $aid > 0 && $t >= $startTs && $t <= $endTs) {
|
||||||
|
$set[$did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($set);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 曾被指派(to>0,任意时间,含继承)的诊单 id 列表:分子候选、订单序列拉取范围。
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $logRows
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function candidateDiagIds(array $logRows): array
|
||||||
|
{
|
||||||
|
$set = [];
|
||||||
|
foreach ($logRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($did > 0 && (int) ($r['to_assistant_id'] ?? 0) > 0) {
|
||||||
|
$set[$did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($set);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心计算。
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $logRows 指派日志(≤ 月末、诊单未删除),须按 (diagnosis_id, create_time, id) 升序;
|
||||||
|
* 键:id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit
|
||||||
|
* @param iterable<array<string, mixed>> $orderRows 有效订单行,须按 (diagnosis_id, create_time, id) 升序且同一诊单连续(可分块生成器);
|
||||||
|
* 键:id, diagnosis_id, create_time(其余字段原样进入 slotOrdersByAssistant)
|
||||||
|
* @param array<int, int> $offsetMap diagnosis_id => revisit_slot_start_offset(原始值)
|
||||||
|
* @param array<int, true> $refundRejectSet 名下有拒收/退款订单的诊单集合 R
|
||||||
|
* @param array<string, true> $rollbackKeySet 历史回退行键集合(rollbackKey()),仅 slot 模式使用
|
||||||
|
* @param string $inheritMode exclude | slot
|
||||||
|
* @param int $inheritMinNextSlot 继承起算诊次下限(默认 2)
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* inheritMode:string,
|
||||||
|
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
|
||||||
|
* diagsByAssistant:array<int,array<int,true>>,
|
||||||
|
* excludeDiagsByAssistant:array<int,array<int,true>>,
|
||||||
|
* inheritEntryByAssistant:array<int,array<int,int>>,
|
||||||
|
* inheritPairs:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int,inherit_visit_slot:int,entry_slot:int,assign_count:int,is_rollback:int,inherit_visit_slot_snapshot:int}>,
|
||||||
|
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||||
|
* maxEntry:int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function compute(
|
||||||
|
array $logRows,
|
||||||
|
iterable $orderRows,
|
||||||
|
array $offsetMap,
|
||||||
|
array $refundRejectSet,
|
||||||
|
array $rollbackKeySet,
|
||||||
|
int $startTs,
|
||||||
|
int $endTs,
|
||||||
|
string $inheritMode,
|
||||||
|
int $inheritMinNextSlot = self::DEFAULT_MIN_NEXT_SLOT
|
||||||
|
): array {
|
||||||
|
$inheritMode = self::normalizeMode($inheritMode);
|
||||||
|
$slotMode = $inheritMode === self::MODE_SLOT;
|
||||||
|
if ($inheritMinNextSlot < 2) {
|
||||||
|
$inheritMinNextSlot = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序,含释放/继承/from==to 行) */
|
||||||
|
$timeline = [];
|
||||||
|
$pairsRaw = [];
|
||||||
|
/** @var array<int, array<int, true>> $diagsByAssistant 当前模式下的 NI */
|
||||||
|
$diagsByAssistant = [];
|
||||||
|
/** @var array<int, array<int, true>> $excludeDiagsByAssistant :380 现状口径 NI_exclude */
|
||||||
|
$excludeDiagsByAssistant = [];
|
||||||
|
/** @var array<int, array<int, array<string, mixed>>> $inheritFirst 医助 => 诊单 => 最早一条继承日志(附 assign_count) */
|
||||||
|
$inheritFirst = [];
|
||||||
|
|
||||||
|
foreach ($logRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||||
|
$from = (int) ($r['from_assistant_id'] ?? 0);
|
||||||
|
$t = (int) ($r['create_time'] ?? 0);
|
||||||
|
$isInherit = (int) ($r['is_inherit'] ?? 0) === 1;
|
||||||
|
$logId = (int) ($r['id'] ?? 0);
|
||||||
|
if ($did <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$timeline[$did][] = ['t' => $t, 'to' => $aid];
|
||||||
|
if ($aid <= 0 || $t < $startTs || $t > $endTs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 旧口径集合:仅看 is_inherit=0(不忽略 from==to、不豁免回退行)
|
||||||
|
if (!$isInherit) {
|
||||||
|
$excludeDiagsByAssistant[$aid][$did] = true;
|
||||||
|
}
|
||||||
|
if (!$slotMode) {
|
||||||
|
if (!$isInherit) {
|
||||||
|
$pairsRaw[] = [
|
||||||
|
'diagnosis_id' => $did,
|
||||||
|
'to_assistant_id' => $aid,
|
||||||
|
'from_assistant_id' => $from,
|
||||||
|
'create_time' => $t,
|
||||||
|
'log_id' => $logId,
|
||||||
|
];
|
||||||
|
$diagsByAssistant[$aid][$did] = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// slot 模式:指派给自己(from == to)不计任何集合
|
||||||
|
if ($from === $aid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$isRollback = $rollbackKeySet !== []
|
||||||
|
&& isset($rollbackKeySet[self::rollbackKey($did, $t, (int) ($r['operator_admin_id'] ?? 0))]);
|
||||||
|
if ($isInherit || $isRollback) {
|
||||||
|
if (!isset($inheritFirst[$aid][$did])) {
|
||||||
|
$inheritFirst[$aid][$did] = [
|
||||||
|
'diagnosis_id' => $did,
|
||||||
|
'to_assistant_id' => $aid,
|
||||||
|
'from_assistant_id' => $from,
|
||||||
|
'create_time' => $t,
|
||||||
|
'log_id' => $logId,
|
||||||
|
'is_rollback' => $isRollback ? 1 : 0,
|
||||||
|
'inherit_visit_slot_snapshot' => (int) ($r['inherit_visit_slot'] ?? 0),
|
||||||
|
'assign_count' => 1,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$inheritFirst[$aid][$did]['assign_count']++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pairsRaw[] = [
|
||||||
|
'diagnosis_id' => $did,
|
||||||
|
'to_assistant_id' => $aid,
|
||||||
|
'from_assistant_id' => $from,
|
||||||
|
'create_time' => $t,
|
||||||
|
'log_id' => $logId,
|
||||||
|
];
|
||||||
|
$diagsByAssistant[$aid][$did] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D 同月既被非继承又被继承指派给同一 A 时只算 NI
|
||||||
|
foreach ($inheritFirst as $aid => $diagMap) {
|
||||||
|
foreach ($diagMap as $did => $_) {
|
||||||
|
if (isset($diagsByAssistant[$aid][$did])) {
|
||||||
|
unset($inheritFirst[$aid][$did]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($inheritFirst[$aid] === []) {
|
||||||
|
unset($inheritFirst[$aid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剔除名下存在拒收(9)/退款(10) 业务订单的诊单(NI / NI_exclude / IH 同时生效)
|
||||||
|
if ($refundRejectSet !== []) {
|
||||||
|
$strip = static function (array &$map) use ($refundRejectSet): void {
|
||||||
|
foreach ($map as $aid => $diagMap) {
|
||||||
|
foreach ($diagMap as $did => $_) {
|
||||||
|
if (isset($refundRejectSet[$did])) {
|
||||||
|
unset($map[$aid][$did]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($map[$aid] === []) {
|
||||||
|
unset($map[$aid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$strip($diagsByAssistant);
|
||||||
|
$strip($excludeDiagsByAssistant);
|
||||||
|
$strip($inheritFirst);
|
||||||
|
$pairsRaw = array_values(array_filter(
|
||||||
|
$pairsRaw,
|
||||||
|
static fn (array $p): bool => !isset($refundRejectSet[$p['diagnosis_id']])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, true> $inheritDiagSet 需要收集订单时间列表的诊单 */
|
||||||
|
$inheritDiagSet = [];
|
||||||
|
foreach ($inheritFirst as $diagMap) {
|
||||||
|
foreach ($diagMap as $did => $_) {
|
||||||
|
$inheritDiagSet[$did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @var array<int, list<int>> $inheritOrderCts 继承诊单 => 有效订单 create_time 列表 */
|
||||||
|
$inheritOrderCts = [];
|
||||||
|
|
||||||
|
// 分子:曾被指派诊单的订单,统计诊次 = 实单全局序号 + 诊单偏移;持有人 = 订单时间之前(含同刻)最近一次指派
|
||||||
|
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||||
|
$slotOrdersByAssistant = [];
|
||||||
|
$curDid = 0;
|
||||||
|
$seq = 0;
|
||||||
|
$ptr = 0;
|
||||||
|
$holder = 0;
|
||||||
|
$offset = 0;
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($did <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($did !== $curDid) {
|
||||||
|
$curDid = $did;
|
||||||
|
$seq = 0;
|
||||||
|
$ptr = 0;
|
||||||
|
$holder = 0;
|
||||||
|
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||||
|
}
|
||||||
|
$seq++;
|
||||||
|
$effectiveSlot = $seq + $offset;
|
||||||
|
$ct = (int) ($r['create_time'] ?? 0);
|
||||||
|
if (isset($inheritDiagSet[$did])) {
|
||||||
|
$inheritOrderCts[$did][] = $ct;
|
||||||
|
}
|
||||||
|
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||||
|
$tl = $timeline[$did] ?? [];
|
||||||
|
$tlCount = \count($tl);
|
||||||
|
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
|
||||||
|
$holder = (int) $tl[$ptr]['to'];
|
||||||
|
$ptr++;
|
||||||
|
}
|
||||||
|
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ct < $startTs || $ct > $endTs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($holder > 0) {
|
||||||
|
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继承时诊次 k 与起算诊次 entry
|
||||||
|
/** @var array<int, array<int, int>> $inheritEntryByAssistant */
|
||||||
|
$inheritEntryByAssistant = [];
|
||||||
|
$inheritPairs = [];
|
||||||
|
$maxEntry = 0;
|
||||||
|
foreach ($inheritFirst as $aid => $diagMap) {
|
||||||
|
foreach ($diagMap as $did => $row) {
|
||||||
|
$k = RevisitVisitSlotService::slotBefore(
|
||||||
|
$inheritOrderCts[$did] ?? [],
|
||||||
|
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
|
||||||
|
(int) $row['create_time']
|
||||||
|
);
|
||||||
|
$entry = max($inheritMinNextSlot, $k + 1);
|
||||||
|
$inheritEntryByAssistant[$aid][$did] = $entry;
|
||||||
|
if ($entry <= self::MAX_VISIT_SLOT && $entry > $maxEntry) {
|
||||||
|
$maxEntry = $entry;
|
||||||
|
}
|
||||||
|
$row['inherit_visit_slot'] = $k;
|
||||||
|
$row['entry_slot'] = $entry;
|
||||||
|
$inheritPairs[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'inheritMode' => $inheritMode,
|
||||||
|
'pairsRaw' => $pairsRaw,
|
||||||
|
'diagsByAssistant' => $diagsByAssistant,
|
||||||
|
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
|
||||||
|
'inheritEntryByAssistant' => $inheritEntryByAssistant,
|
||||||
|
'inheritPairs' => $inheritPairs,
|
||||||
|
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||||
|
'maxEntry' => $maxEntry,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单集合按医助并集(部门行 / 合计行按诊单去重)。
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, true>> $setsByAssistant
|
||||||
|
* @param list<int> $assistantIds
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
public static function unionDiagSets(array $setsByAssistant, array $assistantIds): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($assistantIds as $aid) {
|
||||||
|
foreach ($setsByAssistant[(int) $aid] ?? [] as $did => $_) {
|
||||||
|
$out[(int) $did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门行 / 合计行的 NI 与 IH 合并:NI_dept = ∪NI(A);IH_dept = (∪IH(A)) \ NI_dept,entry 取各继承者中的最小值。
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, true>> $diagsByAssistant
|
||||||
|
* @param array<int, array<int, int>> $inheritEntryByAssistant
|
||||||
|
* @param list<int> $assistantIds
|
||||||
|
*
|
||||||
|
* @return array{0: array<int, true>, 1: array<int, int>} [NI 去重集合, 诊单 => entry]
|
||||||
|
*/
|
||||||
|
public static function mergeInheritEntries(array $diagsByAssistant, array $inheritEntryByAssistant, array $assistantIds): array
|
||||||
|
{
|
||||||
|
$niSet = self::unionDiagSets($diagsByAssistant, $assistantIds);
|
||||||
|
$ih = [];
|
||||||
|
foreach ($assistantIds as $aid) {
|
||||||
|
foreach ($inheritEntryByAssistant[(int) $aid] ?? [] as $did => $entry) {
|
||||||
|
$did = (int) $did;
|
||||||
|
if (isset($niSet[$did])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entry = (int) $entry;
|
||||||
|
$ih[$did] = isset($ih[$did]) ? min($ih[$did], $entry) : $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$niSet, $ih];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列范围:2 ~ min(MAX, max(minCeiling, 当月分子最大 slot, 最大起算诊次))。
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function slotRange(array $slotOrdersByAssistant, int $maxEntry, int $minCeiling = 4): array
|
||||||
|
{
|
||||||
|
$maxHitSlot = 0;
|
||||||
|
foreach ($slotOrdersByAssistant as $slotMap) {
|
||||||
|
foreach ($slotMap as $slot => $_) {
|
||||||
|
if ((int) $slot > $maxHitSlot) {
|
||||||
|
$maxHitSlot = (int) $slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$upper = max($minCeiling, $maxHitSlot, $maxEntry);
|
||||||
|
$upper = min(self::MAX_VISIT_SLOT, max(2, $upper));
|
||||||
|
|
||||||
|
return range(2, $upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标包:
|
||||||
|
* assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
|
||||||
|
* visit{N}_count / _den / _rate(Den_s = NI + |{entry <= s}|)、visit_all_count / _den / _rate、visit2_rate_new_assign。
|
||||||
|
*
|
||||||
|
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
|
||||||
|
* @param array<int, int> $slotCounts [slot => n]
|
||||||
|
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||||
|
*
|
||||||
|
* @return array<string, int|float|null>
|
||||||
|
*/
|
||||||
|
public static function metricPack(
|
||||||
|
int $newAssigned,
|
||||||
|
array $inheritEntryByDiag,
|
||||||
|
array $slotCounts,
|
||||||
|
array $slots,
|
||||||
|
int $newAssignedExclude
|
||||||
|
): array {
|
||||||
|
$inheritCount = \count($inheritEntryByDiag);
|
||||||
|
/** @var array<int, int> $byEntry entry => 继承诊单数 */
|
||||||
|
$byEntry = [];
|
||||||
|
foreach ($inheritEntryByDiag as $entry) {
|
||||||
|
$entry = (int) $entry;
|
||||||
|
$byEntry[$entry] = ($byEntry[$entry] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
ksort($byEntry);
|
||||||
|
|
||||||
|
$pack = [
|
||||||
|
'assigned_count' => $newAssigned,
|
||||||
|
'inherit_count' => $inheritCount,
|
||||||
|
'assigned_total_count' => $newAssigned + $inheritCount,
|
||||||
|
];
|
||||||
|
foreach ($slots as $slot) {
|
||||||
|
$slot = (int) $slot;
|
||||||
|
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
||||||
|
$den = $newAssigned;
|
||||||
|
foreach ($byEntry as $entry => $n) {
|
||||||
|
if ($entry > $slot) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$den += $n;
|
||||||
|
}
|
||||||
|
$pack['visit' . $slot . '_count'] = $cnt;
|
||||||
|
$pack['visit' . $slot . '_den'] = $den;
|
||||||
|
$pack['visit' . $slot . '_rate'] = $den > 0 ? round($cnt / $den * 100, 2) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allCount = 0;
|
||||||
|
foreach ($slotCounts as $slot => $cnt) {
|
||||||
|
$slot = (int) $slot;
|
||||||
|
if ($slot >= 2 && $slot <= self::MAX_VISIT_SLOT) {
|
||||||
|
$allCount += (int) $cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$allDen = $newAssigned + $inheritCount;
|
||||||
|
$pack['visit_all_count'] = $allCount;
|
||||||
|
$pack['visit_all_den'] = $allDen;
|
||||||
|
$pack['visit_all_rate'] = $allDen > 0 ? round($allCount / $allDen * 100, 2) : null;
|
||||||
|
|
||||||
|
$num2 = (int) ($slotCounts[2] ?? 0);
|
||||||
|
$pack[self::FIELD_RATE_NEW_ASSIGN] = $newAssignedExclude > 0
|
||||||
|
? round($num2 / $newAssignedExclude * 100, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $pack;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,29 +5,32 @@ declare(strict_types=1);
|
|||||||
namespace app\adminapi\logic\stats;
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
use app\adminapi\logic\dept\DeptLogic;
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\common\service\tcm\RevisitVisitSlotService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复诊接诊率统计(按月)
|
* 复诊接诊率统计(按月)—— v2:继承按继承时诊次计入每列独立分母 + 复诊合计列(方案 §1.1,定义 A 累计型)。
|
||||||
*
|
*
|
||||||
* 口径说明:
|
* 口径说明(内存计算见 RevisitRateCalculator,诊次口径见 RevisitVisitSlotService):
|
||||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
* - 新指派数 assigned_count = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
* **is_inherit=0**,诊单未删除)去重后的「医助 × 诊单」组合(NI);同一诊单当月多次指派给同一医助只计 1 次;
|
||||||
* 同一诊单当月被多次指派给同一医助只计 1 次;
|
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单(R,不限月份)。
|
||||||
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
|
* - 继承数 inherit_count = 当月 **is_inherit=1**、to>0、from≠to 的「医助 × 诊单」(IH),同一 (医助, 诊单) 多条继承取最早;
|
||||||
|
* 同月既新指派又继承给同一医助只算新指派;剔除 R。历史「回退自动分配」行(auto_assign_log.rollback_time 匹配)按继承处理。
|
||||||
|
* 继承时诊次 k = 继承时刻之前(严格小于)的有效实单数 + 偏移;起算诊次 entry = max(2, k+1);entry>50 只进合计分母。
|
||||||
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||||
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
* 诊次**跨月累计不重置**;诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;偏移 n 即在实单序号上叠加 n)。
|
||||||
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
|
* - 当月 N 诊单数 visit{N}_count = **当月内下单**且诊次为 N 的订单数,归属下单时点的**持有医助**——
|
||||||
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
* 按指派日志时间线取「订单时间之前(含同刻)最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;继承会转移持有人)。
|
||||||
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
* - 当月 N 诊分母 visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数;接诊率 visit{N}_rate = count ÷ den(den=0 → null)。
|
||||||
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
* - 复诊合计:visit_all_count = Σ_{N≥2} visit{N}_count;visit_all_den = assigned_total_count = |NI ∪ IH|;visit_all_rate 可超过 100%。
|
||||||
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
|
* - 旧口径开关 inherit_mode=exclude|slot(默认 config project.revisit_rate.default_inherit_mode):
|
||||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
* exclude 完全复现旧实现(IH=∅、不忽略 from==to、不豁免回退行,assigned_count/visit{N}_count/visit{N}_rate 与历史逐值一致);
|
||||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
* 无论模式都输出 visit2_rate_new_assign = 二诊单数 ÷ 旧口径新指派数(供自动分配过渡期分档)。
|
||||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
* - 分档动态产出:N 从 2 起,至 max(4, 当月分子最大诊次, 最大起算诊次)(上限 MAX_VISIT_SLOT),返回 `slots` 供前端动态渲染。
|
||||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||||
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
* - 部门行 / 合计行:NI 按诊单去重;IH = (∪IH(A)) \ NI_dept,entry 取部门内最小值;N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||||
*/
|
*/
|
||||||
class RevisitRateLogic
|
class RevisitRateLogic
|
||||||
{
|
{
|
||||||
@@ -41,10 +44,10 @@ class RevisitRateLogic
|
|||||||
private const UNASSIGNED_DEPT_NAME = '未分配部门';
|
private const UNASSIGNED_DEPT_NAME = '未分配部门';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* month:string,start_date:string,end_date:string,
|
* month:string,start_date:string,end_date:string,inherit_mode:string,
|
||||||
* slots:list<int>,
|
* slots:list<int>,
|
||||||
* total:array<string,int|float|null>,
|
* total:array<string,int|float|null>,
|
||||||
* rows:list<array<string,mixed>>
|
* rows:list<array<string,mixed>>
|
||||||
@@ -62,22 +65,19 @@ class RevisitRateLogic
|
|||||||
'month' => $month,
|
'month' => $month,
|
||||||
'start_date' => $ctx['startDate'],
|
'start_date' => $ctx['startDate'],
|
||||||
'end_date' => $ctx['endDate'],
|
'end_date' => $ctx['endDate'],
|
||||||
|
'inherit_mode' => $ctx['inheritMode'],
|
||||||
'slots' => $minSlots,
|
'slots' => $minSlots,
|
||||||
'total' => self::buildMetricPack(0, [], $minSlots),
|
'total' => self::buildMetricPack(0, [], [], $minSlots, 0),
|
||||||
'rows' => [],
|
'rows' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分档:2 ~ max(4, 当月命中的最大诊次)
|
// 分档:2 ~ max(4, 当月命中的最大诊次, 最大起算诊次),上限 MAX_VISIT_SLOT
|
||||||
$maxHitSlot = 0;
|
$slots = RevisitRateCalculator::slotRange(
|
||||||
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
|
$ctx['slotOrdersByAssistant'],
|
||||||
foreach ($slotMap as $slot => $_) {
|
(int) $ctx['maxEntry'],
|
||||||
if ((int) $slot > $maxHitSlot) {
|
self::MIN_VISIT_SLOT_CEILING
|
||||||
$maxHitSlot = (int) $slot;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
|
|
||||||
|
|
||||||
$nameMap = Db::name('admin')
|
$nameMap = Db::name('admin')
|
||||||
->whereIn('id', array_keys($universe))
|
->whereIn('id', array_keys($universe))
|
||||||
@@ -86,22 +86,19 @@ class RevisitRateLogic
|
|||||||
// 医助行(按部门分组收集)
|
// 医助行(按部门分组收集)
|
||||||
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
||||||
$assistantRowsByDept = [];
|
$assistantRowsByDept = [];
|
||||||
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
|
/** @var array<int, list<int>> $deptAssistants 部门 => 医助 id 列表(部门行按诊单去重用) */
|
||||||
$deptDiagSet = [];
|
$deptAssistants = [];
|
||||||
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
||||||
$deptSlotCounts = [];
|
$deptSlotCounts = [];
|
||||||
/** @var array<int, true> $totalDiagSet */
|
|
||||||
$totalDiagSet = [];
|
|
||||||
/** @var array<int, int> $totalSlotCounts */
|
/** @var array<int, int> $totalSlotCounts */
|
||||||
$totalSlotCounts = [];
|
$totalSlotCounts = [];
|
||||||
|
|
||||||
foreach ($universe as $aid => $_) {
|
foreach ($universe as $aid => $_) {
|
||||||
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
||||||
|
$deptAssistants[$deptId][] = (int) $aid;
|
||||||
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
||||||
foreach ($diagSet as $did => $_d) {
|
$inheritEntries = $ctx['inheritEntryByAssistant'][$aid] ?? [];
|
||||||
$deptDiagSet[$deptId][$did] = true;
|
$excludeSet = $ctx['excludeDiagsByAssistant'][$aid] ?? [];
|
||||||
$totalDiagSet[$did] = true;
|
|
||||||
}
|
|
||||||
$slotCounts = [];
|
$slotCounts = [];
|
||||||
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
||||||
$cnt = \count($orders);
|
$cnt = \count($orders);
|
||||||
@@ -119,10 +116,10 @@ class RevisitRateLogic
|
|||||||
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
||||||
'dept_id' => $deptId,
|
'dept_id' => $deptId,
|
||||||
'dept_name' => $deptName,
|
'dept_name' => $deptName,
|
||||||
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
|
] + self::buildMetricPack(\count($diagSet), $inheritEntries, $slotCounts, $slots, \count($excludeSet));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 部门行 + 子行
|
// 部门行 + 子行(NI 按诊单去重;IH = ∪IH(A) \ NI_dept,entry 取最小)
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($assistantRowsByDept as $deptId => $children) {
|
foreach ($assistantRowsByDept as $deptId => $children) {
|
||||||
usort($children, static function (array $a, array $b): int {
|
usort($children, static function (array $a, array $b): int {
|
||||||
@@ -135,6 +132,13 @@ class RevisitRateLogic
|
|||||||
$deptName = $deptId > 0
|
$deptName = $deptId > 0
|
||||||
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||||
: self::UNASSIGNED_DEPT_NAME;
|
: self::UNASSIGNED_DEPT_NAME;
|
||||||
|
$aids = $deptAssistants[$deptId] ?? [];
|
||||||
|
[$deptNiSet, $deptIhEntries] = RevisitRateCalculator::mergeInheritEntries(
|
||||||
|
$ctx['diagsByAssistant'],
|
||||||
|
$ctx['inheritEntryByAssistant'],
|
||||||
|
$aids
|
||||||
|
);
|
||||||
|
$deptExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $aids);
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'row_key' => 'd' . $deptId,
|
'row_key' => 'd' . $deptId,
|
||||||
'is_dept' => 1,
|
'is_dept' => 1,
|
||||||
@@ -142,7 +146,13 @@ class RevisitRateLogic
|
|||||||
'dept_name' => $deptName,
|
'dept_name' => $deptName,
|
||||||
'assistant_count' => \count($children),
|
'assistant_count' => \count($children),
|
||||||
'children' => $children,
|
'children' => $children,
|
||||||
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
|
] + self::buildMetricPack(
|
||||||
|
\count($deptNiSet),
|
||||||
|
$deptIhEntries,
|
||||||
|
$deptSlotCounts[$deptId] ?? [],
|
||||||
|
$slots,
|
||||||
|
\count($deptExcludeSet)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
usort($rows, static function (array $a, array $b): int {
|
usort($rows, static function (array $a, array $b): int {
|
||||||
if ($a['assigned_count'] !== $b['assigned_count']) {
|
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||||
@@ -152,62 +162,182 @@ class RevisitRateLogic
|
|||||||
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
|
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$allAids = array_map('intval', array_keys($universe));
|
||||||
|
[$totalNiSet, $totalIhEntries] = RevisitRateCalculator::mergeInheritEntries(
|
||||||
|
$ctx['diagsByAssistant'],
|
||||||
|
$ctx['inheritEntryByAssistant'],
|
||||||
|
$allAids
|
||||||
|
);
|
||||||
|
$totalExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $allAids);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
'start_date' => $ctx['startDate'],
|
'start_date' => $ctx['startDate'],
|
||||||
'end_date' => $ctx['endDate'],
|
'end_date' => $ctx['endDate'],
|
||||||
|
'inherit_mode' => $ctx['inheritMode'],
|
||||||
'slots' => $slots,
|
'slots' => $slots,
|
||||||
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
|
'total' => self::buildMetricPack(
|
||||||
|
\count($totalNiSet),
|
||||||
|
$totalIhEntries,
|
||||||
|
$totalSlotCounts,
|
||||||
|
$slots,
|
||||||
|
\count($totalExcludeSet)
|
||||||
|
),
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
|
* 医助本人「我的 N 诊接诊率」(个人业绩页卡片,方案 §10 D1-h)。
|
||||||
|
*
|
||||||
|
* 复用 overview() 的全量结果(overview 不按个人数据范围过滤、仅限二中心子树),只从各部门行的 children 中
|
||||||
|
* 挑出 assistant_id 匹配的医助行返回,不暴露其他医助数据;本人不在二中心子树或当月无任何指派 / 成交时 row=null,
|
||||||
|
* in_scope 用于区分两种情况(false=非二中心医助)。结果按 month / assistant / mode 缓存 600 秒,缓存不可用时直接计算。
|
||||||
|
*
|
||||||
|
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
|
||||||
|
*/
|
||||||
|
public static function assistantOverview(int $assistantId, string $month, string $inheritMode = 'slot'): array
|
||||||
|
{
|
||||||
|
$month = self::normalizeMonth($month);
|
||||||
|
$inheritMode = $inheritMode === 'exclude' ? 'exclude' : 'slot';
|
||||||
|
$cacheKey = sprintf('revisit_rate:assistant_overview:%s:%d:%s', $month, $assistantId, $inheritMode);
|
||||||
|
try {
|
||||||
|
$cached = \think\facade\Cache::get($cacheKey);
|
||||||
|
if (\is_array($cached) && isset($cached['month'], $cached['slots'])) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 缓存驱动不可用:直接计算
|
||||||
|
}
|
||||||
|
|
||||||
|
$overview = self::overview(['month' => $month, 'inherit_mode' => $inheritMode]);
|
||||||
|
$row = null;
|
||||||
|
if ($assistantId > 0) {
|
||||||
|
foreach ($overview['rows'] as $deptRow) {
|
||||||
|
foreach ((array) ($deptRow['children'] ?? []) as $child) {
|
||||||
|
if ((int) ($child['assistant_id'] ?? 0) === $assistantId) {
|
||||||
|
$row = $child;
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否属于二中心子树:有行必然在子树内;无行时按人事部门判断,供前端区分「非二中心」与「当月无被指派患者」
|
||||||
|
$inScope = $row !== null;
|
||||||
|
if (!$inScope && $assistantId > 0) {
|
||||||
|
[$assistantDept] = self::buildAssistantDeptIndex([$assistantId]);
|
||||||
|
$deptId = (int) ($assistantDept[$assistantId] ?? 0);
|
||||||
|
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
$inScope = $deptId > 0 && isset($erSet[$deptId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'month' => (string) $overview['month'],
|
||||||
|
'inherit_mode' => (string) $overview['inherit_mode'],
|
||||||
|
'slots' => array_values(array_map('intval', (array) $overview['slots'])),
|
||||||
|
'in_scope' => $inScope,
|
||||||
|
'row' => $row,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
\think\facade\Cache::set($cacheKey, $result, 600);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 写缓存失败不影响返回
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被指派明细(按诊单聚合,与「新指派数 / 继承数 / 被指派合计」同口径可对账)。
|
||||||
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
|
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
|
||||||
|
* kind:new=仅新指派行 / inherit=仅继承行 / all(默认)=两者;行字段 assign_type(new|inherit)、
|
||||||
|
* inherit_visit_slot(继承时诊次 k,新指派为 null)、entry_slot(起算诊次;新指派恒为 2)。
|
||||||
|
* 部门/合计 scope 下与 overview 去重规则一致:诊单只要对 scope 内任一医助为新指派即记为 new,
|
||||||
|
* 否则为 inherit 且 entry 取 scope 内最小值;故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= visit{N}_den。
|
||||||
*
|
*
|
||||||
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string,kind?:string} $params
|
||||||
*
|
*
|
||||||
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
|
* @return array{month:string,inherit_mode:string,kind:string,count:int,rows:list<array<string,mixed>>}
|
||||||
*/
|
*/
|
||||||
public static function assignLines(array $params): array
|
public static function assignLines(array $params): array
|
||||||
{
|
{
|
||||||
$ctx = self::buildStatsCore($params);
|
$ctx = self::buildStatsCore($params);
|
||||||
$assistantSet = self::applyRowScope($ctx, $params);
|
$assistantSet = self::applyRowScope($ctx, $params);
|
||||||
|
$kind = strtolower(trim((string) ($params['kind'] ?? 'all')));
|
||||||
|
if (!\in_array($kind, ['new', 'inherit', 'all'], true)) {
|
||||||
|
$kind = 'all';
|
||||||
|
}
|
||||||
|
$minNext = self::inheritMinNextSlot();
|
||||||
|
|
||||||
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
|
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $niByDiag */
|
||||||
$byDiag = [];
|
$niByDiag = [];
|
||||||
foreach ($ctx['pairsRaw'] as $p) {
|
foreach ($ctx['pairsRaw'] as $p) {
|
||||||
$aid = (int) $p['to_assistant_id'];
|
$aid = (int) $p['to_assistant_id'];
|
||||||
$did = (int) $p['diagnosis_id'];
|
$did = (int) $p['diagnosis_id'];
|
||||||
if (!isset($assistantSet[$aid])) {
|
if (!isset($assistantSet[$aid])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isset($byDiag[$did])) {
|
if (!isset($niByDiag[$did])) {
|
||||||
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
$niByDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
||||||
}
|
}
|
||||||
$byDiag[$did]['assistants'][$aid] = true;
|
$niByDiag[$did]['assistants'][$aid] = true;
|
||||||
$byDiag[$did]['assign_count']++;
|
$niByDiag[$did]['assign_count']++;
|
||||||
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
|
$niByDiag[$did]['last_time'] = max($niByDiag[$did]['last_time'], (int) $p['create_time']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($byDiag === []) {
|
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int, k: int, entry: int, is_rollback: int}> $ihByDiag */
|
||||||
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
|
$ihByDiag = [];
|
||||||
|
foreach ($ctx['inheritPairs'] as $p) {
|
||||||
|
$aid = (int) $p['to_assistant_id'];
|
||||||
|
$did = (int) $p['diagnosis_id'];
|
||||||
|
if (!isset($assistantSet[$aid]) || isset($niByDiag[$did])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entry = (int) $p['entry_slot'];
|
||||||
|
if (!isset($ihByDiag[$did])) {
|
||||||
|
$ihByDiag[$did] = [
|
||||||
|
'assistants' => [],
|
||||||
|
'assign_count' => 0,
|
||||||
|
'last_time' => 0,
|
||||||
|
'k' => (int) $p['inherit_visit_slot'],
|
||||||
|
'entry' => $entry,
|
||||||
|
'is_rollback' => (int) ($p['is_rollback'] ?? 0),
|
||||||
|
];
|
||||||
|
} elseif ($entry < $ihByDiag[$did]['entry']) {
|
||||||
|
$ihByDiag[$did]['entry'] = $entry;
|
||||||
|
$ihByDiag[$did]['k'] = (int) $p['inherit_visit_slot'];
|
||||||
|
$ihByDiag[$did]['is_rollback'] = (int) ($p['is_rollback'] ?? 0);
|
||||||
|
}
|
||||||
|
$ihByDiag[$did]['assistants'][$aid] = true;
|
||||||
|
$ihByDiag[$did]['assign_count'] += (int) ($p['assign_count'] ?? 1);
|
||||||
|
$ihByDiag[$did]['last_time'] = max($ihByDiag[$did]['last_time'], (int) $p['create_time']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
|
if ($kind === 'new') {
|
||||||
|
$ihByDiag = [];
|
||||||
|
} elseif ($kind === 'inherit') {
|
||||||
|
$niByDiag = [];
|
||||||
|
}
|
||||||
|
if ($niByDiag === [] && $ihByDiag === []) {
|
||||||
|
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => 0, 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagInfo = self::fetchDiagnosisInfo(array_values(array_unique(array_merge(array_keys($niByDiag), array_keys($ihByDiag)))));
|
||||||
$assistantIds = [];
|
$assistantIds = [];
|
||||||
foreach ($byDiag as $d) {
|
foreach ([$niByDiag, $ihByDiag] as $map) {
|
||||||
|
foreach ($map as $d) {
|
||||||
foreach ($d['assistants'] as $aid => $_) {
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
$assistantIds[$aid] = true;
|
$assistantIds[$aid] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
$nameMap = Db::name('admin')
|
$nameMap = Db::name('admin')
|
||||||
->whereIn('id', array_keys($assistantIds))
|
->whereIn('id', array_keys($assistantIds))
|
||||||
->column('name', 'id');
|
->column('name', 'id');
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($byDiag as $did => $d) {
|
foreach ($niByDiag as $did => $d) {
|
||||||
$names = [];
|
$names = [];
|
||||||
foreach ($d['assistants'] as $aid => $_) {
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||||
@@ -220,24 +350,50 @@ class RevisitRateLogic
|
|||||||
'assign_count' => (int) $d['assign_count'],
|
'assign_count' => (int) $d['assign_count'],
|
||||||
'last_assign_time' => (int) $d['last_time'],
|
'last_assign_time' => (int) $d['last_time'],
|
||||||
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
||||||
|
'assign_type' => 'new',
|
||||||
|
'assign_type_text' => '新指派',
|
||||||
|
'inherit_visit_slot' => null,
|
||||||
|
'entry_slot' => $minNext,
|
||||||
|
'is_rollback' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
foreach ($ihByDiag as $did => $d) {
|
||||||
|
$names = [];
|
||||||
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
|
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'diagnosis_id' => (int) $did,
|
||||||
|
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||||
|
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||||
|
'assistant_names' => implode('、', $names),
|
||||||
|
'assign_count' => (int) $d['assign_count'],
|
||||||
|
'last_assign_time' => (int) $d['last_time'],
|
||||||
|
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
||||||
|
'assign_type' => 'inherit',
|
||||||
|
'assign_type_text' => $d['is_rollback'] === 1 ? '继承(回退)' : '继承',
|
||||||
|
'inherit_visit_slot' => (int) $d['k'],
|
||||||
|
'entry_slot' => (int) $d['entry'],
|
||||||
|
'is_rollback' => (int) $d['is_rollback'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
|
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
|
||||||
|
|
||||||
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
|
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => \count($rows), 'rows' => $rows];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
|
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
|
||||||
|
* slot=0 表示全部 N>=2(与 visit_all_count 对账),行附 visit_slot。
|
||||||
*
|
*
|
||||||
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string} $params
|
||||||
*
|
*
|
||||||
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
|
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
|
||||||
*/
|
*/
|
||||||
public static function visitOrderLines(array $params): array
|
public static function visitOrderLines(array $params): array
|
||||||
{
|
{
|
||||||
$slot = (int) ($params['slot'] ?? 0);
|
$slot = (int) ($params['slot'] ?? 0);
|
||||||
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
|
if ($slot < 0 || $slot === 1 || $slot > self::MAX_VISIT_SLOT) {
|
||||||
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
|
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,11 +402,18 @@ class RevisitRateLogic
|
|||||||
|
|
||||||
$orderRows = [];
|
$orderRows = [];
|
||||||
foreach ($assistantSet as $aid => $_) {
|
foreach ($assistantSet as $aid => $_) {
|
||||||
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
|
$slotMap = $ctx['slotOrdersByAssistant'][$aid] ?? [];
|
||||||
|
if ($slot > 0) {
|
||||||
|
$slotMap = isset($slotMap[$slot]) ? [$slot => $slotMap[$slot]] : [];
|
||||||
|
}
|
||||||
|
foreach ($slotMap as $s => $orders) {
|
||||||
|
foreach ($orders as $r) {
|
||||||
$r['holder_assistant_id'] = (int) $aid;
|
$r['holder_assistant_id'] = (int) $aid;
|
||||||
|
$r['visit_slot'] = (int) $s;
|
||||||
$orderRows[] = $r;
|
$orderRows[] = $r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if ($orderRows === []) {
|
if ($orderRows === []) {
|
||||||
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
|
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||||
}
|
}
|
||||||
@@ -290,6 +453,7 @@ class RevisitRateLogic
|
|||||||
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
||||||
'assistant_id' => $hid,
|
'assistant_id' => $hid,
|
||||||
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
|
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
|
||||||
|
'visit_slot' => (int) ($r['visit_slot'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
|
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
|
||||||
@@ -331,17 +495,22 @@ class RevisitRateLogic
|
|||||||
/**
|
/**
|
||||||
* 核心统计上下文:
|
* 核心统计上下文:
|
||||||
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||||
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
* 2. 分母:当月新指派 NI 与继承 IH 的「医助 × 诊单」(RevisitRateCalculator),再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||||
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
||||||
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
|
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内);
|
||||||
|
* 继承集合与旧口径集合与分母/分子一并过滤(当月仅有继承行的医助不会静默丢失、也不会逃过二中心过滤)。
|
||||||
*
|
*
|
||||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
|
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,inheritMode:string,
|
||||||
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
|
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
|
||||||
* diagsByAssistant:array<int,array<int,true>>,
|
* diagsByAssistant:array<int,array<int,true>>,
|
||||||
|
* excludeDiagsByAssistant:array<int,array<int,true>>,
|
||||||
|
* inheritEntryByAssistant:array<int,array<int,int>>,
|
||||||
|
* inheritPairs:list<array<string,mixed>>,
|
||||||
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||||
|
* maxEntry:int,
|
||||||
* assistantDept:array<int,int>,
|
* assistantDept:array<int,int>,
|
||||||
* deptNames:array<int,string>
|
* deptNames:array<int,string>
|
||||||
* }
|
* }
|
||||||
@@ -351,125 +520,90 @@ class RevisitRateLogic
|
|||||||
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
||||||
$startTs = (int) strtotime($month . '-01 00:00:00');
|
$startTs = (int) strtotime($month . '-01 00:00:00');
|
||||||
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
||||||
|
$inheritMode = self::resolveInheritMode($params);
|
||||||
|
|
||||||
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
||||||
|
// inherit_visit_slot 为写入时快照,仅供明细展示;统计一律以实时重算为准
|
||||||
$logRows = Db::name('tcm_diagnosis_assign_log')
|
$logRows = Db::name('tcm_diagnosis_assign_log')
|
||||||
->alias('lg')
|
->alias('lg')
|
||||||
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
->where('lg.create_time', '<=', $endTs)
|
->where('lg.create_time', '<=', $endTs)
|
||||||
->where('lg.diagnosis_id', '>', 0)
|
->where('lg.diagnosis_id', '>', 0)
|
||||||
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
|
->field([
|
||||||
|
'lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id', 'lg.operator_admin_id',
|
||||||
|
'lg.create_time', 'lg.is_inherit', 'lg.inherit_visit_slot',
|
||||||
|
])
|
||||||
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
|
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
|
// 分母剔除集合:当月被指派(含继承)诊单中名下存在拒收(9)/退款(10) 业务订单者(与明细 assignLines 同口径)
|
||||||
$timeline = [];
|
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(
|
||||||
$pairsRaw = [];
|
RevisitRateCalculator::monthAssignedDiagIds($logRows, $startTs, $endTs)
|
||||||
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
|
);
|
||||||
$diagsByAssistant = [];
|
// 历史「回退自动分配」行(slot 模式按继承处理)
|
||||||
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
|
$rollbackKeySet = $inheritMode === RevisitRateCalculator::MODE_SLOT
|
||||||
$candidateDiagSet = [];
|
? self::fetchRollbackKeySet($startTs, $endTs)
|
||||||
foreach ($logRows as $r) {
|
: [];
|
||||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
|
||||||
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
|
||||||
$t = (int) ($r['create_time'] ?? 0);
|
|
||||||
$timeline[$did][] = ['t' => $t, 'to' => $aid];
|
|
||||||
if ($aid > 0) {
|
|
||||||
$candidateDiagSet[$did] = true;
|
|
||||||
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
|
|
||||||
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
|
|
||||||
$diagsByAssistant[$aid][$did] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
|
// 分子:曾被指派诊单的订单序列(分块生成器,同一诊单连续),统计诊次 = 实单全局序号 + 诊单偏移
|
||||||
$assignedDiagIds = [];
|
$candidateDiagIds = RevisitRateCalculator::candidateDiagIds($logRows);
|
||||||
foreach ($diagsByAssistant as $diagSet) {
|
$offsetMap = self::fetchRevisitSlotStartOffsetMap($candidateDiagIds);
|
||||||
foreach ($diagSet as $did => $_) {
|
$orderRows = self::iterateOrderSeqRows(
|
||||||
$assignedDiagIds[(int) $did] = true;
|
$candidateDiagIds,
|
||||||
}
|
|
||||||
}
|
|
||||||
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
|
|
||||||
if ($refundRejectDiagSet !== []) {
|
|
||||||
foreach ($diagsByAssistant as $aid => $diagSet) {
|
|
||||||
foreach ($diagSet as $did => $_) {
|
|
||||||
if (isset($refundRejectDiagSet[$did])) {
|
|
||||||
unset($diagsByAssistant[$aid][$did]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($diagsByAssistant[$aid] === []) {
|
|
||||||
unset($diagsByAssistant[$aid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$pairsRaw = array_values(array_filter(
|
|
||||||
$pairsRaw,
|
|
||||||
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
|
|
||||||
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
|
||||||
$slotOrdersByAssistant = [];
|
|
||||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
|
|
||||||
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
|
|
||||||
$orderRows = self::fetchOrderSeqRows(
|
|
||||||
$chunk,
|
|
||||||
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
||||||
);
|
);
|
||||||
$curDid = 0;
|
|
||||||
$seq = 0;
|
$calc = RevisitRateCalculator::compute(
|
||||||
$ptr = 0;
|
$logRows,
|
||||||
$holder = 0;
|
$orderRows,
|
||||||
$offset = 0;
|
$offsetMap,
|
||||||
foreach ($orderRows as $r) {
|
$refundRejectDiagSet,
|
||||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
$rollbackKeySet,
|
||||||
if ($did <= 0) {
|
$startTs,
|
||||||
continue;
|
$endTs,
|
||||||
}
|
$inheritMode,
|
||||||
if ($did !== $curDid) {
|
self::inheritMinNextSlot()
|
||||||
$curDid = $did;
|
);
|
||||||
$seq = 0;
|
unset($logRows);
|
||||||
$ptr = 0;
|
$pairsRaw = $calc['pairsRaw'];
|
||||||
$holder = 0;
|
$diagsByAssistant = $calc['diagsByAssistant'];
|
||||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
$excludeDiagsByAssistant = $calc['excludeDiagsByAssistant'];
|
||||||
}
|
$inheritEntryByAssistant = $calc['inheritEntryByAssistant'];
|
||||||
$seq++;
|
$inheritPairs = $calc['inheritPairs'];
|
||||||
$effectiveSlot = $seq + $offset;
|
$slotOrdersByAssistant = $calc['slotOrdersByAssistant'];
|
||||||
$ct = (int) ($r['create_time'] ?? 0);
|
|
||||||
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
|
||||||
$tl = $timeline[$did] ?? [];
|
|
||||||
$tlCount = \count($tl);
|
|
||||||
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
|
|
||||||
$holder = (int) $tl[$ptr]['to'];
|
|
||||||
$ptr++;
|
|
||||||
}
|
|
||||||
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($ct < $startTs || $ct > $endTs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($holder > 0) {
|
|
||||||
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
$universeIds = array_keys(
|
||||||
|
$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant
|
||||||
|
);
|
||||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||||
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
||||||
if ($subtreeSet === []) {
|
if ($subtreeSet === []) {
|
||||||
// 无二中心部门时整表为空,避免误展示其它中心数据
|
// 无二中心部门时整表为空,避免误展示其它中心数据
|
||||||
$diagsByAssistant = [];
|
$diagsByAssistant = [];
|
||||||
|
$excludeDiagsByAssistant = [];
|
||||||
|
$inheritEntryByAssistant = [];
|
||||||
$slotOrdersByAssistant = [];
|
$slotOrdersByAssistant = [];
|
||||||
} else {
|
} else {
|
||||||
foreach ($universeIds as $aid) {
|
foreach ($universeIds as $aid) {
|
||||||
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
||||||
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
||||||
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
|
unset(
|
||||||
|
$diagsByAssistant[$aid],
|
||||||
|
$excludeDiagsByAssistant[$aid],
|
||||||
|
$inheritEntryByAssistant[$aid],
|
||||||
|
$slotOrdersByAssistant[$aid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 列范围上界只看过滤后仍在表内的继承行
|
||||||
|
$maxEntry = 0;
|
||||||
|
foreach ($inheritEntryByAssistant as $entries) {
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ((int) $entry <= self::MAX_VISIT_SLOT && (int) $entry > $maxEntry) {
|
||||||
|
$maxEntry = (int) $entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -480,29 +614,95 @@ class RevisitRateLogic
|
|||||||
'endDate' => date('Y-m-d', $endTs),
|
'endDate' => date('Y-m-d', $endTs),
|
||||||
'startTs' => $startTs,
|
'startTs' => $startTs,
|
||||||
'endTs' => $endTs,
|
'endTs' => $endTs,
|
||||||
|
'inheritMode' => $inheritMode,
|
||||||
'pairsRaw' => $pairsRaw,
|
'pairsRaw' => $pairsRaw,
|
||||||
'diagsByAssistant' => $diagsByAssistant,
|
'diagsByAssistant' => $diagsByAssistant,
|
||||||
|
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
|
||||||
|
'inheritEntryByAssistant' => $inheritEntryByAssistant,
|
||||||
|
'inheritPairs' => $inheritPairs,
|
||||||
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||||
|
'maxEntry' => $maxEntry,
|
||||||
'assistantDept' => $assistantDept,
|
'assistantDept' => $assistantDept,
|
||||||
'deptNames' => $deptNames,
|
'deptNames' => $deptNames,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
|
* 口径白名单:请求参数 inherit_mode=exclude|slot,缺省取 config project.revisit_rate.default_inherit_mode(再缺省 slot)。
|
||||||
|
*/
|
||||||
|
private static function resolveInheritMode(array $params): string
|
||||||
|
{
|
||||||
|
$default = (string) (config('project.revisit_rate.default_inherit_mode') ?? RevisitRateCalculator::MODE_SLOT);
|
||||||
|
|
||||||
|
return RevisitRateCalculator::normalizeMode($params['inherit_mode'] ?? '', $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 继承起算诊次下限(config project.revisit_rate.inherit_min_next_slot,默认 2,最小 2) */
|
||||||
|
private static function inheritMinNextSlot(): int
|
||||||
|
{
|
||||||
|
$v = (int) (config('project.revisit_rate.inherit_min_next_slot') ?? RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT);
|
||||||
|
|
||||||
|
return max(RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT, $v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当月「回退自动分配」键集合:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) 与
|
||||||
|
* assign_log(diagnosis_id, create_time, operator_admin_id) 一一对应(AutoAssignLogLogic::rollbackOne 同一事务同一 $now)。
|
||||||
*
|
*
|
||||||
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
private static function fetchRollbackKeySet(int $startTs, int $endTs): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('tcm_diagnosis_auto_assign_log')
|
||||||
|
->where('rollback_time', '>=', $startTs)
|
||||||
|
->where('rollback_time', '<=', $endTs)
|
||||||
|
->where('diagnosis_id', '>', 0)
|
||||||
|
->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[RevisitRateCalculator::rollbackKey(
|
||||||
|
(int) ($r['diagnosis_id'] ?? 0),
|
||||||
|
(int) ($r['rollback_time'] ?? 0),
|
||||||
|
(int) ($r['rollback_admin_id'] ?? 0)
|
||||||
|
)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单序列分块生成器:每块按诊单 id 切分(一个诊单的订单全部落在同一块),块内已按 (diagnosis_id, create_time, id) 升序。
|
||||||
|
*
|
||||||
|
* @param list<int> $diagIds
|
||||||
|
* @param list<string> $fields
|
||||||
|
*
|
||||||
|
* @return \Generator<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator
|
||||||
|
{
|
||||||
|
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||||
|
foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) {
|
||||||
|
yield $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计涉及的医助全集:新指派 ∪ 继承 ∪ 旧口径新指派 ∪ 分子(持有成交)。
|
||||||
|
*
|
||||||
|
* @param array{diagsByAssistant:array<int,array<int,true>>,excludeDiagsByAssistant:array<int,array<int,true>>,inheritEntryByAssistant:array<int,array<int,int>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
|
||||||
*
|
*
|
||||||
* @return array<int, true>
|
* @return array<int, true>
|
||||||
*/
|
*/
|
||||||
private static function assistantUniverse(array $ctx): array
|
private static function assistantUniverse(array $ctx): array
|
||||||
{
|
{
|
||||||
$set = [];
|
$set = [];
|
||||||
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
|
foreach (['diagsByAssistant', 'excludeDiagsByAssistant', 'inheritEntryByAssistant', 'slotOrdersByAssistant'] as $key) {
|
||||||
|
foreach (array_keys($ctx[$key] ?? []) as $aid) {
|
||||||
$set[(int) $aid] = true;
|
$set[(int) $aid] = true;
|
||||||
}
|
}
|
||||||
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
|
|
||||||
$set[(int) $aid] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $set;
|
return $set;
|
||||||
@@ -715,17 +915,8 @@ class RevisitRateLogic
|
|||||||
*/
|
*/
|
||||||
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
||||||
{
|
{
|
||||||
$q = Db::name('tcm_prescription_order')
|
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||||
->alias('o')
|
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
|
||||||
->whereIn('o.diagnosis_id', $diagIds)
|
|
||||||
->whereNull('o.delete_time');
|
|
||||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
|
||||||
|
|
||||||
return $q
|
|
||||||
->field($fields)
|
|
||||||
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
|
||||||
->select()
|
|
||||||
->toArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -735,21 +926,8 @@ class RevisitRateLogic
|
|||||||
*/
|
*/
|
||||||
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
||||||
{
|
{
|
||||||
if ($diagIds === []) {
|
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||||
return [];
|
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
|
||||||
}
|
|
||||||
$out = [];
|
|
||||||
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
|
||||||
$rows = Db::name('tcm_diagnosis')
|
|
||||||
->whereIn('id', $chunk)
|
|
||||||
->whereNull('delete_time')
|
|
||||||
->column('revisit_slot_start_offset', 'id');
|
|
||||||
foreach ($rows as $id => $offset) {
|
|
||||||
$out[(int) $id] = (int) $offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -759,15 +937,8 @@ class RevisitRateLogic
|
|||||||
*/
|
*/
|
||||||
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
||||||
{
|
{
|
||||||
$offset = (int) ($offsetMap[$diagId] ?? 0);
|
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||||
if ($offset < 0) {
|
return RevisitVisitSlotService::resolveRevisitSlotStartOffset($diagId, $offsetMap);
|
||||||
$offset = 0;
|
|
||||||
}
|
|
||||||
if ($offset > 20) {
|
|
||||||
$offset = 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -797,23 +968,24 @@ class RevisitRateLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 指标包:assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
|
||||||
|
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(实现见 RevisitRateCalculator::metricPack)。
|
||||||
|
*
|
||||||
|
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
|
||||||
* @param array<int, int> $slotCounts [slot => n]
|
* @param array<int, int> $slotCounts [slot => n]
|
||||||
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||||
|
* @param int $newAssignedExclude 旧口径(:380 现状)新指派数,供 visit2_rate_new_assign
|
||||||
*
|
*
|
||||||
* @return array<string, int|float|null>
|
* @return array<string, int|float|null>
|
||||||
*/
|
*/
|
||||||
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
|
private static function buildMetricPack(
|
||||||
{
|
int $newAssigned,
|
||||||
$pack = ['assigned_count' => $assigned];
|
array $inheritEntryByDiag,
|
||||||
foreach ($slots as $slot) {
|
array $slotCounts,
|
||||||
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
array $slots,
|
||||||
$pack['visit' . $slot . '_count'] = $cnt;
|
int $newAssignedExclude
|
||||||
$pack['visit' . $slot . '_rate'] = $assigned > 0
|
): array {
|
||||||
? round($cnt / $assigned * 100, 2)
|
return RevisitRateCalculator::metricPack($newAssigned, $inheritEntryByDiag, $slotCounts, $slots, $newAssignedExclude);
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $pack;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use think\facade\Db;
|
|||||||
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
||||||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
||||||
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
||||||
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic)
|
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面的『新指派数』(assigned_count) 同口径(见 RevisitRateLogic;
|
||||||
|
* 接诊率 v2 另有继承分母 inherit_count,本看板不纳入——方案 D1-g)
|
||||||
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
||||||
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
||||||
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
||||||
@@ -856,9 +857,9 @@ class YejiStatsLogic
|
|||||||
|
|
||||||
$rangeNote = $channelFilterActive
|
$rangeNote = $channelFilterActive
|
||||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。被指派数与复诊接诊率『新指派数』同口径(不含继承)。'
|
||||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
||||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派,与复诊接诊率『新指派数』同口径)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
||||||
|
|
||||||
$leaderboards = [];
|
$leaderboards = [];
|
||||||
@@ -1780,7 +1781,7 @@ class YejiStatsLogic
|
|||||||
/**
|
/**
|
||||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
||||||
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
||||||
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
* 与「复诊接诊率」页面『新指派数』(assigned_count) 同口径(不含接诊率 v2 的继承分母)。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||||
*
|
*
|
||||||
* @param array<int, int> $adminToPrimary
|
* @param array<int, int> $adminToPrimary
|
||||||
* @param int[]|null $tagDiagIds
|
* @param int[]|null $tagDiagIds
|
||||||
@@ -1871,7 +1872,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1),按 to_assistant_id 汇总(供排行榜)。
|
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1,与复诊接诊率『新指派数』同口径),按 to_assistant_id 汇总(供排行榜)。
|
||||||
*
|
*
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
@@ -2617,7 +2618,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
|
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重;即复诊接诊率『新指派数』口径)。
|
||||||
*
|
*
|
||||||
* @param array{
|
* @param array{
|
||||||
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
|
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
|
||||||
@@ -2910,7 +2911,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
|
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
|
||||||
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
|
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单;与复诊接诊率『新指派数』(assigned_count) 同口径,不含继承。';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'start_date' => (string) $c['startDate'],
|
'start_date' => (string) $c['startDate'],
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use app\common\logic\BaseLogic;
|
|||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\model\tcm\DiagnosisAiReport;
|
use app\common\model\tcm\DiagnosisAiReport;
|
||||||
use app\common\service\DifyChatService;
|
use app\common\service\DifyChatService;
|
||||||
|
use app\common\service\NihaixiaClinicalSkill;
|
||||||
|
use app\common\service\TcmAncientBooksReference;
|
||||||
|
use app\adminapi\service\AssistantSseTiming;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
|
|
||||||
@@ -21,11 +24,11 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
/** @var string Safe machine-readable code for the current assistant request. */
|
/** @var string Safe machine-readable code for the current assistant request. */
|
||||||
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||||
|
|
||||||
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
|
private const PROMPT_VERSION = 'patient-context-case-explain-nihaixia-ancient-v4';
|
||||||
|
|
||||||
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
|
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-nihaixia-ancient-v4';
|
||||||
|
|
||||||
private const ANALYSIS_PROMPT_VERSION = 'patient-context-analysis-v2';
|
private const ANALYSIS_PROMPT_VERSION = 'patient-context-analysis-nihaixia-ancient-v5';
|
||||||
|
|
||||||
private const MAX_ASSISTANT_PROMPT_LENGTH = 500;
|
private const MAX_ASSISTANT_PROMPT_LENGTH = 500;
|
||||||
|
|
||||||
@@ -53,6 +56,8 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
|
|
||||||
private const PERMISSION_ASSISTANT = 'tcm.diagnosis/aiassistant';
|
private const PERMISSION_ASSISTANT = 'tcm.diagnosis/aiassistant';
|
||||||
|
|
||||||
|
private const PERMISSION_GENERATE_PRESCRIPTION = 'tcm.diagnosis/aigenerateprescription';
|
||||||
|
|
||||||
private const PATIENT_OPTIONS_DEFAULT_PAGE_SIZE = 20;
|
private const PATIENT_OPTIONS_DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
private const PATIENT_OPTIONS_MAX_PAGE_SIZE = 50;
|
private const PATIENT_OPTIONS_MAX_PAGE_SIZE = 50;
|
||||||
@@ -355,9 +360,30 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
string $task,
|
string $task,
|
||||||
string $prompt,
|
string $prompt,
|
||||||
int $adminId,
|
int $adminId,
|
||||||
array $adminInfo
|
array $adminInfo,
|
||||||
|
?AssistantSseTiming $timing = null
|
||||||
): ?array {
|
): ?array {
|
||||||
|
$prepareStartedAt = hrtime(true);
|
||||||
|
$aggregateMs = 0;
|
||||||
|
$compactionMs = 0;
|
||||||
|
$sourceBytes = 0;
|
||||||
|
$queryBytes = 0;
|
||||||
|
$attachmentCount = 0;
|
||||||
|
$compactionAttempted = false;
|
||||||
|
$status = 'error';
|
||||||
|
try {
|
||||||
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||||
|
$task = strtolower(trim($task));
|
||||||
|
if (!isset(self::ASSISTANT_TASKS[$task])) {
|
||||||
|
self::setError('不支持的 AI 助手任务');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($task === 'prescription_generate'
|
||||||
|
&& !self::hasPermission($adminId, $adminInfo, self::PERMISSION_GENERATE_PRESCRIPTION)) {
|
||||||
|
self::setError('权限不足,无法使用 AI 生成处方');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||||
$diagnosisId,
|
$diagnosisId,
|
||||||
$adminId,
|
$adminId,
|
||||||
@@ -369,18 +395,17 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$task = strtolower(trim($task));
|
|
||||||
if (!isset(self::ASSISTANT_TASKS[$task])) {
|
|
||||||
self::setError('不支持的 AI 助手任务');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
|
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
|
||||||
if ($task === 'custom' && $prompt === '') {
|
if ($task === 'custom' && $prompt === '') {
|
||||||
self::setError('请输入要咨询的问题');
|
self::setError('请输入要咨询的问题');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$aggregateStartedAt = hrtime(true);
|
||||||
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
||||||
|
$aggregateMs = AssistantSseTiming::elapsedMs($aggregateStartedAt);
|
||||||
|
$sourceBytes = strlen((string) ($context['case_text'] ?? ''));
|
||||||
|
$attachmentCount = count(is_array($context['files'] ?? null) ? $context['files'] : []);
|
||||||
if ($context['case_lines'] === []) {
|
if ($context['case_lines'] === []) {
|
||||||
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
|
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
|
||||||
return null;
|
return null;
|
||||||
@@ -395,9 +420,23 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::fitContextForPrompt($context, $profile)) {
|
$compactionAttempted = $sourceBytes > self::MAX_PROMPT_SOURCE_BYTES;
|
||||||
|
$compactionStartedAt = hrtime(true);
|
||||||
|
if (!self::fitContextForPrompt($context, $profile, $timing)) {
|
||||||
|
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
|
||||||
|
|
||||||
|
$query = self::buildAssistantPrompt($context, $task, $prompt);
|
||||||
|
$inputs = self::buildUpstreamInputs(
|
||||||
|
$context,
|
||||||
|
'病例问诊助手',
|
||||||
|
self::ASSISTANT_PROMPT_VERSION
|
||||||
|
);
|
||||||
|
$knowledgeSource = NihaixiaClinicalSkill::knowledgeSource();
|
||||||
|
$queryBytes = strlen($query);
|
||||||
|
$status = 'ok';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'diagnosis_id' => $diagnosisId,
|
'diagnosis_id' => $diagnosisId,
|
||||||
@@ -405,12 +444,9 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
'model_name' => $model,
|
'model_name' => $model,
|
||||||
'model_label' => $modelLabel,
|
'model_label' => $modelLabel,
|
||||||
'task' => $task,
|
'task' => $task,
|
||||||
'inputs' => self::buildUpstreamInputs(
|
'inputs' => $inputs,
|
||||||
$context,
|
'query' => $query,
|
||||||
'病例问诊助手',
|
'knowledge_source' => $knowledgeSource,
|
||||||
self::ASSISTANT_PROMPT_VERSION
|
|
||||||
),
|
|
||||||
'query' => self::buildAssistantPrompt($context, $task, $prompt),
|
|
||||||
'user' => 'admin-diagnosis-assistant-' . $adminId,
|
'user' => 'admin-diagnosis-assistant-' . $adminId,
|
||||||
'admin_id' => $adminId,
|
'admin_id' => $adminId,
|
||||||
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
|
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
|
||||||
@@ -421,6 +457,20 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
? $context['source_diagnosis_ids']
|
? $context['source_diagnosis_ids']
|
||||||
: [],
|
: [],
|
||||||
];
|
];
|
||||||
|
} finally {
|
||||||
|
if ($timing !== null) {
|
||||||
|
$timing->log('prepare', [
|
||||||
|
'duration_ms' => AssistantSseTiming::elapsedMs($prepareStartedAt),
|
||||||
|
'aggregate_ms' => $aggregateMs,
|
||||||
|
'compaction_ms' => $compactionMs,
|
||||||
|
'source_bytes' => $sourceBytes,
|
||||||
|
'query_bytes' => $queryBytes,
|
||||||
|
'attachment_count' => $attachmentCount,
|
||||||
|
'compaction_attempted' => $compactionAttempted,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -432,20 +482,30 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
public static function streamPreparedAssistant(
|
public static function streamPreparedAssistant(
|
||||||
array $prepared,
|
array $prepared,
|
||||||
callable $onDelta,
|
callable $onDelta,
|
||||||
?callable $shouldAbort = null
|
?callable $shouldAbort = null,
|
||||||
|
?AssistantSseTiming $timing = null
|
||||||
): ?array {
|
): ?array {
|
||||||
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
||||||
$profile = (string) ($prepared['profile'] ?? '');
|
$profile = (string) ($prepared['profile'] ?? '');
|
||||||
$adminId = (int) ($prepared['admin_id'] ?? 0);
|
$adminId = (int) ($prepared['admin_id'] ?? 0);
|
||||||
$deliveredDelta = false;
|
$deliveredDelta = false;
|
||||||
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
|
$deltaCount = 0;
|
||||||
|
$deltaBytes = 0;
|
||||||
|
$retryAttempted = false;
|
||||||
|
$retryMs = 0;
|
||||||
|
$status = 'error';
|
||||||
|
$upstreamStartedAt = hrtime(true);
|
||||||
|
$forwardDelta = static function (string $delta) use (&$deliveredDelta, &$deltaCount, &$deltaBytes, $onDelta) {
|
||||||
$accepted = $onDelta($delta);
|
$accepted = $onDelta($delta);
|
||||||
if ($accepted !== false) {
|
if ($accepted !== false) {
|
||||||
$deliveredDelta = true;
|
$deliveredDelta = true;
|
||||||
|
++$deltaCount;
|
||||||
|
$deltaBytes += strlen($delta);
|
||||||
}
|
}
|
||||||
return $accepted;
|
return $accepted;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
try {
|
try {
|
||||||
$result = DifyChatService::streamChat(
|
$result = DifyChatService::streamChat(
|
||||||
$profile,
|
$profile,
|
||||||
@@ -457,6 +517,7 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
if ($timing === null) {
|
||||||
self::logAssistantFailure(
|
self::logAssistantFailure(
|
||||||
$diagnosisId,
|
$diagnosisId,
|
||||||
$profile,
|
$profile,
|
||||||
@@ -464,12 +525,15 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
$e,
|
$e,
|
||||||
(string) ($prepared['task'] ?? '')
|
(string) ($prepared['task'] ?? '')
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
$status = 'exception';
|
||||||
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
|
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
|
||||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($result['ok'])) {
|
if (empty($result['ok'])) {
|
||||||
|
if ($timing === null) {
|
||||||
self::logAssistantUpstreamError(
|
self::logAssistantUpstreamError(
|
||||||
$diagnosisId,
|
$diagnosisId,
|
||||||
$profile,
|
$profile,
|
||||||
@@ -477,6 +541,7 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
(string) ($prepared['task'] ?? ''),
|
(string) ($prepared['task'] ?? ''),
|
||||||
is_array($result) ? $result : []
|
is_array($result) ? $result : []
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Some Dify-compatible gateways accept blocking chat but reject or
|
// Some Dify-compatible gateways accept blocking chat but reject or
|
||||||
// incompletely terminate streaming responses. Before any delta has
|
// incompletely terminate streaming responses. Before any delta has
|
||||||
@@ -491,6 +556,8 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
$retryAttempted = true;
|
||||||
|
$retryStartedAt = hrtime(true);
|
||||||
try {
|
try {
|
||||||
$result = DifyChatService::chat(
|
$result = DifyChatService::chat(
|
||||||
$profile,
|
$profile,
|
||||||
@@ -500,6 +567,7 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
if ($timing === null) {
|
||||||
self::logAssistantFailure(
|
self::logAssistantFailure(
|
||||||
$diagnosisId,
|
$diagnosisId,
|
||||||
$profile,
|
$profile,
|
||||||
@@ -508,7 +576,10 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
(string) ($prepared['task'] ?? '')
|
(string) ($prepared['task'] ?? '')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
$retryMs = AssistantSseTiming::elapsedMs($retryStartedAt);
|
||||||
if (empty($result['ok'])) {
|
if (empty($result['ok'])) {
|
||||||
|
if ($timing === null) {
|
||||||
self::logAssistantUpstreamError(
|
self::logAssistantUpstreamError(
|
||||||
$diagnosisId,
|
$diagnosisId,
|
||||||
$profile,
|
$profile,
|
||||||
@@ -519,8 +590,24 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return self::formatAssistantResult($prepared, $result);
|
$formatted = self::formatAssistantResult($prepared, $result);
|
||||||
|
$status = $formatted === null ? 'error' : 'ok';
|
||||||
|
return $formatted;
|
||||||
|
} finally {
|
||||||
|
if ($timing !== null) {
|
||||||
|
$timing->log('upstream_done', [
|
||||||
|
'duration_ms' => AssistantSseTiming::elapsedMs($upstreamStartedAt),
|
||||||
|
'retry_ms' => $retryMs,
|
||||||
|
'retry_attempted' => $retryAttempted,
|
||||||
|
'delta_count' => $deltaCount,
|
||||||
|
'delta_bytes' => $deltaBytes,
|
||||||
|
'status' => $status,
|
||||||
|
'error_code' => $status !== 'ok' ? self::getAssistantErrorCode() : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -551,6 +638,8 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
$payload = [
|
$payload = [
|
||||||
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
||||||
'answer' => $content,
|
'answer' => $content,
|
||||||
|
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
|
||||||
|
'ancient_book_sources' => is_array($result['ancient_book_sources'] ?? null) ? $result['ancient_book_sources'] : [],
|
||||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||||
'model_label' => (string) ($prepared['model_label'] ?? ''),
|
'model_label' => (string) ($prepared['model_label'] ?? ''),
|
||||||
'model_name' => (string) ($prepared['model_name'] ?? ''),
|
'model_name' => (string) ($prepared['model_name'] ?? ''),
|
||||||
@@ -783,17 +872,18 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$inputs = self::buildUpstreamInputs($context, '诊单结构化分析', self::ANALYSIS_PROMPT_VERSION);
|
||||||
|
$prompt = self::buildAnalysisPrompt($context);
|
||||||
|
$files = is_array($context['files'] ?? null) ? $context['files'] : [];
|
||||||
|
$analysis = null;
|
||||||
|
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||||
try {
|
try {
|
||||||
$result = DifyChatService::chat(
|
$result = DifyChatService::chat(
|
||||||
$profile,
|
$profile,
|
||||||
self::buildUpstreamInputs(
|
$inputs,
|
||||||
$context,
|
$attempt === 0 ? $prompt : self::buildAnalysisRepairPrompt($prompt),
|
||||||
'诊单结构化分析',
|
|
||||||
self::ANALYSIS_PROMPT_VERSION
|
|
||||||
),
|
|
||||||
self::buildAnalysisPrompt($context),
|
|
||||||
'admin-diagnosis-analysis-' . $adminId,
|
'admin-diagnosis-analysis-' . $adminId,
|
||||||
is_array($context['files'] ?? null) ? $context['files'] : []
|
$files
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('diagnosis ai analysis upstream call failed', [
|
Log::warning('diagnosis ai analysis upstream call failed', [
|
||||||
@@ -810,21 +900,26 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
self::setError('AI智能分析暂时不可用,请稍后重试');
|
self::setError('AI智能分析暂时不可用,请稍后重试');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$analysis = self::parseAnalysisResponse((string) ($result['content'] ?? ''));
|
$analysis = self::parseAnalysisResponse((string) ($result['content'] ?? ''));
|
||||||
|
if ($analysis !== null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($analysis === null) {
|
if ($analysis === null) {
|
||||||
self::setError('AI返回的分析结构不符合要求,请重试');
|
self::setError('AI返回的分析结构不符合要求,请重试');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($analysis, [
|
return array_merge($analysis, [
|
||||||
|
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
|
||||||
|
'ancient_book_sources' => is_array($result['ancient_book_sources'] ?? null) ? $result['ancient_book_sources'] : [],
|
||||||
'diagnosis_id' => $diagnosisId,
|
'diagnosis_id' => $diagnosisId,
|
||||||
'model_key' => $profile,
|
'model_key' => $profile,
|
||||||
'model_label' => $modelLabel,
|
'model_label' => $modelLabel,
|
||||||
'model_name' => $modelName,
|
'model_name' => $modelName,
|
||||||
'generated_at' => date('Y-m-d H:i:s'),
|
'generated_at' => date('Y-m-d H:i:s'),
|
||||||
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
|
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
|
||||||
'context_version' => (string) ($context['context_version'] ?? self::ANALYSIS_PROMPT_VERSION),
|
'context_version' => self::ANALYSIS_PROMPT_VERSION,
|
||||||
'source_summary' => is_array($context['source_summary'] ?? null)
|
'source_summary' => is_array($context['source_summary'] ?? null)
|
||||||
? $context['source_summary']
|
? $context['source_summary']
|
||||||
: [],
|
: [],
|
||||||
@@ -938,7 +1033,8 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
$modelLabel,
|
$modelLabel,
|
||||||
$content,
|
$content,
|
||||||
(string) ($result['message_id'] ?? ''),
|
(string) ($result['message_id'] ?? ''),
|
||||||
$adminId
|
$adminId,
|
||||||
|
is_array($result['ancient_book_sources'] ?? null) ? $result['ancient_book_sources'] : []
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('diagnosis ai report persist failed', [
|
Log::warning('diagnosis ai report persist failed', [
|
||||||
@@ -1035,7 +1131,7 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
|
||||||
return [
|
return [
|
||||||
'diagnosis_id' => $id,
|
'diagnosis_id' => $id,
|
||||||
'report' => self::formatReportRow($report->toArray(), $context['fingerprint']),
|
'report' => self::formatReportRow($report->toArray(), $context['fingerprint'], $context),
|
||||||
'can_edit' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT),
|
'can_edit' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT),
|
||||||
'can_refresh' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH),
|
'can_refresh' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH),
|
||||||
];
|
];
|
||||||
@@ -1442,6 +1538,13 @@ PROMPT;
|
|||||||
PROMPT;
|
PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function buildAnalysisRepairPrompt(string $originalPrompt): string
|
||||||
|
{
|
||||||
|
return $originalPrompt . "\n\n格式校验提醒:请重新独立作答,只输出一个可解析的 JSON 对象,且仅有 diagnosis_advice、risk_assessment、treatment_advice 三个键。"
|
||||||
|
. '前后不要解释、代码块或参考资料版本标识;两个建议字段各不超过 500 字;risk_assessment 最多 5 项,'
|
||||||
|
. '每项仅有 label(不超过 80 字)与小写 level(high、medium、low)。资料不足时说明缺口,不能编造风险或处方。';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 让患者纵向资料适配单次上游请求的体积上限。
|
* 让患者纵向资料适配单次上游请求的体积上限。
|
||||||
*
|
*
|
||||||
@@ -1450,7 +1553,11 @@ PROMPT;
|
|||||||
*
|
*
|
||||||
* @param array<string,mixed> $context
|
* @param array<string,mixed> $context
|
||||||
*/
|
*/
|
||||||
private static function fitContextForPrompt(array &$context, string $profile): bool
|
private static function fitContextForPrompt(
|
||||||
|
array &$context,
|
||||||
|
string $profile,
|
||||||
|
?AssistantSseTiming $timing = null
|
||||||
|
): bool
|
||||||
{
|
{
|
||||||
$caseText = (string) ($context['case_text'] ?? '');
|
$caseText = (string) ($context['case_text'] ?? '');
|
||||||
if ($caseText === '' || strlen($caseText) <= self::MAX_PROMPT_SOURCE_BYTES) {
|
if ($caseText === '' || strlen($caseText) <= self::MAX_PROMPT_SOURCE_BYTES) {
|
||||||
@@ -1460,12 +1567,12 @@ PROMPT;
|
|||||||
try {
|
try {
|
||||||
$compacted = PatientAiReportLogic::compactSourceForPrompt($profile, $caseText);
|
$compacted = PatientAiReportLogic::compactSourceForPrompt($profile, $caseText);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
if ($timing === null) {
|
||||||
Log::warning('diagnosis ai context compaction failed', [
|
Log::warning('diagnosis ai context compaction failed', [
|
||||||
'diagnosis_id' => (int) ($context['diagnosis_id'] ?? 0),
|
|
||||||
'profile' => $profile,
|
|
||||||
'source_bytes' => strlen($caseText),
|
'source_bytes' => strlen($caseText),
|
||||||
'exception_class' => get_class($e),
|
'exception_class' => get_class($e),
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
self::setError('患者纵向资料过大,AI 分片读取失败,请稍后重试');
|
self::setError('患者纵向资料过大,AI 分片读取失败,请稍后重试');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1593,7 +1700,8 @@ PROMPT;
|
|||||||
string $modelLabel,
|
string $modelLabel,
|
||||||
string $content,
|
string $content,
|
||||||
string $messageId,
|
string $messageId,
|
||||||
int $adminId
|
int $adminId,
|
||||||
|
array $ancientBookSources = []
|
||||||
): int {
|
): int {
|
||||||
$now = time();
|
$now = time();
|
||||||
$row = [
|
$row = [
|
||||||
@@ -1603,6 +1711,7 @@ PROMPT;
|
|||||||
'model_label' => self::cleanText($modelLabel, 50),
|
'model_label' => self::cleanText($modelLabel, 50),
|
||||||
'report_content' => $content,
|
'report_content' => $content,
|
||||||
'message_id' => self::cleanText($messageId, 191),
|
'message_id' => self::cleanText($messageId, 191),
|
||||||
|
'ancient_book_sources_json' => json_encode(TcmAncientBooksReference::localizeSources($ancientBookSources), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
|
||||||
'prompt_version' => self::PROMPT_VERSION,
|
'prompt_version' => self::PROMPT_VERSION,
|
||||||
'case_fingerprint' => (string) $context['fingerprint'],
|
'case_fingerprint' => (string) $context['fingerprint'],
|
||||||
'generated_by' => $adminId,
|
'generated_by' => $adminId,
|
||||||
@@ -1618,6 +1727,7 @@ PROMPT;
|
|||||||
'model_label',
|
'model_label',
|
||||||
'report_content',
|
'report_content',
|
||||||
'message_id',
|
'message_id',
|
||||||
|
'ancient_book_sources_json',
|
||||||
'prompt_version',
|
'prompt_version',
|
||||||
'case_fingerprint',
|
'case_fingerprint',
|
||||||
'generated_by',
|
'generated_by',
|
||||||
@@ -1658,7 +1768,8 @@ PROMPT;
|
|||||||
if (isset($rowsByModel[$modelKey])) {
|
if (isset($rowsByModel[$modelKey])) {
|
||||||
$reports[] = self::formatReportRow(
|
$reports[] = self::formatReportRow(
|
||||||
$rowsByModel[$modelKey],
|
$rowsByModel[$modelKey],
|
||||||
(string) $context['fingerprint']
|
(string) $context['fingerprint'],
|
||||||
|
$context
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1691,11 +1802,14 @@ PROMPT;
|
|||||||
* @param array<string,mixed> $row
|
* @param array<string,mixed> $row
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
private static function formatReportRow(array $row, string $currentFingerprint): array
|
private static function formatReportRow(array $row, string $currentFingerprint, array $context = []): array
|
||||||
{
|
{
|
||||||
$content = (string) ($row['report_content'] ?? '');
|
$content = (string) ($row['report_content'] ?? '');
|
||||||
$generatedTime = (int) ($row['generated_time'] ?? 0);
|
$generatedTime = (int) ($row['generated_time'] ?? 0);
|
||||||
$editedTime = (int) ($row['edited_time'] ?? 0);
|
$editedTime = (int) ($row['edited_time'] ?? 0);
|
||||||
|
// The fingerprint includes the source summary and diagnosis IDs. Reuse
|
||||||
|
// current provenance only while it still matches the saved report.
|
||||||
|
$sameSources = hash_equals($currentFingerprint, (string) ($row['case_fingerprint'] ?? ''));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) ($row['id'] ?? 0),
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
@@ -1706,13 +1820,16 @@ PROMPT;
|
|||||||
'content' => $content,
|
'content' => $content,
|
||||||
'report' => self::parseReport($content),
|
'report' => self::parseReport($content),
|
||||||
'message_id' => (string) ($row['message_id'] ?? ''),
|
'message_id' => (string) ($row['message_id'] ?? ''),
|
||||||
|
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSourceForPromptVersion((string) ($row['prompt_version'] ?? '')),
|
||||||
|
'ancient_book_sources' => self::decodeAncientBookSources($row['ancient_book_sources_json'] ?? ''),
|
||||||
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
||||||
'case_fingerprint' => (string) ($row['case_fingerprint'] ?? ''),
|
'case_fingerprint' => (string) ($row['case_fingerprint'] ?? ''),
|
||||||
'prescription_fingerprint' => (string) ($row['case_fingerprint'] ?? ''),
|
'prescription_fingerprint' => (string) ($row['case_fingerprint'] ?? ''),
|
||||||
'is_stale' => !hash_equals(
|
'is_stale' => !$sameSources,
|
||||||
$currentFingerprint,
|
'source_summary' => $sameSources && is_array($context['source_summary'] ?? null)
|
||||||
(string) ($row['case_fingerprint'] ?? '')
|
? $context['source_summary'] : [],
|
||||||
),
|
'source_diagnosis_ids' => $sameSources && is_array($context['source_diagnosis_ids'] ?? null)
|
||||||
|
? array_values(array_map('intval', $context['source_diagnosis_ids'])) : [],
|
||||||
'generated_by' => (int) ($row['generated_by'] ?? 0),
|
'generated_by' => (int) ($row['generated_by'] ?? 0),
|
||||||
'generated_time' => $generatedTime,
|
'generated_time' => $generatedTime,
|
||||||
'generated_at' => $generatedTime > 0 ? date('Y-m-d H:i:s', $generatedTime) : '',
|
'generated_at' => $generatedTime > 0 ? date('Y-m-d H:i:s', $generatedTime) : '',
|
||||||
@@ -1723,6 +1840,14 @@ PROMPT;
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string,mixed>> */
|
||||||
|
private static function decodeAncientBookSources(mixed $value): array
|
||||||
|
{
|
||||||
|
$decoded = is_string($value) ? json_decode($value, true) : null;
|
||||||
|
return is_array($decoded) && array_is_list($decoded)
|
||||||
|
? TcmAncientBooksReference::localizeSources($decoded) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string,array<string,mixed>> */
|
/** @return array<string,array<string,mixed>> */
|
||||||
private static function modelConfigs(): array
|
private static function modelConfigs(): array
|
||||||
{
|
{
|
||||||
@@ -1760,8 +1885,24 @@ PROMPT;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qwen occasionally drops letters from this key while keeping the other
|
||||||
|
// two contract keys intact. Accept only the two observed spellings, and
|
||||||
|
// never choose between conflicting diagnosis fields.
|
||||||
|
if (!array_key_exists('diagnosis_advice', $decoded)) {
|
||||||
|
$aliases = array_intersect(['diagnosis_adive', 'diagnosis_ad'], array_keys($decoded));
|
||||||
|
if (count($aliases) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$diagnosisField = $decoded[reset($aliases)];
|
||||||
|
} else {
|
||||||
|
if (array_key_exists('diagnosis_adive', $decoded) || array_key_exists('diagnosis_ad', $decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$diagnosisField = $decoded['diagnosis_advice'];
|
||||||
|
}
|
||||||
|
|
||||||
$diagnosisAdvice = self::validatedAnalysisText(
|
$diagnosisAdvice = self::validatedAnalysisText(
|
||||||
$decoded['diagnosis_advice'] ?? null,
|
$diagnosisField,
|
||||||
self::MAX_ANALYSIS_ADVICE_LENGTH,
|
self::MAX_ANALYSIS_ADVICE_LENGTH,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ use app\common\service\AppointmentCallPolicy;
|
|||||||
use app\common\service\FileService;
|
use app\common\service\FileService;
|
||||||
use app\common\service\DataScope\DataScopeService;
|
use app\common\service\DataScope\DataScopeService;
|
||||||
use app\common\service\storage\Driver as StorageDriver;
|
use app\common\service\storage\Driver as StorageDriver;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
|
use app\common\service\tcm\RevisitDueService;
|
||||||
|
use app\common\service\tcm\RevisitVisitSlotService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
/**
|
/**
|
||||||
* 中医辨房病因诊单逻辑
|
* 中医辨房病因诊单逻辑
|
||||||
@@ -260,6 +263,10 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
Diagnosis::destroy($params['id']);
|
Diagnosis::destroy($params['id']);
|
||||||
|
// 诊单删除后把复诊到期快照推进为失效(Hook 内部吞异常,不影响删除结果)
|
||||||
|
foreach ((array) $params['id'] as $deletedId) {
|
||||||
|
RevisitDueHook::onDiagnosisChanged((int) $deletedId, 'delete');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -277,8 +284,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
|
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
|
||||||
|
|
||||||
// 处理既往史为数组
|
// 处理既往史为数组
|
||||||
if (!empty($diagnosis['past_history'])) {
|
if (($diagnosis['past_history'] ?? null) !== null && $diagnosis['past_history'] !== '') {
|
||||||
$diagnosis['past_history'] = explode(',', $diagnosis['past_history']);
|
$diagnosis['past_history'] = explode(',', (string) $diagnosis['past_history']);
|
||||||
} else {
|
} else {
|
||||||
$diagnosis['past_history'] = [];
|
$diagnosis['past_history'] = [];
|
||||||
}
|
}
|
||||||
@@ -291,8 +298,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($multiSelectFields as $field) {
|
foreach ($multiSelectFields as $field) {
|
||||||
if (!empty($diagnosis[$field])) {
|
if (($diagnosis[$field] ?? null) !== null && $diagnosis[$field] !== '') {
|
||||||
$diagnosis[$field] = explode(',', $diagnosis[$field]);
|
$diagnosis[$field] = explode(',', (string) $diagnosis[$field]);
|
||||||
} else {
|
} else {
|
||||||
$diagnosis[$field] = [];
|
$diagnosis[$field] = [];
|
||||||
}
|
}
|
||||||
@@ -379,8 +386,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理既往史为数组
|
// 处理既往史为数组
|
||||||
if (!empty($diagnosis['past_history'])) {
|
if (($diagnosis['past_history'] ?? null) !== null && $diagnosis['past_history'] !== '') {
|
||||||
$diagnosis['past_history'] = explode(',', $diagnosis['past_history']);
|
$diagnosis['past_history'] = explode(',', (string) $diagnosis['past_history']);
|
||||||
} else {
|
} else {
|
||||||
$diagnosis['past_history'] = [];
|
$diagnosis['past_history'] = [];
|
||||||
}
|
}
|
||||||
@@ -393,8 +400,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($multiSelectFields as $field) {
|
foreach ($multiSelectFields as $field) {
|
||||||
if (!empty($diagnosis[$field])) {
|
if (($diagnosis[$field] ?? null) !== null && $diagnosis[$field] !== '') {
|
||||||
$diagnosis[$field] = explode(',', $diagnosis[$field]);
|
$diagnosis[$field] = explode(',', (string) $diagnosis[$field]);
|
||||||
} else {
|
} else {
|
||||||
$diagnosis[$field] = [];
|
$diagnosis[$field] = [];
|
||||||
}
|
}
|
||||||
@@ -707,6 +714,15 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
|
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
|
||||||
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
|
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
|
||||||
}
|
}
|
||||||
|
// from == to(指派给自己)不硬拦:批量指派流程不中断,复诊接诊率 slot 口径统计时忽略该行;前端弹窗负责提示
|
||||||
|
|
||||||
|
// 继承时患者所处诊次快照(继承时刻之前的有效实单数 + 偏移;统计以实时重算为准,本列供指派记录面板展示/对账)
|
||||||
|
$inheritVisitSlot = 0;
|
||||||
|
$inheritVisitSlotCalcTime = 0;
|
||||||
|
if ($isInherit === 1) {
|
||||||
|
$inheritVisitSlot = min(255, max(0, RevisitVisitSlotService::slotAtTime($id, $now)));
|
||||||
|
$inheritVisitSlotCalcTime = $now;
|
||||||
|
}
|
||||||
|
|
||||||
Db::name('tcm_diagnosis')
|
Db::name('tcm_diagnosis')
|
||||||
->where('id', $id)
|
->where('id', $id)
|
||||||
@@ -730,6 +746,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
'related_po_creator_id' => $relatedPoCreatorId,
|
'related_po_creator_id' => $relatedPoCreatorId,
|
||||||
'related_po_create_time' => $relatedPoCreateTime,
|
'related_po_create_time' => $relatedPoCreateTime,
|
||||||
'is_inherit' => $isInherit,
|
'is_inherit' => $isInherit,
|
||||||
|
'inherit_visit_slot' => $inheritVisitSlot,
|
||||||
|
'inherit_visit_slot_calc_time' => $inheritVisitSlotCalcTime,
|
||||||
'create_time' => $now,
|
'create_time' => $now,
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -741,6 +759,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(换医助重置延迟字段;钩子内部吞异常,不影响指派结果)
|
||||||
|
RevisitDueHook::onAssistantChanged($id, $toAssistantId, 'assign');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -785,6 +806,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
try {
|
try {
|
||||||
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
||||||
|
|
||||||
|
// 起始偏移影响 current_slot/next_slot:刷新复诊到期快照(钩子内部吞异常)
|
||||||
|
RevisitDueHook::onDiagnosisChanged($diagnosisId, 'offset');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -849,6 +873,16 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$inherit = (int) ($r['is_inherit'] ?? 0);
|
$inherit = (int) ($r['is_inherit'] ?? 0);
|
||||||
$r['is_inherit'] = $inherit;
|
$r['is_inherit'] = $inherit;
|
||||||
$r['is_inherit_text'] = $inherit === 1 ? '是' : '否';
|
$r['is_inherit_text'] = $inherit === 1 ? '是' : '否';
|
||||||
|
// 继承时诊次快照(2026-10 加列前的历史继承行 calc_time=0,无法回溯,文案留空)
|
||||||
|
$k = (int) ($r['inherit_visit_slot'] ?? 0);
|
||||||
|
$kCalcTime = (int) ($r['inherit_visit_slot_calc_time'] ?? 0);
|
||||||
|
$r['inherit_visit_slot'] = $k;
|
||||||
|
$r['inherit_visit_slot_calc_time'] = $kCalcTime;
|
||||||
|
if ($inherit === 1 && $kCalcTime > 0) {
|
||||||
|
$r['inherit_visit_slot_text'] = $k > 0 ? sprintf('继承时第 %d 诊', $k) : '继承时未成交';
|
||||||
|
} else {
|
||||||
|
$r['inherit_visit_slot_text'] = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
|
|
||||||
@@ -4281,6 +4315,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
|
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
|
||||||
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
|
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
|
||||||
|
|
||||||
|
// 7) 复诊到期(契约 C,与诊单列表行 revisit_due 同构;无快照且无法计算时为 state='none')
|
||||||
|
$revisitDue = RevisitDueService::buildListPayload([$diagnosisId], ['self_heal' => true, 'page_size' => 1])[$diagnosisId] ?? null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'appointment' => $appointment,
|
'appointment' => $appointment,
|
||||||
'diagnosis' => $diagnosis,
|
'diagnosis' => $diagnosis,
|
||||||
@@ -4288,6 +4325,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
'tracking_notes' => $trackingNotes,
|
'tracking_notes' => $trackingNotes,
|
||||||
'unserved_days' => $unservedDays,
|
'unserved_days' => $unservedDays,
|
||||||
'last_blood_record_at' => $lastBloodRecordAt,
|
'last_blood_record_at' => $lastBloodRecordAt,
|
||||||
|
'revisit_due' => $revisitDue,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\service\followupaudio\FollowupAudioAccess as Access;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioApply as Apply;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioFields as Fields;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioGate as Gate;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
final class FollowupAudioLogic
|
||||||
|
{
|
||||||
|
public static function capabilities(int $diagnosisId, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
$diagnosis = Access::diagnosis($diagnosisId, $actor, $info, false, false);
|
||||||
|
$scope = Gate::scopeAllowed($diagnosisId, $actor);
|
||||||
|
$preview = Gate::previewOnly();
|
||||||
|
$ready = Store::ready();
|
||||||
|
$enabled = Store::enabled() && $scope;
|
||||||
|
$verified = Store::verified();
|
||||||
|
$manualReady = Gate::manualReviewReady();
|
||||||
|
$daily = Access::canDaily($actor, $info);
|
||||||
|
return [
|
||||||
|
'enabled' => $enabled, 'audio_verified' => $verified,
|
||||||
|
'manual_review_enabled' => Gate::manualReviewEnabled(), 'manual_review_ready' => $manualReady,
|
||||||
|
'preview_only' => $preview, 'preview_ready' => $preview && $ready, 'test_scope_allowed' => $scope,
|
||||||
|
'can_upload' => $enabled && $ready, 'can_review' => $enabled && $ready, 'can_apply' => $enabled && ($verified || $manualReady) && !$preview,
|
||||||
|
'can_daily' => $daily, 'limits' => Upload::limits(),
|
||||||
|
'models' => $enabled ? ProviderConfig::readyModels() : [],
|
||||||
|
// No migration/dictionary dependency is introduced when the feature is OFF.
|
||||||
|
'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function requireEnabled(bool $verified = false): void
|
||||||
|
{
|
||||||
|
if (!Store::enabled()) {
|
||||||
|
throw new DomainException('回访录音功能尚未启用');
|
||||||
|
}
|
||||||
|
if ($verified && !Store::ready()) {
|
||||||
|
throw new DomainException('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(array $p, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
$channelRoles = Store::validateChannelRoles(array_key_exists('channel_roles', $p) ? $p['channel_roles'] : 'unconfirmed');
|
||||||
|
self::requireEnabled(true);
|
||||||
|
Access::diagnosis($p['diagnosis_id'], $actor, $info);
|
||||||
|
$upload = Upload::owned($p['upload_id'], $actor, $info);
|
||||||
|
if ((int) $upload['diagnosis_id'] !== $p['diagnosis_id'] || $upload['status'] !== 'complete') {
|
||||||
|
throw new DomainException('录音与当前诊单不匹配或尚未上传完成');
|
||||||
|
}
|
||||||
|
Upload::path($upload);
|
||||||
|
$date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $p['recorded_at'], new \DateTimeZone('Asia/Shanghai'));
|
||||||
|
if (!$date || $date->format('Y-m-d H:i:s') !== $p['recorded_at'] || $date->getTimestamp() > time() + 300) {
|
||||||
|
throw new DomainException('请填写正确的实际通话时间(北京时间),不能晚于当前时间');
|
||||||
|
}
|
||||||
|
if (!in_array($p['model_key'], ['qwen', 'openai'], true) || !Store::ready($p['model_key'])) {
|
||||||
|
throw new DomainException('所选模型音频能力尚未验证');
|
||||||
|
}
|
||||||
|
return Store::create($upload, $p['recorded_at'], $p['model_key'], $actor, $info, $channelRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lists(int $diagnosisId, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
Access::diagnosis($diagnosisId, $actor, $info);
|
||||||
|
return ['items' => Store::enabled() ? Store::lists($diagnosisId) : []];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function detail(int $id, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
Access::task($id, $actor, $info);
|
||||||
|
return self::protectDetail(Store::detail($id), $actor, $info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function saveDraft(int $id, int $version, array $items, int $actor, array $info, ?string $channelRoles = null): array
|
||||||
|
{
|
||||||
|
self::requireEnabled();
|
||||||
|
$task = Access::task($id, $actor, $info);
|
||||||
|
self::checkDailyItems($task, $items, $actor, $info);
|
||||||
|
return self::protectDetail(Store::saveDraft($id, $version, $items, $actor, $channelRoles), $actor, $info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function apply(int $id, int $version, array $items, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
Gate::assertMayApply();
|
||||||
|
self::requireEnabled(true);
|
||||||
|
$task = Access::task($id, $actor, $info);
|
||||||
|
self::checkDailyItems($task, $items, $actor, $info);
|
||||||
|
return Apply::apply($id, $version, $items, $actor, $info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function retry(int $id, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
self::requireEnabled(true);
|
||||||
|
Access::task($id, $actor, $info);
|
||||||
|
return Store::retry($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkDailyItems(array $task, array $items, int $actor, array $info): void
|
||||||
|
{
|
||||||
|
$diagnosis = Access::diagnosis((int) $task['diagnosis_id'], $actor, $info);
|
||||||
|
$plain = Access::allowed($actor, Access::actor($actor), 'tcm.diagnosis/phonePlain');
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
throw new DomainException('审阅内容无效');
|
||||||
|
}
|
||||||
|
if (!empty($item['selected']) && ($item['kind'] ?? '') === 'diagnosis' && !$plain) {
|
||||||
|
foreach (['phone', 'id_card'] as $key) {
|
||||||
|
if ($key === 'id_card' && trim((string) ($diagnosis[$key] ?? '')) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (array_key_exists($key, (array) ($item['values'] ?? []))
|
||||||
|
&& (string) $item['values'][$key] !== (string) ($diagnosis[$key] ?? '')) {
|
||||||
|
throw new DomainException('无权通过录音修改已有手机号或身份证');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($item['selected']) && ($item['kind'] ?? '') !== 'diagnosis') {
|
||||||
|
Access::diagnosis((int) $task['diagnosis_id'], $actor, $info, true);
|
||||||
|
// Continue to validate identity suggestions later in this same batch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static function catalogForActor(array $diagnosis, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
$info = Access::actor($actor);
|
||||||
|
$catalog = Fields::catalog();
|
||||||
|
$basic = DiagnosisLogic::canEditPatientBasicInfo((int) $diagnosis['id'], $info);
|
||||||
|
$plain = Access::allowed($actor, $info, 'tcm.diagnosis/phonePlain');
|
||||||
|
foreach ($catalog['diagnosis'] as &$field) {
|
||||||
|
$key = $field['key'];
|
||||||
|
if ((!$basic && in_array($key, ['patient_name', 'id_card', 'phone', 'gender', 'age'], true))
|
||||||
|
|| (!$plain && ($key === 'phone' || ($key === 'id_card' && !empty($diagnosis['id_card']))))) {
|
||||||
|
$field['readonly'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($field);
|
||||||
|
return $catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function protectDetail(array $detail, int $actor, array $info): array
|
||||||
|
{
|
||||||
|
$diagnosis = Access::diagnosis((int) $detail['diagnosis_id'], $actor, $info);
|
||||||
|
$detail['fields'] = self::catalogForActor($diagnosis, $actor, $info);
|
||||||
|
if (!Access::allowed($actor, Access::actor($actor), 'tcm.diagnosis/phonePlain')) {
|
||||||
|
foreach ($detail['items'] as &$item) {
|
||||||
|
foreach (['phone', 'id_card'] as $key) {
|
||||||
|
$value = (string) ($item['current_values'][$key] ?? '');
|
||||||
|
if ($value !== '') {
|
||||||
|
$item['current_values'][$key] = mb_substr($value, 0, 3) . '****' . mb_substr($value, -4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
return $detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ use app\common\model\auth\AdminDept;
|
|||||||
use app\common\model\dept\Dept;
|
use app\common\model\dept\Dept;
|
||||||
use app\common\model\tcm\PatientAiReport;
|
use app\common\model\tcm\PatientAiReport;
|
||||||
use app\common\service\DifyChatService;
|
use app\common\service\DifyChatService;
|
||||||
|
use app\common\service\NihaixiaClinicalSkill;
|
||||||
|
use app\common\service\TcmAncientBooksReference;
|
||||||
use app\common\service\FileService;
|
use app\common\service\FileService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
@@ -41,7 +43,7 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
|
|
||||||
private const PERMISSION_GENERATE = 'tcm.diagnosis/generatepatientaireport';
|
private const PERMISSION_GENERATE = 'tcm.diagnosis/generatepatientaireport';
|
||||||
|
|
||||||
private const PROMPT_VERSION = 'patient-longitudinal-report-v2';
|
private const PROMPT_VERSION = 'patient-longitudinal-report-nihaixia-ancient-v4';
|
||||||
|
|
||||||
/** @var array<int,string> */
|
/** @var array<int,string> */
|
||||||
private const MODEL_KEYS = ['qwen', 'openai'];
|
private const MODEL_KEYS = ['qwen', 'openai'];
|
||||||
@@ -246,6 +248,8 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
'source_snapshot' => $sourceJson,
|
'source_snapshot' => $sourceJson,
|
||||||
'source_summary_json' => self::encodeJson($sourceSummary),
|
'source_summary_json' => self::encodeJson($sourceSummary),
|
||||||
'source_diagnosis_ids_json' => self::encodeJson($diagnosisIds),
|
'source_diagnosis_ids_json' => self::encodeJson($diagnosisIds),
|
||||||
|
'ancient_book_sources_json' => self::encodeJson(TcmAncientBooksReference::localizeSources(
|
||||||
|
is_array($upstream['ancient_book_sources'] ?? null) ? $upstream['ancient_book_sources'] : [])),
|
||||||
'source_hash' => $sourceHash,
|
'source_hash' => $sourceHash,
|
||||||
'prompt_version' => self::PROMPT_VERSION,
|
'prompt_version' => self::PROMPT_VERSION,
|
||||||
'message_id' => self::cleanText($upstream['message_id'] ?? '', 191),
|
'message_id' => self::cleanText($upstream['message_id'] ?? '', 191),
|
||||||
@@ -276,6 +280,8 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
'report' => $generatedReport,
|
'report' => $generatedReport,
|
||||||
'disclaimer' => self::DISCLAIMER,
|
'disclaimer' => self::DISCLAIMER,
|
||||||
'source_summary' => $generatedReport['source_summary'] ?? self::emptySourceSummary(),
|
'source_summary' => $generatedReport['source_summary'] ?? self::emptySourceSummary(),
|
||||||
|
'source_diagnosis_ids' => $generatedReport['source_diagnosis_ids'] ?? [],
|
||||||
|
'knowledge_source' => $generatedReport['knowledge_source'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,6 +994,7 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
$chunkCount = count($chunks);
|
$chunkCount = count($chunks);
|
||||||
$inputs = self::sourceInputsForUpstream($snapshot['source_summary'] ?? []);
|
$inputs = self::sourceInputsForUpstream($snapshot['source_summary'] ?? []);
|
||||||
$files = self::collectUpstreamFiles($snapshot);
|
$files = self::collectUpstreamFiles($snapshot);
|
||||||
|
$ancientBookSources = [];
|
||||||
|
|
||||||
if ($chunkCount === 1) {
|
if ($chunkCount === 1) {
|
||||||
$result = DifyChatService::chat(
|
$result = DifyChatService::chat(
|
||||||
@@ -1025,6 +1032,7 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
||||||
throw new \RuntimeException('Patient evidence chunk analysis failed');
|
throw new \RuntimeException('Patient evidence chunk analysis failed');
|
||||||
}
|
}
|
||||||
|
$ancientBookSources = self::mergeAncientBookSources($ancientBookSources, $part);
|
||||||
$summaries[] = [
|
$summaries[] = [
|
||||||
'part' => $index + 1,
|
'part' => $index + 1,
|
||||||
'total' => $chunkCount,
|
'total' => $chunkCount,
|
||||||
@@ -1057,6 +1065,7 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
||||||
throw new \RuntimeException('Patient evidence reduction failed');
|
throw new \RuntimeException('Patient evidence reduction failed');
|
||||||
}
|
}
|
||||||
|
$ancientBookSources = self::mergeAncientBookSources($ancientBookSources, $part);
|
||||||
$reduced[] = [
|
$reduced[] = [
|
||||||
'part' => $index + 1,
|
'part' => $index + 1,
|
||||||
'total' => count($summaryChunks),
|
'total' => count($summaryChunks),
|
||||||
@@ -1084,11 +1093,49 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
'patient-longitudinal-report'
|
'patient-longitudinal-report'
|
||||||
);
|
);
|
||||||
$result['analysis_chunk_count'] = $chunkCount;
|
$result['analysis_chunk_count'] = $chunkCount;
|
||||||
|
$result['ancient_book_sources'] = self::mergeAncientBookSources($ancientBookSources, $result);
|
||||||
$result['analysis_reduction_rounds'] = $reductionRounds;
|
$result['analysis_reduction_rounds'] = $reductionRounds;
|
||||||
$result['analyzed_source_bytes'] = strlen($sourceJson);
|
$result['analyzed_source_bytes'] = strlen($sourceJson);
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param list<array<string,mixed>> $accumulated
|
||||||
|
* @param array<string,mixed> $response
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function mergeAncientBookSources(array $accumulated, array $response): array
|
||||||
|
{
|
||||||
|
$sources = $response['ancient_book_sources'] ?? [];
|
||||||
|
if (!is_array($sources)) {
|
||||||
|
return $accumulated;
|
||||||
|
}
|
||||||
|
$seen = [];
|
||||||
|
foreach ($accumulated as $source) {
|
||||||
|
$seen[self::ancientBookSourceKey($source)] = true;
|
||||||
|
}
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
if (!is_array($source)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = self::ancientBookSourceKey($source);
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$accumulated[] = $source;
|
||||||
|
if (count($accumulated) >= 12) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TcmAncientBooksReference::localizeSources($accumulated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ancientBookSourceKey(array $source): string
|
||||||
|
{
|
||||||
|
return (string) ($source['commit'] ?? '') . "\0" . (string) ($source['path'] ?? '')
|
||||||
|
. "\0" . (string) ($source['line'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string,mixed> $snapshot */
|
/** @param array<string,mixed> $snapshot */
|
||||||
private static function buildPrompt(array $snapshot): string
|
private static function buildPrompt(array $snapshot): string
|
||||||
{
|
{
|
||||||
@@ -1427,6 +1474,8 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
'report' => $generatedReport,
|
'report' => $generatedReport,
|
||||||
'disclaimer' => self::DISCLAIMER,
|
'disclaimer' => self::DISCLAIMER,
|
||||||
'source_summary' => is_array($newest) ? ($newest['source_summary'] ?? self::emptySourceSummary()) : self::emptySourceSummary(),
|
'source_summary' => is_array($newest) ? ($newest['source_summary'] ?? self::emptySourceSummary()) : self::emptySourceSummary(),
|
||||||
|
'source_diagnosis_ids' => is_array($newest) ? ($newest['source_diagnosis_ids'] ?? []) : [],
|
||||||
|
'knowledge_source' => is_array($newest) ? ($newest['knowledge_source'] ?? null) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1465,6 +1514,10 @@ class PatientAiReportLogic extends BaseLogic
|
|||||||
'disclaimer' => self::DISCLAIMER,
|
'disclaimer' => self::DISCLAIMER,
|
||||||
'source_hash' => (string) ($row['source_hash'] ?? ''),
|
'source_hash' => (string) ($row['source_hash'] ?? ''),
|
||||||
'source_summary' => $sourceSummary !== [] ? $sourceSummary : self::emptySourceSummary(),
|
'source_summary' => $sourceSummary !== [] ? $sourceSummary : self::emptySourceSummary(),
|
||||||
|
'source_diagnosis_ids' => self::decodeJsonArray($row['source_diagnosis_ids_json'] ?? ''),
|
||||||
|
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSourceForPromptVersion((string) ($row['prompt_version'] ?? '')),
|
||||||
|
'ancient_book_sources' => TcmAncientBooksReference::localizeSources(
|
||||||
|
self::decodeJsonArray($row['ancient_book_sources_json'] ?? '')),
|
||||||
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
||||||
'version' => max((int) ($row['version'] ?? 1), 1),
|
'version' => max((int) ($row['version'] ?? 1), 1),
|
||||||
'generated_time' => $generatedTime,
|
'generated_time' => $generatedTime,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace app\adminapi\logic\tcm;
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\service\NihaixiaClinicalSkill;
|
||||||
|
use app\common\service\TcmAncientBooksReference;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
|
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiDoctorSnapshot as DoctorSnapshot;
|
use app\common\service\prescriptionai\PrescriptionAiDoctorSnapshot as DoctorSnapshot;
|
||||||
@@ -298,6 +300,9 @@ final class PrescriptionAiLogic
|
|||||||
}
|
}
|
||||||
foreach ($results as $result) {
|
foreach ($results as $result) {
|
||||||
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
||||||
|
if ($full) {
|
||||||
|
$body = self::localizeSourceMetadata($body);
|
||||||
|
}
|
||||||
unset($result['body_cipher']);
|
unset($result['body_cipher']);
|
||||||
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
||||||
$result['herb_score'] = $result['herb_score'] === null ? null : (float) $result['herb_score'];
|
$result['herb_score'] = $result['herb_score'] === null ? null : (float) $result['herb_score'];
|
||||||
@@ -321,6 +326,15 @@ final class PrescriptionAiLogic
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Existing encrypted results may still contain repository links. */
|
||||||
|
private static function localizeSourceMetadata(array $body): array
|
||||||
|
{
|
||||||
|
$body['knowledge_source'] = NihaixiaClinicalSkill::localizeKnowledgeSource($body['knowledge_source'] ?? null);
|
||||||
|
$body['ancient_book_sources'] = TcmAncientBooksReference::localizeSources(
|
||||||
|
is_array($body['ancient_book_sources'] ?? null) ? $body['ancient_book_sources'] : []);
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
private static function formatBatch(array $batch, array $models, ?array $rx = null): array
|
private static function formatBatch(array $batch, array $models, ?array $rx = null): array
|
||||||
{
|
{
|
||||||
$validity = $batch['validity'];
|
$validity = $batch['validity'];
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use think\facade\Log;
|
|||||||
*/
|
*/
|
||||||
class PrescriptionLibraryAiLogic extends BaseLogic
|
class PrescriptionLibraryAiLogic extends BaseLogic
|
||||||
{
|
{
|
||||||
private const PROMPT_VERSION = 'rx-explain-v1';
|
private const PROMPT_VERSION = 'rx-explain-nihaixia-v2';
|
||||||
|
|
||||||
private const MAX_REPORT_LENGTH = 12000;
|
private const MAX_REPORT_LENGTH = 12000;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user