Compare commits

...
923 changed files with 11718 additions and 2534 deletions
+14
View File
@@ -75,6 +75,8 @@ export interface FollowupTaskSummary {
version: number
created_at: number | string
expires_at: number | string
can_delete?: boolean
delete_block_reason?: string
can_retry: boolean
audio_available: boolean
preview_only?: boolean
@@ -82,6 +84,16 @@ export interface FollowupTaskSummary {
pipeline_progress?: { transcribed: number; total: number }
}
export interface FollowupTaskDetail extends FollowupTaskSummary {
extraction_revision?: number
extraction_channel_roles?: FollowupChannelRoles
can_reextract?: boolean
reextract_block_reason?: string
role_check?: {
conflict: boolean
suggested_roles: FollowupChannelRoles
message: string
evidence: Array<{ channel: FollowupAudioChannel; start_ms: number; end_ms: number; text: string }>
}
summary: string
transcript: string
transcript_segments?: FollowupTranscriptSegment[]
@@ -130,6 +142,8 @@ export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSign
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 followupAudioReextract = (data: { id: number; version: number; channel_roles: Exclude<FollowupChannelRoles, 'unconfirmed'> }, signal?: AbortSignal) => post<FollowupTaskDetail>('reextract', data, signal)
export const followupAudioDelete = (data: { id: number; version: number }, signal?: AbortSignal) => post<{ id: number; status: 'deleted'; version: number }>('delete', 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> {
@@ -0,0 +1,49 @@
import request from '@/utils/request'
export interface PrescriptionAiVisibilityConfig {
role_ids: number[]
admin_ids: number[]
}
export interface PrescriptionAiVisibilityOptions {
roles: { id: number; name: string }[]
admins: { id: number; name: string; account: string }[]
}
export interface PrescriptionAiCapabilities {
agreement_visible: boolean
can_manage_visibility: boolean
}
// A failed or malformed read must never become an empty policy that can be saved.
function readVisibilityConfig(value: unknown): PrescriptionAiVisibilityConfig {
const config = value as Partial<PrescriptionAiVisibilityConfig> | null
const validIds = (ids: unknown): ids is number[] =>
Array.isArray(ids) && ids.every((id) => Number.isSafeInteger(id) && id > 0)
if (!config || !validIds(config.role_ids) || !validIds(config.admin_ids)) {
throw new Error('可见范围配置响应格式不正确')
}
return { role_ids: [...config.role_ids], admin_ids: [...config.admin_ids] }
}
export async function getPrescriptionAiVisibilityConfig() {
const data = await request.get({ url: '/tcm.prescriptionAi/getVisibilityConfig' })
return readVisibilityConfig(data)
}
export async function setPrescriptionAiVisibilityConfig(params: PrescriptionAiVisibilityConfig) {
const data = await request.post({ url: '/tcm.prescriptionAi/setVisibilityConfig', params })
return readVisibilityConfig(data)
}
export function getPrescriptionAiVisibilityOptions() {
return request.get({
url: '/tcm.prescriptionAi/visibilityOptions'
}) as Promise<PrescriptionAiVisibilityOptions>
}
export function getPrescriptionAiCapabilities() {
return request.get({
url: '/tcm.prescriptionAi/capabilities'
}) as Promise<PrescriptionAiCapabilities>
}
+38
View File
@@ -299,3 +299,41 @@ export function assistantPerformanceRevisitRate(params: {
}): Promise<AssistantRevisitRateResult> {
return request.get({ url: '/stats.assistantPerformance/revisitRate', params })
}
/** 独立指派周期统计,与原 revisitRate 按月报表分别调用。 */
export type RevisitRateCohortType = 'new' | 'inherit'
export interface RevisitRateQuery {
start_date: string
end_date: string
dept_ids?: string
assistant_id?: number
}
export interface RevisitRateDetailQuery extends RevisitRateQuery {
scope_assistant_id?: number
dept_id?: number
}
const revisitCohortPrefix = (cohortType: RevisitRateCohortType) =>
cohortType === 'inherit' ? '/stats.revisitInheritRate' : '/stats.revisitAssignRate'
export function revisitCohortOverview(params: RevisitRateQuery, cohortType: RevisitRateCohortType) {
return request.get({ url: `${revisitCohortPrefix(cohortType)}/overview`, params })
}
export function revisitCohortDeptOptions(cohortType: RevisitRateCohortType) {
return request.get({ url: `${revisitCohortPrefix(cohortType)}/deptOptions` })
}
export function revisitCohortAssignLines(params: RevisitRateDetailQuery, cohortType: RevisitRateCohortType) {
return request.get({ url: `${revisitCohortPrefix(cohortType)}/assignLines`, params })
}
export function revisitCohortVisitOrderLines(
params: RevisitRateDetailQuery & { slot: number },
cohortType: RevisitRateCohortType
) {
return request.get({ url: `${revisitCohortPrefix(cohortType)}/visitOrderLines`, params })
}
+2
View File
@@ -490,8 +490,10 @@ export function prescriptionOrderPatchPrescriptionUsage(params: {
times_per_day: number
usage_days: number
medication_days: number
dosage_bag_count?: number
aux_times_per_day?: number
aux_usage_days?: number
aux_dosage_bag_count?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
}
@@ -312,6 +312,17 @@
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
<el-button
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
@@ -325,6 +336,17 @@
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
<el-button
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
@@ -943,6 +965,15 @@
label-width="108px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="dosage_bag_count"
>
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
@@ -968,6 +999,15 @@
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="aux_dosage_bag_count"
>
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
@@ -1432,10 +1472,12 @@ const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
dosage_bag_count: 1 as number | undefined,
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
aux_dosage_bag_count: 1 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
@@ -1444,9 +1486,15 @@ const patchUsageRules = computed<FormRules>(() => {
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
}
}
return rules
})
@@ -1459,6 +1507,7 @@ function openPatchUsageDialog() {
return
}
const aux = detailAuxUsage.value
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
@@ -1468,16 +1517,20 @@ function openPatchUsageDialog() {
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.aux_dosage_bag_count =
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.dosage_bag_count = 1
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.aux_dosage_bag_count = 1
patchUsageForm.medication_days = undefined
}
@@ -1498,17 +1551,25 @@ async function submitPatchUsage() {
times_per_day: number
usage_days: number
medication_days: number
dosage_bag_count?: number
aux_times_per_day?: number
aux_usage_days?: number
aux_dosage_bag_count?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
}
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
@@ -1763,7 +1824,7 @@ async function refresh() {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
void fetchLogisticsTrace()
}
if (props.loadRelatedData) void fetchLogs(id)
if (props.loadRelatedData) await fetchLogs(id)
const diagId = d.diagnosis_id
if (props.loadRelatedData && diagId) {
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
@@ -883,6 +883,17 @@
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
<el-button
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
@@ -896,6 +907,17 @@
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
<el-button
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
@@ -2261,6 +2283,15 @@
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="dosage_bag_count"
>
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
@@ -2286,6 +2317,15 @@
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="aux_dosage_bag_count"
>
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
@@ -3954,10 +3994,12 @@ const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
dosage_bag_count: 1 as number | undefined,
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
aux_dosage_bag_count: 1 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
@@ -3966,9 +4008,15 @@ const patchUsageRules = computed<FormRules>(() => {
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
}
}
return rules
})
@@ -3981,6 +4029,7 @@ function openPatchUsageDialog() {
return
}
const aux = detailAuxUsage.value
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
@@ -3990,16 +4039,20 @@ function openPatchUsageDialog() {
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.aux_dosage_bag_count =
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.dosage_bag_count = 1
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.aux_dosage_bag_count = 1
patchUsageForm.medication_days = undefined
}
@@ -4020,17 +4073,25 @@ async function submitPatchUsage() {
times_per_day: number
usage_days: number
medication_days: number
dosage_bag_count?: number
aux_times_per_day?: number
aux_usage_days?: number
aux_dosage_bag_count?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
}
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
@@ -0,0 +1,227 @@
<template>
<div class="prescription-ai-visibility">
<el-card v-loading="loading" shadow="never" class="!border-none">
<div class="text-xl font-medium mb-[20px]">处方 AI 一致度可见范围</div>
<el-alert type="info" :closable="false" show-icon class="mb-5">
<template #title>指定用户组或用户可查看一致度</template>
<div class="leading-6">
用户命中任一指定系统角色,或被单独指定,即可查看桌面端“与 AI
一致度”、逐味对照和一致度统计。AI
报告继续按原有权限开放,已有报告不受影响,患者与处方的数据范围保持原有规则。
</div>
</el-alert>
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
show-icon
class="mb-5"
/>
<div v-if="loading" role="status" class="mb-5">正在读取当前可见范围…</div>
<el-form
v-if="hasLoaded"
class="ls-form"
:model="formData"
:disabled="!ready || loading || saving"
label-width="140px"
>
<el-form-item label="指定用户组" prop="role_ids">
<div class="w-full max-w-[640px]">
<el-select
v-model="formData.role_ids"
class="w-full"
multiple
filterable
clearable
placeholder="请选择系统角色,可搜索角色名称"
>
<el-option
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
<div class="form-tips">账号拥有任一指定角色即可命中。</div>
</div>
</el-form-item>
<el-form-item label="指定用户" prop="admin_ids">
<div class="w-full max-w-[640px]">
<el-select
v-model="formData.admin_ids"
class="w-full"
multiple
filterable
clearable
placeholder="请选择用户,可搜索姓名或账号"
>
<el-option
v-for="admin in adminOptions"
:key="admin.id"
:label="admin.label"
:value="admin.id"
/>
</el-select>
<div class="form-tips">
单独指定的用户无需同时命中用户组。当前不在可选列表中的已配置 ID
会保留,移除后保存才会取消该项授权。
</div>
</div>
</el-form-item>
<el-form-item label="生效规则">
<div>
<div class="leading-6">指定用户组与指定用户按“或”生效。</div>
<div class="form-tips">
两项均为空时,所有账号(包括超级管理员)均隐藏一致度、对照与统计。
拥有本页管理权限不自动获得一致度查看权,超级管理员也需命中上述名单。
</div>
</div>
</el-form-item>
</el-form>
<el-alert
v-if="ready && !formData.role_ids.length && !formData.admin_ids.length"
title="当前选择为空:保存后所有账号将隐藏一致度、对照与统计,AI 报告仍保留。"
type="warning"
:closable="false"
show-icon
class="mb-5"
/>
<el-alert
v-if="saveError"
:title="saveError"
type="error"
:closable="false"
show-icon
class="mb-5"
/>
<el-button :disabled="loading || saving" @click="loadConfig">
{{ loadError ? '重试加载' : '重新加载' }}
</el-button>
</el-card>
<footer-btns v-if="canManage">
<el-button
type="primary"
:loading="saving"
:disabled="!ready || loading || saving"
@click="handleSubmit"
>
{{ saving ? '正在保存' : '保存' }}
</el-button>
</footer-btns>
</div>
</template>
<script setup lang="ts" name="prescriptionAiVisibility">
import { computed, reactive, ref } from 'vue'
import type {
PrescriptionAiVisibilityConfig,
PrescriptionAiVisibilityOptions
} from '@/api/setting/prescription_ai_visibility'
import {
getPrescriptionAiCapabilities,
getPrescriptionAiVisibilityConfig,
getPrescriptionAiVisibilityOptions,
setPrescriptionAiVisibilityConfig
} from '@/api/setting/prescription_ai_visibility'
import feedback from '@/utils/feedback'
const loading = ref(false)
const saving = ref(false)
const ready = ref(false)
const hasLoaded = ref(false)
const canManage = ref(false)
const loadError = ref('')
const saveError = ref('')
const formData = reactive<PrescriptionAiVisibilityConfig>({ role_ids: [], admin_ids: [] })
const optionsData = reactive<PrescriptionAiVisibilityOptions>({ roles: [], admins: [] })
const roleOptions = computed(() => {
const options = new Map(optionsData.roles.map((role) => [role.id, role]))
for (const id of formData.role_ids) {
if (!options.has(id)) {
options.set(id, { id, name: `已配置角色 #${id}(当前不在可选列表)` })
}
}
return [...options.values()]
})
const adminOptions = computed(() => {
const options = new Map(
optionsData.admins.map((admin) => [
admin.id,
{ id: admin.id, label: `${admin.name || `用户 #${admin.id}`}(${admin.account})` }
])
)
for (const id of formData.admin_ids) {
if (!options.has(id)) {
options.set(id, { id, label: `已配置用户 #${id}(当前不在可选列表)` })
}
}
return [...options.values()]
})
const assignConfig = (config: PrescriptionAiVisibilityConfig) => {
formData.role_ids = [...config.role_ids]
formData.admin_ids = [...config.admin_ids]
}
const loadConfig = async () => {
if (loading.value || saving.value) return
loading.value = true
ready.value = false
canManage.value = false
loadError.value = ''
saveError.value = ''
try {
const capabilities = await getPrescriptionAiCapabilities()
if (capabilities.can_manage_visibility !== true) {
loadError.value = '当前账号无权管理处方 AI 一致度可见范围。'
return
}
canManage.value = true
const [config, options] = await Promise.all([
getPrescriptionAiVisibilityConfig(),
getPrescriptionAiVisibilityOptions()
])
if (!Array.isArray(options?.roles) || !Array.isArray(options?.admins)) {
throw new Error('可选用户组或用户响应格式不正确')
}
// Apply both responses together; an incomplete load cannot overwrite the current policy.
assignConfig(config)
optionsData.roles = options.roles
optionsData.admins = options.admins
hasLoaded.value = true
ready.value = true
} catch {
loadError.value = '可见范围加载失败,尚未读取完整配置。请重试加载后再保存。'
} finally {
loading.value = false
}
}
const handleSubmit = async () => {
if (!ready.value || !canManage.value || loading.value || saving.value) return
saving.value = true
saveError.value = ''
try {
const config = await setPrescriptionAiVisibilityConfig({
role_ids: [...formData.role_ids],
admin_ids: [...formData.admin_ids]
})
assignConfig(config)
feedback.msgSuccess('可见范围已保存')
} catch {
saveError.value = '保存结果未确认,当前选择已保留。请重试保存,或重新加载以确认服务端策略。'
} finally {
saving.value = false
}
}
void loadConfig()
</script>
@@ -0,0 +1,901 @@
<template>
<div class="revisit-rate-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="rate-filter-form">
<el-form-item :label="dateLabel">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator="至"
:clearable="false"
style="width: 280px"
@change="loadData"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="二中心(全部)"
clearable
filterable
check-strictly
node-key="id"
:default-expand-all="true"
:props="deptTreeProps"
style="width: 210px"
@change="handleDeptChange"
/>
</el-form-item>
<el-form-item label="医助">
<el-select
v-model="assistantId"
placeholder="全部医助"
filterable
clearable
:loading="optionsLoading"
style="width: 180px"
@change="loadData"
>
<el-option
v-for="assistant in assistantOptions"
:key="assistant.id"
:label="assistant.name"
:value="assistant.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
<el-button :disabled="loading" @click="handleReset">重置</el-button>
</el-form-item>
<el-form-item>
<el-popover placement="bottom-start" :width="540" trigger="hover">
<template #reference>
<span class="rate-caliber-trigger"
><el-icon><InfoFilled /></el-icon>统计口径</span
>
</template>
<div class="rate-caliber">
<p>
<b>{{ cohortLabel }}</b
>:按所选{{
dateLabel
}}范围确定患者,订单从对应指派后累计至查询当天;结束日期允许选择未来日期,但未来订单不会提前计入。
</p>
<p v-if="isInherit">
<b>继承诊次重新起算</b>:继承后的第 1 笔有效订单算二诊,第 2
笔算三诊,依次递增;不使用继承前的全局诊次,也不叠加诊次偏移。只统计继承患者,不混入新指派患者。
</p>
<p v-else>
只统计新指派患者,不混入继承患者。继承患者在独立的「继承复诊接诊率」页面统计。
</p>
<p>
<b>N 诊接诊率</b> = N 诊单数 ÷ 本页{{ cohortLabel }};分母为 0
显示「—」。<b>复诊合计接诊率</b> = 复诊合计单数 ÷ 本页{{
cohortLabel
}},表示人均复诊成交单数,可超过 100%。
</p>
<p>
订单仅计入有效业务订单,剔除已取消 / 拒收 /
退款;归属按服务端的患者持有记录计算。医助按人事部门归组;仅统计「二中心」及其组织下级,选定部门时包含其下级。
</p>
<p>
部门行和合计行按诊单去重,不能直接将医助行相加。灰字为「单数 /
分母」。
</p>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-alert class="mt-3" type="info" :closable="false" show-icon :title="reportHint" />
<el-alert
v-if="optionsError"
class="mt-3"
type="warning"
:title="optionsError"
:closable="false"
/>
<el-alert
v-if="queryError"
class="mt-3"
type="error"
:title="queryError"
:closable="false"
/>
</el-card>
<div class="rate-kpi-grid">
<div
v-for="card in summaryCards"
:key="card.key"
class="rate-kpi-card"
:class="{ 'is-clickable': card.clickable }"
@click="card.clickable && card.onClick()"
>
<div class="rate-kpi-label">{{ card.label }}</div>
<div class="rate-kpi-value">{{ card.value }}</div>
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="rate-card-header">
<span>{{ reportTitle }} · 部门 / 医助明细</span>
<span class="rate-card-hint">{{ dateLabel }}:{{ rangeText }}</span>
<span v-if="observedUntil" class="rate-card-hint"
>订单统计截至:{{ observedUntil }}</span
>
</div>
<div class="rate-card-hint mt-2">
点击{{ cohortLabel }}、各诊次单数可查看患者与订单;底栏合计按诊单去重。
</div>
</template>
<el-table
v-loading="loading"
:data="rows"
size="small"
border
row-key="row_key"
:tree-props="{ children: 'children' }"
:default-expand-all="true"
show-summary
:summary-method="getSummaries"
>
<el-table-column label="部门 / 医助" min-width="200" fixed>
<template #default="{ row }">
<span v-if="row.is_dept" class="rate-dept-cell"
>{{ row.dept_name
}}<span class="rate-dept-count"
>{{ row.assistant_count }} 人</span
></span
>
<span v-else>{{ row.assistant_name }}</span>
</template>
</el-table-column>
<el-table-column
:prop="cohortCountKey"
:label="cohortLabel"
min-width="100"
align="right"
>
<template #header>
<span>{{ cohortLabel }}</span>
<el-tooltip
effect="dark"
placement="top"
:content="`所选${dateLabel}范围内的${cohortLabel},部门和合计按诊单去重`"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row[cohortCountKey] > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row)"
>{{ row[cohortCountKey] }}</el-link
>
<span v-else>{{ row[cohortCountKey] ?? 0 }}</span>
</template>
</el-table-column>
<template v-for="slot in visitSlots" :key="slot">
<el-table-column
:prop="`visit${slot}_count`"
:label="`${slotLabel(slot)}单数`"
min-width="96"
align="right"
>
<template #header>
<span>{{ slotLabel(slot) }}单数</span>
<el-tooltip effect="dark" placement="top" :content="slotCountTip(slot)"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
type="primary"
:underline="false"
@click="openOrderDetail(slot, row)"
>{{ row[`visit${slot}_count`] }}</el-link
>
<span v-else>{{ row[`visit${slot}_count`] ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column
:prop="`visit${slot}_rate`"
:label="`${slotLabel(slot)}接诊率`"
min-width="110"
align="right"
>
<template #header>
<span>{{ slotLabel(slot) }}接诊率</span>
<el-tooltip
effect="dark"
placement="top"
:content="`${slotLabel(slot)}单数 ÷ ${cohortLabel};分母为 0 显示「—」`"
><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 class="rate-cell-sub">{{
denText(row, `visit${slot}`)
}}</span>
</div>
</template>
</el-table-column>
</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="所选患者从对应指派后累计至查询当天的全部复诊订单数"
><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="128"
align="right"
>
<template #header>
<span>复诊合计接诊率</span>
<el-tooltip
effect="dark"
placement="top"
:content="`复诊合计单数 ÷ ${cohortLabel};表示人均复诊成交单数,可超过 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 class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="1080px"
top="6vh"
destroy-on-close
@close="invalidateDetail"
>
<p class="rate-detail-hint">{{ detailHint }}</p>
<el-alert
v-if="detailError"
class="mb-3"
type="error"
:title="detailError"
:closable="false"
/>
<el-table
v-loading="detailLoading"
:data="detailRows"
size="small"
border
stripe
max-height="60vh"
>
<template v-if="detailType === 'assign'">
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
>
<span v-else>{{ row.patient_name || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
><template #default="{ row }">{{
row.patient_phone || '—'
}}</template></el-table-column
>
<el-table-column
prop="assistant_names"
:label="isInherit ? '继承医助' : '被指派医助'"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="assign_count"
:label="isInherit ? '范围内继承次数' : '范围内指派次数'"
width="120"
align="right"
/>
<el-table-column
prop="first_assign_time_text"
:label="isInherit ? '首次继承时间' : '首次指派时间'"
min-width="160"
/>
<el-table-column
prop="last_assign_time_text"
:label="isInherit ? '最近继承时间' : '最近指派时间'"
min-width="160"
/>
</template>
<template v-else>
<el-table-column
prop="order_no"
label="订单号"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link
v-if="canViewOrderDetail && row.order_id"
type="primary"
:underline="false"
@click="openOrderInfo(row.order_id)"
>{{ row.order_no || `#${row.order_id}` }}</el-link
>
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
</template>
</el-table-column>
<el-table-column
v-if="detailSlot === 0 || isInherit"
prop="visit_slot"
:label="isInherit ? '继承后诊次' : '诊次'"
width="100"
align="center"
><template #default="{ row }">{{
slotLabel(Number(row.visit_slot))
}}</template></el-table-column
>
<el-table-column
v-if="isInherit"
prop="global_visit_slot"
label="全局诊次(参考)"
width="130"
align="center"
><template #default="{ row }">{{
row.global_visit_slot ? slotLabel(Number(row.global_visit_slot)) : '—'
}}</template></el-table-column
>
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
>
<span v-else>{{ row.patient_name || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
><template #default="{ row }">{{
row.patient_phone || '—'
}}</template></el-table-column
>
<el-table-column prop="amount" label="金额" width="100" align="right"
><template #default="{ row }">¥ {{ row.amount }}</template></el-table-column
>
<el-table-column
prop="cohort_assign_time_text"
:label="isInherit ? '继承时间' : '指派时间'"
min-width="160"
/>
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
</template>
</el-table>
<div class="rate-detail-footer">共 {{ detailCount }} 条</div>
</el-dialog>
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
<diagnosis-edit-popup ref="diagnosisEditRef" />
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import {
revisitCohortAssignLines,
revisitCohortDeptOptions,
revisitCohortOverview,
revisitCohortVisitOrderLines,
type RevisitRateCohortType,
type RevisitRateDetailQuery,
type RevisitRateQuery
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
const props = defineProps<{ cohortType: RevisitRateCohortType }>()
const OrderDetailDrawer = defineAsyncComponent(
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
)
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
interface MetricPack extends Record<string, any> {
assigned_count: number
inherit_count: number
assigned_total_count: number
visit_all_count: number
visit_all_den: number
visit_all_rate: number | null
}
interface StatsRow extends MetricPack {
row_key: string
is_dept: 0 | 1
dept_id: number
dept_name: string
assistant_id?: number
assistant_name?: string
assistant_count?: number
children?: StatsRow[]
}
interface DeptOption {
id: number
pid: number
name: string
}
interface DeptTreeNode extends DeptOption {
children?: DeptTreeNode[]
}
interface AssistantOption {
id: number
name: string
dept_id: number
}
interface SummaryCard {
key: string
label: string
value: string
sub: string
clickable: boolean
onClick: () => void
}
const isInherit = computed(() => props.cohortType === 'inherit')
const cohortLabel = computed(() => (isInherit.value ? '继承数' : '新指派数'))
const cohortCountKey = computed(() => (isInherit.value ? 'inherit_count' : 'assigned_count'))
const dateLabel = computed(() => (isInherit.value ? '继承日期' : '指派日期'))
const reportTitle = computed(() => (isInherit.value ? '继承复诊接诊率' : '按指派周期复诊率'))
const reportHint = computed(() =>
isInherit.value
? '按继承日期范围选择患者,订单累计至查询当天。继承后第 1 笔有效订单算二诊,第 2 笔算三诊;忽略继承前全局诊次及偏移,新指派患者另页统计。'
: '按新指派日期范围选择患者,订单累计至查询当天,跨月继续累计。继承患者在独立的「继承复诊接诊率」页面统计。'
)
const permissionPrefix = computed(() =>
isInherit.value ? 'stats.revisitInheritRate' : 'stats.revisitAssignRate'
)
const canViewAssignLines = computed(() => hasPermission([`${permissionPrefix.value}/assignLines`]))
const canViewOrderLines = computed(() =>
hasPermission([`${permissionPrefix.value}/visitOrderLines`])
)
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
const formatDate = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const defaultDateRange = (): [string, string] => {
const now = new Date()
return [formatDate(new Date(now.getFullYear(), now.getMonth(), 1)), formatDate(now)]
}
const dateRange = ref<[string, string]>(defaultDateRange())
const deptId = ref<number | undefined>()
const assistantId = ref<number | undefined>()
const loading = ref(false)
const queryError = ref('')
const rows = ref<StatsRow[]>([])
const total = ref<MetricPack | null>(null)
const visitSlots = ref<number[]>([2, 3, 4])
const observedUntil = ref('')
/** 下钻只复用已展示报表的查询快照,避免读取正在编辑的筛选。 */
const loadedQuery = ref<RevisitRateQuery | null>(null)
let overviewRequestId = 0
let disposed = false
const rangeText = computed(() =>
loadedQuery.value
? `${loadedQuery.value.start_date} 至 ${loadedQuery.value.end_date}`
: `${dateRange.value[0]} 至 ${dateRange.value[1]}`
)
const currentQuery = (): RevisitRateQuery => ({
start_date: dateRange.value[0],
end_date: dateRange.value[1],
dept_ids: deptId.value ? String(deptId.value) : '',
assistant_id: assistantId.value || undefined
})
const deptOptions = ref<DeptOption[]>([])
const allAssistants = ref<AssistantOption[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const deptTreeOptions = computed<DeptTreeNode[]>(() => {
const nodes = new Map<number, DeptTreeNode>()
deptOptions.value.forEach((item) => nodes.set(item.id, { ...item, children: [] }))
const roots: DeptTreeNode[] = []
nodes.forEach((node) => {
const parent = nodes.get(node.pid)
if (parent && parent !== node) parent.children!.push(node)
else roots.push(node)
})
const stripEmptyChildren = (node: DeptTreeNode) => {
if (node.children?.length) node.children.forEach(stripEmptyChildren)
else delete node.children
}
roots.forEach(stripEmptyChildren)
return roots
})
const selectedDeptIds = computed(() => {
if (!deptId.value) return null
const ids = new Set<number>([Number(deptId.value)])
let expanded = true
while (expanded) {
expanded = false
deptOptions.value.forEach((item) => {
if (ids.has(item.pid) && !ids.has(item.id)) {
ids.add(item.id)
expanded = true
}
})
}
return ids
})
const assistantOptions = computed(() =>
allAssistants.value.filter(
(item) => !selectedDeptIds.value || selectedDeptIds.value.has(item.dept_id)
)
)
const handleDeptChange = () => {
if (assistantId.value && !assistantOptions.value.some((item) => item.id === assistantId.value))
assistantId.value = undefined
void loadData()
}
const loadDeptOptions = async () => {
optionsLoading.value = true
optionsError.value = ''
try {
const res = await revisitCohortDeptOptions(props.cohortType)
if (disposed) return
deptOptions.value = (res?.rows ?? []).map((item: DeptOption) => ({
...item,
id: Number(item.id),
pid: Number(item.pid)
}))
allAssistants.value = (res?.assistants ?? []).map((item: AssistantOption) => ({
...item,
id: Number(item.id),
dept_id: Number(item.dept_id)
}))
} catch {
if (!disposed) optionsError.value = '部门及医助选项加载失败,请刷新页面重试。'
} finally {
if (!disposed) optionsLoading.value = false
}
}
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot === 0) return '复诊合计'
if (slot >= 1 && slot <= 10) return `${cnDigits[slot]}诊`
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
return `${slot}诊`
}
const slotCountTip = (slot: number) =>
isInherit.value
? `所选继承患者在继承后的第 ${slot - 1} 笔有效订单数,累计至查询当天;不使用继承前全局诊次和偏移`
: `所选新指派患者的${slotLabel(slot)}有效订单数,从对应指派后累计至查询当天`
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const denText = (row: Record<string, any>, prefix: string) =>
`${Number(row[`${prefix}_count`] ?? 0)} / ${row[`${prefix}_den`] ?? '—'}`
const summaryCards = computed<SummaryCard[]>(() => {
const t = total.value
const cards: SummaryCard[] = [
{
key: 'cohort',
label: cohortLabel.value,
value: t ? String(t[cohortCountKey.value] ?? 0) : '—',
sub: `按${dateLabel.value}范围选择患者`,
clickable: !!(canViewAssignLines.value && t && t[cohortCountKey.value] > 0),
onClick: () => void openAssignDetail(null)
}
]
visitSlots.value.forEach((slot) =>
cards.push({
key: `visit${slot}`,
label: `${slotLabel(slot)}接诊率`,
value: t ? formatRate(t[`visit${slot}_rate`]) : '—',
sub: t
? `${slotLabel(slot)}下单 ${t[`visit${slot}_count`] ?? 0} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}`
: '',
clickable: !!(canViewOrderLines.value && t && t[`visit${slot}_count`] > 0),
onClick: () => void 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.value && t && t.visit_all_count > 0),
onClick: () => void openOrderDetail(0, null)
})
return cards
})
/** 服务端已按诊单去重,合计不能由各医助行相加。 */
const getSummaries = ({ columns }: { columns: Array<{ property?: string }> }) =>
columns.map((column, index) => {
if (index === 0) return '合计'
const t = total.value
if (!t) return ''
const prop = column.property || ''
if (prop === cohortCountKey.value) return String(t[prop] ?? 0)
const match = /^visit(\d+|_all)_(count|rate)$/.exec(prop)
if (!match) return ''
return match[2] === 'rate'
? `${formatRate(t[prop])}(${denText(t, `visit${match[1]}`)})`
: String(t[prop] ?? 0)
})
const loadData = async () => {
const requestId = ++overviewRequestId
invalidateDetail()
detailVisible.value = false
loadedQuery.value = null
rows.value = []
total.value = null
observedUntil.value = ''
queryError.value = ''
loading.value = true
const query = currentQuery()
try {
const res = await revisitCohortOverview(query, props.cohortType)
if (disposed || requestId !== overviewRequestId) return
rows.value = res?.rows ?? []
total.value = res?.total ?? null
observedUntil.value = res?.observed_until ?? ''
const slots: number[] = Array.isArray(res?.slots) ? res.slots.map(Number) : []
visitSlots.value = slots.length
? [
...new Set(
slots.filter((slot) => Number.isInteger(slot) && slot >= 2 && slot <= 50)
)
].sort((a, b) => a - b)
: [2, 3, 4]
loadedQuery.value = { ...query }
} catch {
if (!disposed && requestId === overviewRequestId)
queryError.value = '报表加载失败,请重新查询。'
} finally {
if (!disposed && requestId === overviewRequestId) loading.value = false
}
}
const detailVisible = ref(false)
const detailType = ref<'assign' | 'orders'>('assign')
const detailTitle = ref('')
const detailHint = ref('')
const detailLoading = ref(false)
const detailError = ref('')
const detailRows = ref<any[]>([])
const detailCount = ref(0)
const detailSlot = ref(2)
let detailRequestId = 0
const invalidateDetail = () => {
++detailRequestId
detailLoading.value = false
detailRows.value = []
detailCount.value = 0
detailError.value = ''
}
const rowScope = (
row: StatsRow | null
): Pick<RevisitRateDetailQuery, 'scope_assistant_id' | 'dept_id'> & { label: string } => {
if (!row) return { label: '合计' }
return row.is_dept
? { dept_id: row.dept_id, label: row.dept_name }
: { scope_assistant_id: row.assistant_id, label: row.assistant_name || '' }
}
const openDetail = async (type: 'assign' | 'orders', slot: number, row: StatsRow | null) => {
if (loading.value || !loadedQuery.value) return
if (type === 'assign' ? !canViewAssignLines.value : !canViewOrderLines.value) return
const query = { ...loadedQuery.value }
const { label, ...scope } = rowScope(row)
const requestId = ++detailRequestId
detailType.value = type
detailSlot.value = slot
detailTitle.value = `${label} · ${type === 'assign' ? `${cohortLabel.value}明细` : `${slotLabel(slot)}订单明细`}(${query.start_date} 至 ${query.end_date})`
detailHint.value = `${dateLabel.value}:${query.start_date} 至 ${query.end_date};订单统计截至:${observedUntil.value || '查询当天'}${isInherit.value && type === 'orders' ? '。继承后首笔有效订单算二诊,全局诊次仅供参考。' : '。'}`
detailRows.value = []
detailCount.value = 0
detailError.value = ''
detailLoading.value = true
detailVisible.value = true
try {
// assistant_id 保留顶层筛选,scope_assistant_id 独立约束医助行。
const params: RevisitRateDetailQuery = { ...query, ...scope }
const res =
type === 'assign'
? await revisitCohortAssignLines(params, props.cohortType)
: await revisitCohortVisitOrderLines({ ...params, slot }, props.cohortType)
if (disposed || requestId !== detailRequestId || !detailVisible.value) return
detailRows.value = res?.rows ?? []
detailCount.value = Number(res?.count ?? detailRows.value.length)
} catch {
if (!disposed && requestId === detailRequestId)
detailError.value = '明细加载失败,请关闭后重试。'
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
const openAssignDetail = (row: StatsRow | null) => openDetail('assign', 0, row)
const openOrderDetail = (slot: number, row: StatsRow | null) => openDetail('orders', slot, row)
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
const openOrderInfo = (orderId: number) => {
if (canViewOrderDetail && orderId) orderDetailRef.value?.open(orderId)
}
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
const openPatientDetail = (diagnosisId: number) => {
if (canViewPatientDetail && diagnosisId) diagnosisEditRef.value?.openViewOnly(diagnosisId)
}
const handleReset = () => {
dateRange.value = defaultDateRange()
deptId.value = undefined
assistantId.value = undefined
void loadData()
}
onMounted(() => {
void loadDeptOptions()
void loadData()
})
onBeforeUnmount(() => {
disposed = true
++overviewRequestId
++detailRequestId
})
</script>
<style lang="scss" scoped>
.revisit-rate-page {
.rate-filter-form {
display: flex;
flex-wrap: wrap;
gap: 12px 0;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.rate-caliber-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: help;
}
.rate-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.rate-kpi-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px 20px;
&.is-clickable {
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: var(--el-box-shadow-light);
}
}
.rate-kpi-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.rate-kpi-value {
margin-top: 6px;
font-size: 26px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rate-kpi-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-card-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
}
.rate-card-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-dept-cell {
font-weight: 600;
.rate-dept-count {
margin-left: 6px;
font-weight: 400;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-th-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
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-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-detail-footer {
margin-top: 10px;
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-caliber {
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
p + p {
margin-top: 6px;
}
}
</style>
@@ -0,0 +1,7 @@
<template>
<cohort-report cohort-type="new" />
</template>
<script setup lang="ts" name="revisitAssignRateStatsPage">
import CohortReport from './components/CohortReport.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<cohort-report cohort-type="inherit" />
</template>
<script setup lang="ts" name="revisitInheritRateStatsPage">
import CohortReport from '../revisit_assign_rate/components/CohortReport.vue'
</script>
@@ -205,8 +205,8 @@
</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.duration == null ? '未记录' : `${record.duration} 分钟` }}</dd>
<dt>强度</dt><dd>{{ intensityLabel(record.intensity) }}</dd>
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
</dl>
<div v-if="record.images?.length" class="daily-matrix__detail-images">
@@ -343,11 +343,13 @@
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="运动时长(分钟)" required>
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" />
<el-form-item label="运动时长(分钟)" :required="!exerciseForm.id">
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" placeholder="未记录" />
<span v-if="exerciseForm.id" class="ml-2 text-info">留空表示未记录</span>
</el-form-item>
<el-form-item label="运动强度" required>
<el-select v-model="exerciseForm.intensity" placeholder="请选择运动强度" class="w-full">
<el-form-item label="运动强度">
<el-select v-model="exerciseIntensity" placeholder="未记录" class="w-full">
<el-option label="未记录" value="" />
<el-option label="低强度" :value="1" />
<el-option label="中强度" :value="2" />
<el-option label="高强度" :value="3" />
@@ -492,8 +494,8 @@ interface ExerciseRecord {
patient_id?: number
record_date?: string
exercise_type?: string
duration?: number
intensity?: number
duration?: number | null
intensity?: number | null
intensity_text?: string
images?: string[]
note?: string
@@ -547,6 +549,11 @@ const trackingSubmitting = ref(false)
const bloodForm = ref<BloodRecord>(createBloodForm())
const dietForm = ref<DietRecord>(createDietForm())
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
// el-option uses a string sentinel; persisted unknowns remain SQL/JSON null.
const exerciseIntensity = computed<number | ''>({
get: () => exerciseForm.value.intensity ?? '',
set: (value) => { exerciseForm.value.intensity = value === '' ? null : value }
})
const trackingForm = ref({
note_date: '',
existing_content: '',
@@ -719,8 +726,8 @@ function recordTimePeriodLabel(record: BloodRecord) {
return labels[period] || period || '未提供时段'
}
function intensityLabel(value?: number) {
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '—'
function intensityLabel(value?: number | null) {
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '未记录'
}
function bloodDetailFields(record: BloodRecord) {
@@ -880,7 +887,7 @@ function getCell(metric: string, date: string): { value: string; isHigh: boolean
return { value: count ? '✓' : '', isHigh: false, hasRecord: count > 0 }
}
if (metric === 'exercise') {
return { value: exercise.map((record) => record.duration ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
return { value: exercise.map((record) => record.duration != null ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
}
if (metric === 'tracking') {
return { value: tracking?.content ? summarizeTracking(tracking.content) : '', isHigh: false, hasRecord: !!tracking?.content }
@@ -970,7 +977,12 @@ function openDietRecord(source: DietRecord | undefined, date: string) {
function openExerciseRecord(source: ExerciseRecord | undefined, date: string) {
if (!canEditDailyRecord.value) return
exerciseForm.value = { ...createExerciseForm(date), ...source, images: [...(source?.images || [])] }
exerciseForm.value = {
...createExerciseForm(date), ...source,
// Existing omissions must not inherit the defaults used only for manual new records.
...(source ? { duration: source.duration ?? null, intensity: source.intensity ?? null } : {}),
images: [...(source?.images || [])]
}
exerciseDialogVisible.value = true
}
@@ -1063,12 +1075,12 @@ async function submitExercise() {
feedback.msgWarning('请选择运动类型')
return
}
if (!exerciseForm.value.duration) {
if (!exerciseForm.value.id && !exerciseForm.value.duration) {
feedback.msgWarning('请输入运动时长')
return
}
const targetDiagnosisId = diagnosisId.value
const payload = { ...exerciseForm.value }
const payload = { ...exerciseForm.value, duration: exerciseForm.value.duration ?? null, intensity: exerciseForm.value.intensity ?? null }
exerciseSubmitting.value = true
try {
if (payload.id) {
@@ -34,8 +34,8 @@
<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 label="操作" width="112" align="right">
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button><el-button v-if="!viewOnly && row.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(row)" :title="row.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(row)">删除</el-button></template>
</el-table-column>
</el-table>
</div>
@@ -43,7 +43,7 @@
<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 class="detail-actions"><el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag><el-button v-if="!viewOnly && detail.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(detail)" :title="detail.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(detail)">删除录音</el-button></div>
</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 || '任务处理失败,不能写入;请先核对失败原因。'" />
@@ -56,13 +56,25 @@
<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="声道角色人工标注">
<el-alert v-if="detail.status === 'review' && !reviewItems.length" type="warning" :closable="false" title="未生成可写入信息" :description="detail.uncertainties.length ? `本次提炼保留了 ${detail.uncertainties.length} 条待核实原因,已展示在下方。请结合原文回听核对,尤其注意声道角色是否相符。` : '请查看回访摘要并回听录音;本次没有可供确认写入的结构化候选。'" show-icon />
<section v-if="hasStereo && !['queued', 'running'].includes(detail.status)" 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>
<section v-if="detail.role_check?.conflict || (detail.can_reextract !== undefined && savedChannelRoles !== extractionRoles && savedChannelRoles !== 'unconfirmed')" class="role-reextract" aria-label="角色纠错与重新提炼">
<strong>{{ detail.role_check?.conflict ? '声道角色与转写内容可能不一致' : '审核角色已修改,原提炼结果尚未更新' }}</strong>
<p>{{ detail.role_check?.message || '修改角色标注不会自动改变已生成的建议,可按核实后的角色重新提炼。' }}</p>
<blockquote v-for="(evidence, index) in detail.role_check?.evidence || []" :key="index"><span>{{ evidence.channel === 0 ? '左声道' : '右声道' }} · {{ audioTimestamp(evidence.start_ms) }}:{{ evidence.text }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(evidence.start_ms)">回听依据</el-button></blockquote>
<p>当前提炼依据:{{ extractionRoleLabel(extractionRoles) }}。{{ reextractTarget !== 'unconfirmed' ? `待确认的新角色:${extractionRoleLabel(reextractTarget)}。` : '请先回听并修改上方角色标注。' }}建议仅供核对,系统不会自动交换角色。</p>
<el-button type="primary" plain :disabled="!canReextract || busy || dirty || reextractTarget === 'unconfirmed' || reextractTarget === extractionRoles" @click="reextractWithRoles">确认角色并重新提炼</el-button>
<p v-if="dirty">请先完成当前单项核对或取消编辑,等待审核草稿保存后再重新提炼。</p>
<p v-else-if="detail.reextract_block_reason">{{ detail.reextract_block_reason }}</p>
<p class="muted">复用已完成的转写,不重复语音识别;旧结果保留,重新提炼不自动写入病例。</p>
</section>
<p v-if="(detail.extraction_revision || 0) > 0" class="muted">第 {{ (detail.extraction_revision || 0) + 1 }} 版提炼 · {{ extractionRoleLabel(extractionRoles) }} · 历史结果已保留</p>
<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">
@@ -144,13 +156,13 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate,
FollowupAudioError, followupAudioReextract, followupAudioDelete, 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 { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewBatchIssue, 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;
@@ -194,7 +206,7 @@ const uploading = ref(false)
const uploadProgress = ref(0)
const uploadStage = ref('')
const createUncertain = ref(false)
const mutation = ref<'save' | 'apply' | 'retry' | ''>('')
const mutation = ref<'save' | 'apply' | 'retry' | 'delete' | 'reextract' | ''>('')
const errorMessage = ref('')
const audioUrl = ref('')
const audioElement = ref<HTMLAudioElement | null>(null)
@@ -210,6 +222,12 @@ 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 extractionRoles = computed(() => normalizedChannelRoles(detail.value?.extraction_channel_roles))
const reextractTarget = computed<FollowupChannelRoles>(() => {
if (savedChannelRoles.value !== 'unconfirmed' && savedChannelRoles.value !== extractionRoles.value) return savedChannelRoles.value
return normalizedChannelRoles(detail.value?.role_check?.suggested_roles)
})
const canReextract = computed(() => !props.viewOnly && featureEnabled.value && detail.value?.status === 'review' && detail.value?.can_reextract === true)
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 ? '测试预览仅保存审核草稿,不允许确认写入病历'
@@ -220,7 +238,7 @@ const applyIssue = computed(() => previewOnly.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) || '')
: reviewBatchIssue(reviewItems.value, detail.value?.fields || props.capabilities.fields) || 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
@@ -270,9 +288,25 @@ 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) {
function taskUnavailable(error: unknown): boolean {
return error instanceof FollowupAudioError && (error.code === 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE' || error.message.includes('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'))
}
function forgetUnavailableTask(id: number) {
if (selectedId.value !== id) return
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
tasks.value = tasks.value.filter(row => row.id !== id)
errorMessage.value = '录音已删除或不再可访问,已关闭本页缓存与播放;未写入病例。'
void refresh()
}
async function readDetail(id: number, preserve = false, verifyOnly = false) {
const ticket = scope.capture()
const fresh = await followupAudioDetail(id, ticket.signal)
let fresh: FollowupTaskDetail
try { fresh = await followupAudioDetail(id, ticket.signal) }
catch (error) {
if (scope.current(ticket) && id === selectedId.value && taskUnavailable(error)) { forgetUnavailableTask(id); return }
throw error
}
if (verifyOnly && detail.value && fresh.version === detail.value.version && fresh.status === detail.value.status) return
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'
@@ -292,6 +326,9 @@ async function refresh() {
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)
} else if (!updated && selectedId.value && detail.value) {
// A limited list can omit older tasks; only an authoritative detail denial revokes them.
await readDetail(selectedId.value, false, true)
}
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
finally {
@@ -382,6 +419,7 @@ async function submitUpload() {
}
async function handleReviewError(error: unknown) {
const ticket = scope.capture()
if (taskUnavailable(error) && detail.value) { forgetUnavailableTask(detail.value.id); return }
errorMessage.value = followupError(error)
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
@@ -488,6 +526,57 @@ async function applySelected() {
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
}
function extractionRoleLabel(roles: FollowupChannelRoles): string {
return roles === 'left_service' ? '左客服 / 右患者或家属' : roles === 'right_service' ? '右客服 / 左患者或家属' : '角色未确认'
}
async function reextractWithRoles() {
const roles = reextractTarget.value
if (!canReextract.value || !detail.value || busy.value || roles === 'unconfirmed' || roles === extractionRoles.value) return
if (dirty.value) { ElMessage.warning('请先完成或取消当前审核修改,等待草稿保存后再重新提炼'); return }
const ticket = scope.capture(), id = detail.value.id, version = detail.value.version, diagnosisId = props.diagnosisId
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId && selectedId.value === id
clearAutosave(); clearTimer(); mutation.value = 'reextract'; errorMessage.value = ''
let confirmed = false
try {
await ElMessageBox.confirm(`确认已回听并核实角色为“${extractionRoleLabel(roles)}”?将保留旧版结果,复用已完成的转写重新提炼,不重复语音识别;新建议须重新审核,不自动写入病例。`, '确认角色并重新提炼', { type: 'warning', confirmButtonText: '角色正确,开始重新提炼', cancelButtonText: '返回回听' })
if (!current() || !canReextract.value || dirty.value) return
confirmed = true
const fresh = await followupAudioReextract({ id, version, channel_roles: roles }, ticket.signal)
if (!current()) return
installDetail(fresh, false); saveState.value = 'idle'; editingRoles.value = false
ElMessage.success('已提交重新提炼,将复用原转写;新结果仍需审核,不自动写入病例')
} catch (error) { if (confirmed && current()) await handleReviewError(error) }
finally { if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() } }
}
function canDeleteTask(task: FollowupTaskSummary): boolean {
return !props.viewOnly && featureEnabled.value && task.can_delete === true && ['review', 'failed'].includes(task.status)
}
async function deleteRecording(task: FollowupTaskSummary) {
if (busy.value || !canDeleteTask(task)) return
const ticket = scope.capture(), diagnosisId = props.diagnosisId, id = task.id, version = task.version
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId
clearAutosave(); clearTimer(); mutation.value = 'delete'; errorMessage.value = ''
let confirmed = false
try {
await ElMessageBox.confirm('删除后,该录音任务及审核草稿将从列表移除,未保存修改会丢弃;不删除或回滚病例。原录音和转写仍按原保留期限清理。重新上传同一文件将创建新的分析任务。确认删除?', '删除录音任务', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '保留录音' })
if (!current() || !canDeleteTask(task)) return
confirmed = true
await followupAudioDelete({ id, version }, ticket.signal)
if (!current()) return
// Fence pending playback, autosaves and reads; deletion never revives a stale draft.
if (selectedId.value === id) {
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
}
tasks.value = tasks.value.filter(row => row.id !== id)
errorMessage.value = ''
ElMessage.success('录音任务已删除,未修改病例。原资料按保留策略清理。')
await refresh()
} catch (error) {
if (confirmed && current()) errorMessage.value = followupError(error)
} finally {
if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() }
}
}
async function retryTask() {
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
const ticket = scope.capture()
@@ -577,6 +666,11 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
.detail-heading { align-items: flex-start; }
.recording-title { min-width: 0; }
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
.role-reextract { padding: 12px; border: 1px solid var(--el-color-warning-light-5); background: var(--el-color-warning-light-9); border-radius: 6px; display: grid; gap: 8px; }
.role-reextract p { margin: 0; line-height: 1.6; }
.role-reextract blockquote { margin: 0; padding: 8px; background: var(--el-bg-color); white-space: pre-wrap; overflow-wrap: anywhere; }
.role-reextract :deep(.el-button) { justify-self: start; }
.detail-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.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; }
@@ -32,8 +32,8 @@
</el-table-column>
</el-table>
</div>
<section v-if="group === 'pending' && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
<strong>原文中还需补充的信息</strong>
<section v-if="(group === 'pending' || !items.length) && 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="同页核对与回听">
@@ -70,6 +70,22 @@ export function fieldText(value: FollowupValue | undefined, field: FollowupField
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))
}
/** Prevent multiple selected writes to the same diagnosis field; never silently pick a last value. */
export function reviewBatchIssue(items: FollowupReviewItem[], fields: FollowupFields): string {
const touched = new Set<string>()
for (const item of items.filter(item => item.selected)) {
if (item.kind === 'diagnosis') {
for (const key of Object.keys(item.values)) {
if (touched.has(key)) {
const label = fields.diagnosis?.find(field => field.key === key)?.label || key
return `${label}存在多条选中建议,请合并内容到一项或仅保留一项后再确认写入`
}
touched.add(key)
}
}
}
return ''
}
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
if (!item.selected) return ''
for (const key of Object.keys(item.values)) {
+60 -13
View File
@@ -671,9 +671,14 @@
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<el-tab-pane v-if="followupCapabilities?.enabled && formData.id" label="回访录音" name="followupAudio" lazy>
<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音" name="followupAudio" lazy>
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
<div v-else-if="followupCapabilitiesError" role="alert">
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
<el-button class="mt-3" @click="loadFollowupCapabilities">重新加载</el-button>
</div>
<FollowupAudioPanel
v-if="visible && formData.id && followupCapabilities"
v-else-if="visible && followupCapabilities?.enabled && followupCapabilitiesDiagnosisId === Number(formData.id)"
:key="Number(formData.id)"
ref="followupAudioRef"
:diagnosis-id="Number(formData.id)"
@@ -684,6 +689,13 @@
:before-apply="canApplyFollowup"
@applied="handleFollowupApplied"
/>
<el-alert
v-else
:title="followupCapabilities?.test_scope_allowed === false ? '当前诊单未开放回访录音' : '当前未启用回访录音,请联系管理员确认'"
type="info"
:closable="false"
show-icon
/>
</el-tab-pane>
<!-- 病历记录标签页(开方后自动显示) -->
@@ -833,6 +845,9 @@ 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 followupCapabilitiesLoading = ref(false)
const followupCapabilitiesError = ref('')
const followupCapabilitiesDiagnosisId = ref<number | null>(null)
const basicSnapshot = ref('')
let diagnosisGeneration = 0
let capabilitiesRequest: AbortController | undefined
@@ -847,6 +862,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
@@ -1021,18 +1037,50 @@ async function handleFollowupApplied(diagnosisId: number) {
if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
}
}
watch([visible, () => formData.value.id], async ([isVisible, id]) => {
function resetFollowupCapabilities() {
capabilitiesRequest?.abort()
capabilitiesRequest = undefined
followupCapabilities.value = null
if (!isVisible || !id) return
followupCapabilitiesDiagnosisId.value = null
followupCapabilitiesLoading.value = false
followupCapabilitiesError.value = ''
}
async function loadFollowupCapabilities(): Promise<void> {
// A rapid second click must not create another request for the same context.
if (followupCapabilitiesLoading.value) return
resetFollowupCapabilities()
const id = Number(formData.value.id)
if (!visible.value || !id || !hasFollowupAudioPermission.value) return
const generation = diagnosisGeneration
const request = new AbortController()
capabilitiesRequest = request
followupCapabilitiesLoading.value = true
const isCurrent = () => capabilitiesRequest === request && !request.signal.aborted
&& generation === diagnosisGeneration && visible.value && Number(formData.value.id) === id
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() })
const result = await followupAudioCapabilities(id, request.signal)
if (!isCurrent()) return
if (typeof result?.enabled !== 'boolean') throw new Error('Invalid capability response')
followupCapabilities.value = result
followupCapabilitiesDiagnosisId.value = id
} catch {
if (!isCurrent()) return
// No endpoint failure (including permission denial) may mount the panel.
followupCapabilitiesError.value = '回访录音状态加载失败,请重新加载;若仍失败,请联系管理员检查权限或服务状态'
} finally {
if (isCurrent()) {
followupCapabilitiesLoading.value = false
capabilitiesRequest = undefined
}
}
}
watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
// Invalidate synchronously on patient switches and permission changes.
if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
resetFollowupCapabilities()
void loadFollowupCapabilities()
}, { flush: 'sync' })
onBeforeUnmount(() => { diagnosisGeneration++; resetFollowupCapabilities() })
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
const createSourceOptions = [
@@ -1360,7 +1408,7 @@ const open = async (type: string, id?: number) => {
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
resetFollowupCapabilities()
formData.value.id = ''
viewOnly.value = false
mode.value = type
@@ -1390,7 +1438,7 @@ const openViewOnly = async (id: number) => {
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
resetFollowupCapabilities()
formData.value.id = ''
viewOnly.value = true
mode.value = 'edit'
@@ -1490,8 +1538,7 @@ const handleOpenPrescription = () => {
const handleClose = async () => {
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
diagnosisGeneration++
capabilitiesRequest?.abort()
followupCapabilities.value = null
resetFollowupCapabilities()
basicSnapshot.value = ''
formRef.value?.resetFields()
formData.value = {
@@ -0,0 +1,47 @@
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 file = process.env.FOLLOWUP_EXERCISE_MATRIX_SOURCE || path.resolve(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
const source = fs.readFileSync(file, 'utf8')
function setup() {
const descriptor = parse(source, {filename:file}).descriptor
const compiled = compileScript(descriptor, {id:'exercise-null'})
const output = ts.transpileModule(compiled.content, {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
const calls=[]; const warnings=[]; const module={exports:{}}
const api={diagnosisTrackingWindow:async()=>({}),diagnosisTrackingNotes:async()=>[],exerciseRecordEdit:async p=>calls.push(p),exerciseRecordAdd:async p=>calls.push(p)}
new Function('require','module','exports',output)(name=>name==='vue'?{...vue,onMounted(){},onUnmounted(){}}:name==='@/utils/perm'?{hasPermission:()=>true}:name==='@/api/tcm'?api:name==='@/utils/feedback'?{default:{msgWarning:m=>warnings.push(m)}}:{default:{}},module,module.exports)
const scope=vue.effectScope()
const matrix=scope.run(()=>module.exports.default.setup({diagnosisId:1,patientId:101,readOnly:false},{expose(){}}))
return {matrix,calls,warnings,dispose:()=>scope.stop()}
}
test('real DailyMatrix edit and submit preserve null duration/intensity',async()=>{
const f=setup(); f.matrix.openExerciseRecord({id:1,record_date:'2026-09-29',exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null)
f.matrix.exerciseForm.value.note='仅改备注';await f.matrix.submitExercise()
assert.equal(f.calls.length,1);assert.equal(f.calls[0].duration,null);assert.equal(f.calls[0].intensity,null);assert.deepEqual(f.warnings,[]);f.dispose()
})
test('missing keys in existing row do not inherit manual-entry defaults',()=>{
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步'},'2026-09-29')
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null);f.dispose()
})
test('explicit known and zero values remain unchanged; cleared duration submits null',async()=>{
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步',duration:0,intensity:1},'2026-09-29')
await f.matrix.submitExercise();assert.equal(f.calls[0].duration,0);assert.equal(f.calls[0].intensity,1)
f.matrix.exerciseForm.value.duration=undefined;await f.matrix.submitExercise();assert.equal(f.calls[1].duration,null);f.dispose()
})
test('unknown intensity option maps to null without conflating known low intensity',()=>{
const f=setup();f.matrix.openExerciseRecord({id:1,exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
assert.equal(f.matrix.exerciseIntensity.value,'');f.matrix.exerciseIntensity.value=1;assert.equal(f.matrix.exerciseForm.value.intensity,1)
f.matrix.exerciseIntensity.value='';assert.equal(f.matrix.exerciseForm.value.intensity,null)
assert.equal(f.matrix.intensityLabel(null),'未记录');assert.equal(f.matrix.intensityLabel(1),'低强度');f.dispose()
})
test('matrix compiles and detail rendering explicitly distinguishes unknown from zero',()=>{
const descriptor=parse(source,{filename:file}).descriptor;const script=compileScript(descriptor,{id:'exercise-compile'})
const render=compileTemplate({source:descriptor.template.content,filename:file,id:'exercise-compile',compilerOptions:{bindingMetadata:script.bindings}})
assert.deepEqual(render.errors,[]);assert.ok(/<el-option label="未记录" value=""/.test(source), "explicit unknown option exists")
assert.ok(/record\.duration == null \? '未记录'/.test(source), 'null duration renders unknown')
})
+333 -2
View File
@@ -38,6 +38,8 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
followupAudioUploadChunk: async (id, index, blob) => { calls.push(['chunk', id, index, blob.size]); return { index, received: true } },
followupAudioUploadComplete: async id => { calls.push(['complete', id]); return { upload_id: id, duration_seconds: 10, sha256: 'test' } },
followupAudioCreate: async payload => { calls.push(['create', payload]); return { task_id: 2, status: 'queued' } },
followupAudioReextract: async payload => { calls.push(['reextract', payload]); return task(payload.id, { status: 'queued', version: payload.version + 1, items: [], extraction_revision: 1, extraction_channel_roles: payload.channel_roles }) },
followupAudioDelete: async payload => { calls.push(['delete', payload]); return { id: payload.id, status: 'deleted', version: payload.version + 1 } },
followupAudioRetry: async id => { calls.push(['retry', id]); return { id, status: 'queued' } },
...overrides
}
@@ -267,7 +269,7 @@ test('authenticated audio object URLs revoked on hide and stale blobs never crea
test('editor capability gate, dirty guard and successful apply hook wire diagnosis plus daily refresh', () => {
const source = fs.readFileSync(path.join(componentDir, '../edit.vue'), 'utf8')
assert.match(source, /v-if="followupCapabilities\?\.enabled && formData.id"/)
assert.match(source, /<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音"/)
assert.match(source, /:before-leave="beforeTabLeave"/)
assert.match(source, /next === 'followupAudio' && basicDirty.value/)
assert.match(source, /await loadDiagnosisDetailIntoForm\(diagnosisId, true\)/)
@@ -1409,7 +1411,7 @@ test('actual patient editor outside preview allowlist still opens and edits its
await editor.handleSubmit()
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
assert.equal(edits[0].remark, '合成普通表单编辑')
assert.match(descriptor.template.content, /v-if="followupCapabilities\?\.enabled && formData.id"/)
assert.match(descriptor.template.content, /<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音"/)
} finally { scope.stop() }
})
@@ -1793,3 +1795,332 @@ test('detail fetch begun before an edit preserves that edit when the new snapsho
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
f.dispose()
})
test('delete requires actor-aware capability and blocks applied/running/unknown tasks', async () => {
const f=setupPanel();await settle()
for (const row of [task(), task(1,{can_delete:false}),task(1,{can_delete:true,status:'applied'}),task(1,{can_delete:true,status:'running'}),task(1,{can_delete:true,status:'needs_reconciliation'})]) await f.panel.deleteRecording(row)
assert.equal(f.calls.filter(c=>c[0]==='delete').length,0)
f.props.viewOnly=true;await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.calls.filter(c=>c[0]==='delete').length,0);f.dispose()
})
test('confirmed deletion sends optimistic version once, clears selected editor and never applies', async () => {
let removed=false;const current=task(1,{can_delete:true})
const f=setupPanel({followupAudioDetail:async()=>current,followupAudioLists:async()=>({items:removed?[]:[current]}),followupAudioDelete:async p=>{f.calls.push(['delete',p]);removed=true;return {id:p.id,status:'deleted',version:p.version+1}}})
await settle();await f.panel.openTask(1);await f.panel.deleteRecording(current)
assert.deepEqual(f.calls.find(c=>c[0]==='delete')[1],{id:1,version:1});assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.deepEqual(f.panel.tasks.value,[]);assert.deepEqual(f.panel.reviewItems.value,[])
assert.equal(f.calls.some(c=>c[0]==='apply'),false);assert.equal(f.panel.mutation.value,'');f.dispose()
})
test('delete cancellation keeps recording and drafts, patient switch before confirm sends nothing', async () => {
const f=setupPanel({}, {}, {confirm:async()=>{throw new Error('cancel')}});await settle();await f.panel.openTask(1);await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.calls.some(c=>c[0]==='delete'),false);assert.equal(f.panel.mutation.value,'');f.dispose()
const answer=deferred();const g=setupPanel({}, {}, {confirm:()=>answer.promise});await settle();const pending=g.panel.deleteRecording(task(1,{can_delete:true}));g.props.diagnosisId=20;await settle();answer.resolve();await pending;assert.equal(g.calls.some(c=>c[0]==='delete'),false);g.dispose()
})
test('late delete result cannot clear another patient and errors preserve current recording', async () => {
const response=deferred();const f=setupPanel({followupAudioDelete:async p=>{f.calls.push(['delete',p]);return response.promise}});await settle();await f.panel.openTask(1)
const deleting=f.panel.deleteRecording(task(1,{can_delete:true}));await settle();const duplicate=f.panel.deleteRecording(task(1,{can_delete:true}));await duplicate;assert.equal(f.calls.filter(c=>c[0]==='delete').length,1)
f.props.diagnosisId=20;await settle();await f.panel.openTask(2);response.resolve({id:1,status:'deleted',version:2});await deleting;assert.equal(f.panel.selectedId.value,2);assert.equal(f.panel.detail.value.id,2);f.dispose()
const g=setupPanel({followupAudioDelete:async()=>{throw new apiModule.FollowupAudioError('版本已变化')}});await settle();await g.panel.openTask(1);await g.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(g.panel.selectedId.value,1);assert.match(g.panel.errorMessage.value,/版本已变化/);g.dispose()
})
test('delete API has no automatic retry and empty results expose uncertainty reasons without tab switching', async () => {
const requests=[];const api=loadTs(path.join(root,'src/api/followupAudio.ts'),()=>({default:{post:async(config,options)=>{requests.push({config,options});return {code:1,data:{id:1,status:'deleted',version:2}}}}}))
await api.followupAudioDelete({id:1,version:1});assert.equal(requests[0].config.url,'/tcm.followupAudio/delete');assert.equal(requests[0].options.isOpenRetry,false)
const source=fs.readFileSync(path.join(componentDir,'FollowupAudioReview.vue'),'utf8');assert.match(source,/\(group === 'pending' \|\| !items.length\) && uncertainties\?\.length/)
const panelSource=fs.readFileSync(path.join(componentDir,'FollowupAudioPanel.vue'),'utf8');assert.match(panelSource,/未生成可写入信息/);assert.match(panelSource,/不删除或回滚病例/)
})
test('remote deletion revalidates missing detail and releases cached audio/editor', async () => {
let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioDetail:async id=>{if(deleted)throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE');return task(id)}})
await settle();await f.panel.openTask(1);await f.panel.loadAudio();assert.equal(f.panel.audioUrl.value,'blob:private-0')
deleted=true;await f.panel.refresh();await settle()
assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.equal(f.panel.audioUrl.value,'');assert.deepEqual(f.revoked,['blob:private-0']);assert.deepEqual(f.panel.reviewItems.value,[]);assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose()
})
test('list pagination omission alone never discards current unsaved edits', async () => {
let omit=false;const f=setupPanel({followupAudioLists:async()=>({items:omit?[]:[task()]})});await settle();await f.panel.openTask(1)
f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;omit=true;await f.panel.refresh()
assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9);assert.notEqual(f.panel.saveState.value,'conflict');f.dispose()
})
test('deleting another row resumes current task autosave without clearing its draft', async () => {
const f=setupPanel();await settle();await f.panel.openTask(1);f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9
await f.panel.deleteRecording(task(2,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9)
f.tick();await settle();assert.ok(f.calls.some(c=>c[0]==='save'&&c[1].id===1&&c[1].items[0].values.fasting_blood_sugar===6.9));f.dispose()
})
test('autosave denial for remotely deleted task clears local playback without another save', async () => {
let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioSaveDraft:async()=>{deleted=true;throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE')}})
await settle();await f.panel.openTask(1);await f.panel.loadAudio();f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;await f.panel.saveDraft(true);await settle()
assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.audioUrl.value,'');assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose()
})
// Execute the real editor script and its actual tab template, with deferred network I/O.
function setupAudioEntry(permissions = ['*']) {
const userStore = vue.reactive({ perms: [...permissions], userInfo: { id: 1 } })
const permissionModule = loadTs(path.join(root, 'src/utils/perm.ts'), () => ({ default: () => userStore }))
const requests = [], warnings = [], hooks = {}, exposed = {}
const filename = path.join(componentDir, '../edit.vue')
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
const script = compileScript(descriptor, { id: 'entry-runtime' })
const panelComponent = { name: 'FollowupAudioPanel' }
const module = execute(script.content, name => {
if (name === 'vue') return { ...vue, onBeforeUnmount: callback => { hooks.unmount = callback } }
if (name === '@/api/tcm') return { tcmDiagnosisDetail: async ({ id }) => ({ id: String(id), patient_id: String(id + 100), phone: '', id_card: '', remark: '原始备注' }) }
if (name === '@/api/app') return { getDictData: async () => ({}) }
if (name === '@/api/followupAudio') return { followupAudioCapabilities: (id, signal) => { const pending = deferred(); requests.push({ id, signal, ...pending }); return pending.promise } }
if (name === '@/utils/feedback') return { default: { msgWarning: message => warnings.push(message) } }
if (name === '@/utils/perm') return permissionModule
if (name === '@/stores/modules/user') return { default: () => userStore }
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
if (name === 'element-plus') return { ElMessage: { warning() {} } }
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
if (name.endsWith('FollowupAudioPanel.vue')) return { default: panelComponent }
if (name.endsWith('.vue')) return { default: {} }
throw new Error(`Unexpected entry import: ${name}`)
})
const tab = descriptor.template.content.match(/<el-tab-pane[^>]+name="followupAudio"[\s\S]*?<\/el-tab-pane>/)[0]
const compiled = compileTemplate({ source: tab, filename, id: 'entry-tab', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(compiled.errors, [])
const render = execute(compiled.code, () => ({ ...vue, resolveComponent: name => name })).render
const scope = vue.effectScope()
const editor = scope.run(() => module.default.setup({}, { expose: value => Object.assign(exposed, value), emit() {} }))
const dailyTab = descriptor.template.content.match(/<el-tab-pane\s+v-if="hasDailyRecordPermission"[\s\S]*?<\/el-tab-pane>/)[0]
const dailyCompiled = compileTemplate({ source: dailyTab, filename, id: 'daily-tab', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(dailyCompiled.errors, [])
const renderDaily = execute(dailyCompiled.code, () => ({ ...vue, resolveComponent: name => name })).render
const nodes = (renderTab = render) => {
const collected = []
const visit = node => {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) { node.forEach(visit); return }
collected.push(node)
visit(node.children)
if (node.children?.default) visit(node.children.default())
}
visit(renderTab({}, [], {}, vue.proxyRefs(editor)))
return collected
}
return { editor, exposed, requests, warnings, nodes, userStore, hasDailyTab: () => nodes(renderDaily).some(node => node.type === 'el-tab-pane'), hasTab: () => nodes().some(node => node.type === 'el-tab-pane'), hasPanel: () => nodes().some(node => node.type === panelComponent), dispose: () => { hooks.unmount(); scope.stop() } }
}
test('saved diagnosis keeps audio entry during pending/error; manual retry recovers without losing dirty input', async () => {
const f = setupAudioEntry()
try {
await f.editor.open('edit', 10)
assert.equal(f.hasTab(), true)
assert.equal(f.hasPanel(), false)
assert.equal(f.editor.followupCapabilitiesLoading.value, true)
assert.ok(f.nodes().some(node => node.props?.title === '正在检查回访录音状态…'))
f.requests[0].reject(new Error('network offline')); await settle()
assert.equal(f.hasTab(), true)
assert.equal(f.hasPanel(), false)
assert.equal(f.editor.followupCapabilitiesLoading.value, false)
assert.match(f.editor.followupCapabilitiesError.value, /加载失败/)
assert.equal(f.requests.length, 1, 'no automatic retry')
f.editor.formData.value.remark = '保留未保存输入'
assert.equal(await f.editor.beforeTabLeave('followupAudio', 'basic'), false)
const retryButton = f.nodes().find(node => node.type === 'el-button')
const retry = retryButton.props.onClick()
const duplicate = f.editor.loadFollowupCapabilities()
assert.equal(f.requests.length, 2, 'rapid retries share the in-flight context')
assert.equal(f.editor.followupCapabilitiesError.value, '')
assert.equal(f.hasPanel(), false)
f.requests[1].resolve(caps()); await retry; await duplicate
assert.equal(f.hasTab(), true)
assert.equal(f.hasPanel(), true)
assert.equal(f.editor.formData.value.remark, '保留未保存输入')
assert.equal(f.editor.basicDirty.value, true)
assert.equal(f.editor.canApplyFollowup(), false)
assert.equal(await f.editor.beforeTabLeave('followupAudio', 'basic'), false)
} finally { f.dispose() }
})
test('disabled/scope-denied and permission-denied capability responses preserve entry without a panel', async () => {
for (const response of [{ ...caps(), enabled: false }, { ...caps(), enabled: false, test_scope_allowed: false }, new Error('403 permission denied'), null]) {
const f = setupAudioEntry()
try {
await f.editor.open('edit', 10)
if (response instanceof Error) f.requests[0].reject(response)
else f.requests[0].resolve(response)
await settle()
assert.equal(f.hasTab(), true)
assert.equal(f.hasPanel(), false)
assert.equal(f.editor.followupCapabilitiesLoading.value, false)
const title = f.nodes().find(node => node.type === 'el-alert').props.title
assert.match(title, response instanceof Error || response === null ? /加载失败/ : /未启用|未开放/)
assert.equal(f.requests.length, 1)
} finally { f.dispose() }
}
})
test('old capability success/error/finally cannot affect a new patient or same-ID reopened context', async () => {
for (const oldFails of [false, true]) for (const id of [10, 20]) {
const f = setupAudioEntry()
try {
await f.editor.open('edit', 10)
const old = f.requests[0]
const opening = f.editor.open('edit', id)
assert.equal(old.signal.aborted, true, 'open resets before awaiting dictionaries')
assert.equal(f.editor.followupCapabilities.value, null)
await opening
assert.equal(f.requests.length, 2)
if (oldFails) old.reject(new Error('late old error'))
else old.resolve(caps())
await settle()
assert.equal(f.editor.followupCapabilities.value, null)
assert.equal(f.editor.followupCapabilitiesError.value, '')
assert.equal(f.editor.followupCapabilitiesLoading.value, true, 'old finally cannot end new loading')
assert.equal(f.hasPanel(), false)
f.requests[1].resolve({ ...caps(), enabled: false }); await settle()
assert.equal(f.editor.followupCapabilitiesDiagnosisId.value, id)
assert.equal(f.editor.followupCapabilities.value.enabled, false)
assert.equal(f.hasPanel(), false)
} finally { f.dispose() }
}
})
test('successful capabilities are immediately invalidated on patient switch; late retry completion stays isolated', async () => {
const f = setupAudioEntry()
try {
await f.editor.open('edit', 10); f.requests[0].resolve(caps()); await settle()
assert.equal(f.hasPanel(), true)
f.editor.formData.value.id = '20'
assert.equal(f.hasPanel(), false)
assert.equal(f.editor.followupCapabilities.value, null)
f.requests[1].reject(new Error('second patient error')); await settle()
const retry = f.editor.loadFollowupCapabilities()
await f.editor.openViewOnly(30)
assert.equal(f.requests[2].signal.aborted, true)
f.requests[3].resolve(caps()); await settle()
f.requests[2].reject(new Error('old retry error')); await retry
assert.equal(f.hasPanel(), true)
assert.equal(f.editor.followupCapabilitiesDiagnosisId.value, 30)
assert.equal(f.editor.followupCapabilitiesError.value, '')
assert.equal(f.editor.viewOnly.value, true)
} finally { f.dispose() }
})
test('close and unmount abort pending capabilities and prevent any late state resurrection', async () => {
for (const unmount of [false, true]) for (const fails of [false, true]) {
const f = setupAudioEntry()
try {
await f.editor.open('edit', 10)
if (unmount) f.dispose()
else await f.editor.handleClose()
assert.equal(f.requests[0].signal.aborted, true)
if (fails) f.requests[0].reject(new Error('late close failure'))
else f.requests[0].resolve(caps())
await settle()
assert.equal(f.editor.followupCapabilities.value, null)
assert.equal(f.editor.followupCapabilitiesLoading.value, false)
assert.equal(f.editor.followupCapabilitiesError.value, '')
assert.equal(f.hasPanel(), false)
if (!unmount) assert.equal(f.hasTab(), false)
} finally { f.dispose() }
}
})
const roleConflictTask = (extra={}) => stereoTask({items:[], channel_roles:'left_service',extraction_channel_roles:'left_service',extraction_revision:0,can_reextract:true,reextract_block_reason:'',role_check:{conflict:true,suggested_roles:'right_service',message:'合成角色冲突',evidence:[{channel:1,start_ms:0,end_ms:15000,text:'我是测试医院的医疗助理'}]},...extra})
test('role correction requires explicit confirmation and submits versioned roles without uploading or applying',async()=>{
let current=roleConflictTask();const confirmations=[]
const f=setupPanel({followupAudioDetail:async()=>current,followupAudioLists:async()=>({items:[current]}),followupAudioReextract:async p=>{f.calls.push(['reextract',p]);current=roleConflictTask({status:'queued',version:2,extraction_revision:1,extraction_channel_roles:p.channel_roles,channel_roles:p.channel_roles,can_reextract:false,role_check:{conflict:false,suggested_roles:'unconfirmed',message:'',evidence:[]}});return current}}, {}, {confirm:async(message)=>{confirmations.push(message)}})
await settle();await f.panel.openTask(1);assert.equal(f.panel.reextractTarget.value,'right_service');await f.panel.reextractWithRoles()
assert.deepEqual(f.calls.find(c=>c[0]==='reextract')[1],{id:1,version:1,channel_roles:'right_service'});assert.match(confirmations[0],/右客服/);assert.match(confirmations[0],/不重复语音识别/)
assert.equal(f.panel.detail.value.status,'queued');assert.equal(f.panel.detail.value.extraction_revision,1);assert.equal(f.calls.some(c=>['apply','create','session','chunk'].includes(c[0])),false);f.dispose()
})
test('role proposal never mutates manual roles or sends re-extraction until confirmed',async()=>{
const f=setupPanel({followupAudioDetail:async()=>roleConflictTask()}, {}, {confirm:async()=>{throw new Error('cancel')}});await settle();await f.panel.openTask(1)
assert.equal(f.panel.savedChannelRoles.value,'left_service');assert.equal(f.panel.channelRoles.value,'left_service');assert.equal(f.calls.some(c=>c[0]==='reextract'),false)
await f.panel.reextractWithRoles();assert.equal(f.calls.some(c=>c[0]==='reextract'),false);assert.equal(f.panel.detail.value.status,'review');assert.equal(f.panel.mutation.value,'');f.dispose()
})
test('re-extraction blocks dirty, readonly, unavailable, same-role and processing contexts',async()=>{
const f=setupPanel({followupAudioDetail:async()=>roleConflictTask()});await settle();await f.panel.openTask(1)
f.panel.reviewRef.value={hasUnsavedChanges:()=>true};await f.panel.reextractWithRoles();assert.equal(f.calls.some(c=>c[0]==='reextract'),false)
f.panel.reviewRef.value=null;f.props.viewOnly=true;await f.panel.reextractWithRoles();f.props.viewOnly=false
for(const patch of [{can_reextract:false},{status:'running'},{status:'applied'},{role_check:{conflict:false,suggested_roles:'left_service',evidence:[],message:''}}]){f.panel.installDetail(roleConflictTask(patch),false);await f.panel.reextractWithRoles()}
assert.equal(f.calls.some(c=>c[0]==='reextract'),false);f.dispose()
})
test('re-extraction confirmation and late results are fenced across patient changes',async()=>{
const answer=deferred();const f=setupPanel({followupAudioDetail:async()=>roleConflictTask()}, {}, {confirm:()=>answer.promise});await settle();await f.panel.openTask(1)
const pending=f.panel.reextractWithRoles();f.props.diagnosisId=20;await settle();answer.resolve();await pending;assert.equal(f.calls.some(c=>c[0]==='reextract'),false);f.dispose()
const response=deferred();const g=setupPanel({followupAudioDetail:async id=>roleConflictTask({id}),followupAudioReextract:async p=>{g.calls.push(['reextract',p]);return response.promise}});await settle();await g.panel.openTask(1)
const work=g.panel.reextractWithRoles();await settle();await g.panel.reextractWithRoles();assert.equal(g.calls.filter(c=>c[0]==='reextract').length,1);g.props.diagnosisId=30;await settle();await g.panel.openTask(2);response.resolve(roleConflictTask({status:'queued',version:2}));await work;assert.equal(g.panel.selectedId.value,2);assert.equal(g.panel.detail.value.id,2);g.dispose()
})
test('explicitly corrected review roles take priority over the advisory suggestion',async()=>{
const f=setupPanel({followupAudioDetail:async()=>roleConflictTask({channel_roles:'right_service',role_check:{conflict:false,suggested_roles:'unconfirmed',message:'',evidence:[]}})});await settle();await f.panel.openTask(1);assert.equal(f.panel.reextractTarget.value,'right_service');f.dispose()
const calls=[];const api=loadTs(path.join(root,'src/api/followupAudio.ts'),()=>({default:{post:async(config,options)=>{calls.push({config,options});return {code:1,data:roleConflictTask({status:'queued'})}}}}));await api.followupAudioReextract({id:1,version:1,channel_roles:'right_service'});assert.equal(calls[0].config.url,'/tcm.followupAudio/reextract');assert.equal(calls[0].options.isOpenRetry,false)
})
test('duplicate diagnosis suggestions are blocked clearly without losing distinct daily measurements',()=>{
const fields={diagnosis:[{key:'symptoms',label:'症状',type:'text'},{key:'remark',label:'备注',type:'text'}]}
const a=item({id:'a',kind:'diagnosis',values:{symptoms:'口干'}}),b=item({id:'b',kind:'diagnosis',values:{symptoms:'睡眠中断'}})
assert.match(state.reviewBatchIssue([a,b],fields),/症状存在多条选中建议/)
assert.equal(state.reviewBatchIssue([a,{...b,selected:false}],fields),'')
assert.equal(state.reviewBatchIssue([item(),item({id:'another',record_time:'12:00'})],fields),'')
assert.equal(a.values.symptoms,'口干');assert.equal(b.values.symptoms,'睡眠中断')
})
test('grouped audio permission independently gates saved diagnosis entry and capabilities requests', async () => {
for (const permissions of [[], ['tcm.diagnosis/dailyRecord'], ['tcm.diagnosis/detail', 'tcm.diagnosis/edit'], ['tcm.diagnosis/followupaudio']]) {
const f = setupAudioEntry(permissions)
try {
await f.editor.open('edit', 10)
assert.equal(f.hasTab(), false)
assert.equal(f.hasPanel(), false)
assert.equal(f.hasDailyTab(), permissions.includes('tcm.diagnosis/dailyRecord'))
await f.editor.loadFollowupCapabilities()
assert.equal(f.requests.length, 0, 'denied entry makes no capabilities request, including direct reload')
assert.equal(f.editor.followupCapabilitiesLoading.value, false)
assert.equal(f.editor.followupCapabilitiesError.value, '')
} finally { f.dispose() }
}
for (const permissions of [['tcm.diagnosis/followupAudio'], ['tcm.diagnosis/followupAudio', 'tcm.diagnosis/dailyRecord'], ['*']]) {
const f = setupAudioEntry(permissions)
try {
await f.editor.open('add')
assert.equal(f.hasTab(), false, 'unsaved diagnoses have no audio entry even with permission')
assert.equal(f.requests.length, 0)
await f.editor.open('edit', 10)
assert.equal(f.hasTab(), true)
assert.equal(f.hasDailyTab(), permissions.includes('*') || permissions.includes('tcm.diagnosis/dailyRecord'))
assert.equal(f.requests.length, 1)
f.requests[0].reject(new Error('transient network failure')); await settle()
assert.equal(f.hasTab(), true, 'granted entry never depends on capabilities success')
assert.equal(f.hasPanel(), false)
assert.match(f.editor.followupCapabilitiesError.value, /加载失败/)
assert.equal(f.requests.length, 1)
const retry = f.editor.loadFollowupCapabilities()
f.requests[1].resolve({ ...caps(), can_upload: false, can_apply: false, can_daily: false })
await retry
assert.equal(f.hasPanel(), true)
const panel = f.nodes().find(node => node.props?.capabilities)
assert.equal(panel.props.capabilities.can_apply, false, 'entry grant never overrides server write capability')
assert.equal(panel.props.capabilities.can_upload, false)
} finally { f.dispose() }
}
})
test('permission revocation aborts pending capabilities and grant refreshes current saved diagnosis only', async () => {
const f = setupAudioEntry(['tcm.diagnosis/followupAudio'])
try {
await f.editor.open('edit', 10)
f.editor.activeTab.value = 'followupAudio'
const old = f.requests[0]
f.userStore.perms = ['tcm.diagnosis/dailyRecord']
assert.equal(old.signal.aborted, true)
assert.equal(f.hasTab(), false)
assert.equal(f.hasDailyTab(), true)
assert.equal(f.editor.activeTab.value, 'basic')
old.resolve(caps()); await settle()
assert.equal(f.editor.followupCapabilities.value, null)
assert.equal(f.requests.length, 1)
f.userStore.perms.push('tcm.diagnosis/followupAudio')
assert.equal(f.hasTab(), true)
assert.equal(f.requests.length, 2)
f.requests[1].resolve(caps()); await settle()
assert.equal(f.hasPanel(), true)
f.userStore.perms = []
assert.equal(f.hasPanel(), false)
assert.equal(f.editor.followupCapabilities.value, null)
assert.equal(f.requests.length, 2)
} finally { f.dispose() }
})
@@ -0,0 +1,253 @@
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 root = path.resolve(__dirname, '..')
const apiPath = path.join(root, 'src/api/setting/prescription_ai_visibility.ts')
const pagePath = path.join(root, 'src/views/setting/prescription_ai_visibility/index.vue')
const { descriptor, errors } = parse(fs.readFileSync(pagePath, 'utf8'), { filename: pagePath })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'prescription-ai-visibility' })
function moduleFrom(source, dependencies) {
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
esModuleInterop: true
}
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
return module.exports
}
const makeApi = (request) =>
moduleFrom(fs.readFileSync(apiPath, 'utf8'), (name) => {
assert.equal(name, '@/utils/request')
return request
})
const tick = () => new Promise((resolve) => setImmediate(resolve))
const deferred = () => {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function makePage(overrides = {}) {
const saved = []
const messages = []
const calls = []
const api = {
getPrescriptionAiCapabilities: async () => {
calls.push('capabilities')
return { agreement_visible: false, can_manage_visibility: true }
},
getPrescriptionAiVisibilityConfig: async () => {
calls.push('config')
return { role_ids: [2, 90], admin_ids: [3, 91] }
},
getPrescriptionAiVisibilityOptions: async () => {
calls.push('options')
return {
roles: [{ id: 2, name: '医生' }],
admins: [{ id: 3, name: '张医生', account: 'zhang.doctor' }]
}
},
setPrescriptionAiVisibilityConfig: async (params) => {
saved.push(params)
return params
},
...overrides
}
const component = moduleFrom(script.content, (name) => {
if (name === 'vue') return vue
if (name === '@/api/setting/prescription_ai_visibility') return api
if (name === '@/utils/feedback') {
return { msgSuccess: (message) => messages.push(message) }
}
throw new Error(`Unexpected import: ${name}`)
}).default
const page = component.setup({}, { expose() {} })
return { page, api, saved, messages, calls }
}
test('API uses the dedicated routes and rejects malformed policy responses', async () => {
const calls = []
let response = { role_ids: [2], admin_ids: [3] }
const api = makeApi({
get: async (params) => {
calls.push(['get', params])
return response
},
post: async (params) => {
calls.push(['post', params])
return response
}
})
const data = await api.getPrescriptionAiVisibilityConfig()
data.role_ids.push(5)
assert.deepEqual(response.role_ids, [2])
await api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] })
await api.getPrescriptionAiVisibilityOptions()
await api.getPrescriptionAiCapabilities()
assert.deepEqual(calls, [
['get', { url: '/tcm.prescriptionAi/getVisibilityConfig' }],
[
'post',
{
url: '/tcm.prescriptionAi/setVisibilityConfig',
params: { role_ids: [], admin_ids: [] }
}
],
['get', { url: '/tcm.prescriptionAi/visibilityOptions' }],
['get', { url: '/tcm.prescriptionAi/capabilities' }]
])
for (response of [
null,
{},
{ role_ids: [], admin_ids: ['3'] },
{ role_ids: [-1], admin_ids: [] }
]) {
await assert.rejects(api.getPrescriptionAiVisibilityConfig(), /响应格式不正确/)
await assert.rejects(
api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] }),
/响应格式不正确/
)
}
})
test('initial loading is pending and cannot submit before the real policy is read', async () => {
const pending = deferred()
const { page, saved } = makePage({ getPrescriptionAiVisibilityConfig: () => pending.promise })
assert.equal(page.loading.value, true)
assert.equal(page.ready.value, false)
assert.equal(page.hasLoaded.value, false)
await page.handleSubmit()
assert.deepEqual(saved, [])
pending.reject(new Error('offline'))
await tick()
assert.equal(page.loading.value, false)
assert.equal(page.ready.value, false)
assert.match(page.loadError.value, /加载失败/)
await page.handleSubmit()
assert.deepEqual(saved, [])
})
test('a failed reload cannot apply a partial policy or overwrite saved selections', async () => {
const { page, api, saved } = makePage()
await tick()
api.getPrescriptionAiVisibilityConfig = async () => ({ role_ids: [], admin_ids: [] })
api.getPrescriptionAiVisibilityOptions = async () => {
throw new Error('options failed')
}
await page.loadConfig()
assert.deepEqual(page.formData.role_ids, [2, 90])
assert.deepEqual(page.formData.admin_ids, [3, 91])
assert.equal(page.hasLoaded.value, true)
assert.equal(page.ready.value, false)
await page.handleSubmit()
assert.deepEqual(saved, [])
api.getPrescriptionAiVisibilityOptions = async () => ({ roles: [], admins: [] })
await page.loadConfig()
assert.equal(page.ready.value, true)
assert.equal(page.loadError.value, '')
assert.deepEqual(page.formData.role_ids, [])
})
test('missing selected IDs stay visible and user labels support both name and account search', async () => {
const { page, saved } = makePage()
await tick()
assert.equal(page.canManage.value, true)
assert.equal(page.ready.value, true)
assert.deepEqual(
page.roleOptions.value.map((row) => row.id),
[2, 90]
)
assert.match(page.roleOptions.value[1].name, /#90.*当前不在可选列表/)
assert.deepEqual(
page.adminOptions.value.map((row) => row.id),
[3, 91]
)
assert.match(page.adminOptions.value[0].label, /张医生.*zhang\.doctor/)
assert.match(page.adminOptions.value[1].label, /#91.*当前不在可选列表/)
await page.handleSubmit()
assert.deepEqual(saved, [{ role_ids: [2, 90], admin_ids: [3, 91] }])
})
test('explicitly clearing both selectors saves the empty policy and applies normalized response', async () => {
const { page, saved, messages, api } = makePage()
await tick()
page.formData.role_ids = []
page.formData.admin_ids = []
await page.handleSubmit()
assert.deepEqual(saved, [{ role_ids: [], admin_ids: [] }])
assert.equal(messages.length, 1)
page.formData.role_ids = [90, 2, 90]
api.setPrescriptionAiVisibilityConfig = async () => ({ role_ids: [2, 90], admin_ids: [] })
await page.handleSubmit()
assert.deepEqual(page.formData.role_ids, [2, 90])
assert.equal(page.saving.value, false)
})
test('pending save blocks duplicate writes and reload; failed write keeps the user selection', async () => {
const pending = deferred()
const saved = []
const { page, calls } = makePage({
setPrescriptionAiVisibilityConfig: (params) => {
saved.push(params)
return pending.promise
}
})
await tick()
page.formData.role_ids = [90]
const first = page.handleSubmit()
assert.equal(page.saving.value, true)
await page.handleSubmit()
await page.loadConfig()
assert.equal(saved.length, 1)
assert.equal(calls.filter((name) => name === 'capabilities').length, 1)
pending.reject(new Error('offline'))
await first
assert.equal(page.saving.value, false)
assert.equal(page.ready.value, true)
assert.deepEqual(page.formData.role_ids, [90])
assert.deepEqual(page.formData.admin_ids, [3, 91])
assert.match(page.saveError.value, /当前选择已保留/)
})
test('management capability is independent from viewing and denies policy reads when absent', async () => {
const { page, calls, saved } = makePage({
getPrescriptionAiCapabilities: async () => ({
agreement_visible: true,
can_manage_visibility: false
})
})
await tick()
assert.deepEqual(calls, [])
assert.equal(page.canManage.value, false)
assert.equal(page.hasLoaded.value, false)
assert.equal(page.ready.value, false)
assert.match(page.loadError.value, /无权管理/)
await page.handleSubmit()
assert.deepEqual(saved, [])
})
test('actual settings template compiles with its script bindings', () => {
const compiled = compileTemplate({
source: descriptor.template.content,
filename: pagePath,
id: 'prescription-ai-visibility',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(compiled.errors, [])
})
@@ -0,0 +1,167 @@
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 selected = [
'patchUsageVisible', 'patchUsageSaving', 'patchUsageFormRef', 'patchUsageForm',
'patchUsageRules', 'openPatchUsageDialog', 'resetPatchUsageForm', 'submitPatchUsage'
]
for (const page of ['components/PrescriptionOrderDetailDrawer.vue', 'order_list_h5.vue']) {
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: page })
const template = compileTemplate({
source: descriptor.template.content, filename, id: page,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
// Run the real handlers; replace the network and the rest of the order page.
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
const declarations = new Map()
for (const node of ast.statements) {
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
if (ts.isVariableStatement(node)) {
for (const declaration of node.declarationList.declarations) {
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
}
}
}
const executed = page.startsWith('components/') ? [...selected, 'refresh'] : selected
for (const name of executed) assert.ok(declarations.has(name), `${page}: missing ${name}`)
const compiled = ts.transpileModule(executed.map(name => declarations.get(name)).join('\n'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
function instance({ hasAux = true, prescriptionType = '浓缩水丸', bagCount = 2, mainBagCount = 2, status = 2, fail = false } = {}) {
const calls = []
const events = []
const aux = { times_per_day: 3, usage_days: 10, dosage_bag_count: bagCount }
const detailPrescription = vue.ref({ prescription_type: prescriptionType, times_per_day: 3, usage_days: 10, dosage_bag_count: mainBagCount })
const detailAuxUsage = vue.ref(hasAux ? aux : null)
const detailData = vue.ref({
id: 42, prescription_id: 17, medication_days: 10, fulfillment_status: status,
gancao_reciperl_order_no: 'TEST-SUBMITTED', ej_pharmacy_order_no: 'TEST-EJ-SUBMITTED'
})
const refresh = async () => {
if (calls[0]?.dosage_bag_count) detailPrescription.value.dosage_bag_count = calls[0].dosage_bag_count
if (calls[0]?.aux_dosage_bag_count) detailAuxUsage.value.dosage_bag_count = calls[0].aux_dosage_bag_count
events.push('refresh')
return { ...detailData.value }
}
const globals = {
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, nextTick: vue.nextTick,
detailPrescription, detailAuxUsage, detailData, detailHasAuxHerbs: vue.ref(hasAux),
feedback: { msgWarning() {}, msgSuccess() {} },
prescriptionOrderPatchPrescriptionUsage: async payload => {
calls.push(payload)
if (fail) throw new Error('Save failed')
},
requestDetail: refresh, refreshCurrentPrescriptionOrderDetail: refresh,
props: { loadRelatedData: true },
emit: event => events.push(event),
fetchLogs: async () => {
events.push('logs:start')
await Promise.resolve()
events.push('logs:done')
},
getLists: () => events.push('lists')
}
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
state.patchUsageFormRef.value = { validate: async () => {}, clearValidate() {} }
return { state, calls, events, detailAuxUsage, detailPrescription }
}
test(`${page}: edits auxiliary bags and refreshes the persisted value without changing usage`, async () => {
const { state, calls, events, detailAuxUsage } = instance()
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 2)
state.patchUsageForm.aux_dosage_bag_count = 4
await state.submitPatchUsage()
assert.deepEqual(calls, [{
id: 42, times_per_day: 3, usage_days: 10, medication_days: 10,
dosage_bag_count: 2,
aux_times_per_day: 3, aux_usage_days: 10, aux_dosage_bag_count: 4
}])
assert.equal(state.patchUsageVisible.value, false)
assert.equal(detailAuxUsage.value.dosage_bag_count, 4)
assert.ok(events.includes('refresh'))
assert.ok(events.indexOf('logs:done') > events.indexOf('refresh'))
const completedEvent = page.startsWith('components/') ? 'detail-changed' : 'lists'
assert.ok(events.indexOf(completedEvent) > events.indexOf('logs:done'))
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 4)
})
test(`${page}: missing historical bag count defaults to one and resets on close`, () => {
const { state } = instance({ bagCount: null, mainBagCount: null })
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
state.patchUsageForm.dosage_bag_count = 4
state.patchUsageForm.aux_dosage_bag_count = 5
state.resetPatchUsageForm()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
})
test(`${page}: only water pills with an auxiliary formula submit or require auxiliary bags`, async () => {
for (const options of [{ hasAux: false }, { prescriptionType: '饮片' }, { prescriptionType: '颗粒' }]) {
const { state, calls } = instance(options)
state.openPatchUsageDialog()
state.patchUsageForm.aux_dosage_bag_count = 5
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count, undefined)
await state.submitPatchUsage()
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
assert.equal(Object.hasOwn(calls[0], 'dosage_bag_count'), !options.prescriptionType)
}
const { state } = instance()
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count[0].required, true)
assert.equal(state.patchUsageRules.value.dosage_bag_count[0].required, true)
})
test(`${page}: main and auxiliary bags can be edited independently in every submitted order status`, async () => {
for (let status = 1; status <= 12; status++) {
const { state, calls, detailPrescription, detailAuxUsage } = instance({ status })
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.dosage_bag_count, 2)
state.patchUsageForm.dosage_bag_count = 5
state.patchUsageForm.aux_dosage_bag_count = 1
await state.submitPatchUsage()
assert.equal(calls.length, 1, `status ${status}`)
assert.equal(calls[0].dosage_bag_count, 5)
assert.equal(calls[0].aux_dosage_bag_count, 1)
assert.equal(detailPrescription.value.dosage_bag_count, 5)
assert.equal(detailAuxUsage.value.dosage_bag_count, 1)
}
const { state, calls } = instance({ hasAux: false })
state.openPatchUsageDialog()
state.patchUsageForm.dosage_bag_count = 4
await state.submitPatchUsage()
assert.equal(calls[0].dosage_bag_count, 4)
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
})
test(`${page}: validation blocks requests and a rejected save retains the selected bags`, async () => {
const { state, calls, events } = instance({ fail: true })
state.openPatchUsageDialog()
state.patchUsageForm.aux_dosage_bag_count = 3
state.patchUsageForm.dosage_bag_count = 4
state.patchUsageFormRef.value.validate = async () => { throw new Error('Required') }
await state.submitPatchUsage()
assert.equal(calls.length, 0)
state.patchUsageFormRef.value.validate = async () => {}
await state.submitPatchUsage()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 3)
assert.equal(state.patchUsageForm.dosage_bag_count, 4)
assert.equal(state.patchUsageVisible.value, true)
assert.equal(state.patchUsageSaving.value, false)
assert.deepEqual(events, [])
})
}
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig(({ mode }) => {
return {
base: '/admin/',
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
server: {
port: 5555,
host: '0.0.0.0',
+6 -3
View File
@@ -89,7 +89,7 @@ def example_batch() -> dict[str, Any]:
"algorithm_version": "prescription-soft-dice-v1.1.0",
"prompt_version": "manual-prescription-required-candidate-v4",
}
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391,
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391, "agreement_visible": True,
"prescription_revision": 1, "status": "success", "validity": "current",
"comparison_type": "non_independent", "models": models, "coverage_status": "partial",
"created_at": "2026-09-10 15:29:00", "cutoff_at": "2026-09-10 15:29:00",
@@ -143,8 +143,11 @@ class PreviewRepository:
def __init__(self) -> None:
self.batch = example_batch()
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": True, "can_manage_visibility": False}
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
return {"enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
return {"agreement_visible": True, "enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
def history(self) -> list[dict[str, Any]]:
"""The current batch plus the earlier ones it supersedes, newest first."""
@@ -170,7 +173,7 @@ class PreviewRepository:
"algorithm_version": "prescription-soft-dice-v1.0.0"},
"openai": {"comparison": {"status": "not_comparable"}}}},
]
return [self.batch, *older]
return [{**row, "agreement_visible": True} for row in [self.batch, *older]]
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
return deepcopy(self.batch)
@@ -0,0 +1,111 @@
"""Capture agreement authorization states using synthetic data and no network."""
from __future__ import annotations
import os
from copy import deepcopy
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from render_issued_prescription_ai import PreviewRepository
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import issued_prescription_ai as ai
from doctor_workstation.ui.pages import prescriptions
from doctor_workstation.ui.theme import apply_theme
class VisibilityPreviewRepository(DemoDoctorRepository, PreviewRepository):
def __init__(self) -> None:
DemoDoctorRepository.__init__(self)
PreviewRepository.__init__(self)
self.visible = True
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": self.visible, "can_manage_visibility": True}
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
return {"enabled": True, "agreement_visible": self.visible, "items": [
{"prescription_id": value, "agreement_visible": self.visible, "status": "success",
"models": {"qwen": {"status": "success", "score": 68.5},
"openai": {"status": "success", "score": 55.2}}} for value in ids]}
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
rows = [{**row, "agreement_visible": self.visible} for row in self.history()]
return {"agreement_visible": self.visible, "enabled": True, "lists": deepcopy(rows), "count": len(rows)}
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
return {**deepcopy(self.batch), "agreement_visible": self.visible}
def main() -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
output = Path(__file__).resolve().parents[2] / "artifacts" / "prescription-ai-visibility"
output.mkdir(parents=True, exist_ok=True)
def immediate(function: Any, **callbacks: Any) -> None:
try:
result = function()
except Exception as error:
if callbacks.get("on_error"):
callbacks["on_error"](error)
else:
raise
else:
if callbacks.get("on_success"):
callbacks["on_success"](result)
finally:
if callbacks.get("on_finished"):
callbacks["on_finished"]()
ai.run_async = immediate
prescriptions.run_async = immediate
repository = VisibilityPreviewRepository()
def capture(widget: Any, filename: str) -> None:
for _ in range(4):
application.processEvents()
assert widget.grab().save(str(output / filename))
page = prescriptions.PrescriptionsPage(repository, ["*"], {"name": "演示医生", "root": 1})
page.resize(1366, 800)
page.show()
capture(page, "list-authorized.png")
repository.visible = False
page.refresh()
capture(page, "list-restricted.png")
page.close()
repository.visible = True
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556,
current_user={"name": "演示医生", "root": 1})
dialog.resize(1280, 860)
dialog.show()
capture(dialog, "report-authorized.png")
repository.visible = False
repository.batch["status"] = "partial"
repository.batch["models"]["openai"].update(
status="failed", report=None, candidate=None, comparison=None,
error_code="upstream_timeout", error_message="演示:模型连接超时,可重试。")
dialog.refresh()
dialog._goto("report")
capture(dialog, "report-restricted.png")
dialog._goto("overview")
capture(dialog, "review-restricted.png")
dialog._goto("per_herb")
capture(dialog, "candidate-restricted.png")
dialog.close()
statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
statistics.resize(1180, 860)
statistics.show()
capture(statistics, "statistics-restricted.png")
statistics.close()
print(output)
if __name__ == "__main__":
main()
@@ -235,6 +235,9 @@ class DoctorRepository(Protocol):
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
"""Read the server's current agreement visibility decision."""
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
"""Read cached dual-model states for up to 100 prescriptions."""
@@ -1611,6 +1614,9 @@ class RemoteDoctorRepository:
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
return dict(_require_mapping(payload, endpoint))
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return self._prescription_ai_request("capabilities", {})
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
raise ValueError("statuses requires at most 100 positive prescription IDs")
@@ -7,7 +7,7 @@ admin-table presentation cannot leak into another workstation page.
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from math import ceil
from typing import Any
@@ -75,12 +75,9 @@ TEXT = QColor("#1A1C1F")
SECONDARY = QColor("#606163")
PLACEHOLDER = QColor("#8E8F90")
_INVALID_INDEX = QModelIndex()
# The last two columns are the frozen right-hand pane. They were 120 + 410,
# which pinned 532 px of the table. Measured against what they actually hold:
# the widest video cell is "暂无通话" at 56 px, and the action cell - two inline
# buttons plus 更多, all two-character labels - reports a 145 px sizeHint. So
# 265 px of the action column was permanently blank on every row while the data
# columns to its left were horizontally scrolling.
# The last two columns form the compact frozen pane. Video status and ending
# time occupy separate lines; actions keep two inline links plus 更多, with
# the remaining actions in its menu so clinical columns retain more space.
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
_BLUE_WARNING = QColor("#A9691D")
@@ -1609,6 +1606,7 @@ class DiagnosisTableHost(QFrame):
self,
*,
action_policy: Mapping[str, bool] | None = None,
video_entry_allowed: Callable[[Any], bool] | None = None,
parent: QWidget | None = None,
tech_blue: bool = False,
) -> None:
@@ -1618,6 +1616,7 @@ class DiagnosisTableHost(QFrame):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(0)
self.action_policy = dict(action_policy or {})
self.video_entry_allowed = video_entry_allowed
self.force_open_prescription = False
if tech_blue:
self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10]
@@ -1633,6 +1632,9 @@ class DiagnosisTableHost(QFrame):
self._fixed_height_timer.timeout.connect(self._sync_fixed_height)
self.main.setObjectName("DiagnosisMainTable")
self.fixed.setObjectName("DiagnosisFixedTable")
# Index widgets supply their own margins. Inherited item padding would
# shrink a 92px video cell to 76px and clip its wrapped status text.
self.fixed.setStyleSheet("QTableView#DiagnosisFixedTable::item { padding: 0; }")
for view in (self.main, self.fixed):
view.setModel(self.model)
view.setSelectionModel(self.selection)
@@ -1786,11 +1788,11 @@ class DiagnosisTableHost(QFrame):
host.hovered_row.connect(self._set_hover_row)
layout = QHBoxLayout(host)
layout.setContentsMargins(3, 2, 3, 2)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
video_capable = self.action_policy.get("video_call", False)
entry_allowed = self.video_entry_allowed is None or self.video_entry_allowed(record)
call_state = video_call_state(record)
is_text = appointment_type_value(record) == "text"
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
if video_capable and entry_allowed and _appointment_active(record) and (is_text or call_state == "live"):
button = QToolButton(host)
button.setText("图文沟通" if is_text else "进入视频问诊")
button.setProperty("rowLink", "primary")
@@ -1803,10 +1805,17 @@ class DiagnosisTableHost(QFrame):
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignCenter)
else:
status = "—"
if video_capable:
if not entry_allowed:
# Match the admin's public status without turning an active
# doctor's room into a receiver entry for an unrelated user.
if _appointment_active(record) and can_appointment_video(appointment_type_value(record)):
status = {"live": "通话中", "pending_room": "接通中", "none": "—"}.get(
call_state, first_value(record, "video_call_hint.label", default="—")
)
elif video_capable:
if call_state == "pending_room":
status = "等待接通"
elif _appointment_active(record):
@@ -1816,10 +1825,17 @@ class DiagnosisTableHost(QFrame):
_appointment_status(record),
"未挂号" if not _has_appointment(record) else "—",
)
label = QLabel(display_text(status), host)
status_text = display_text(status)
label = QLabel(status_text.replace(" · ", "\n", 1), host)
label.setProperty("fixedMuted", True)
label.setTextFormat(Qt.TextFormat.PlainText)
label.setToolTip(status_text)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setWordWrap(True)
# Fill the cell instead of centering the label at its sizeHint:
# its wrapped height can grow when the available width changes.
label.setMinimumWidth(0)
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
layout.addWidget(label)
return host
@@ -11,6 +11,7 @@ from copy import deepcopy
from datetime import datetime
from html import escape
from threading import Event
from time import monotonic
from typing import Any
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
@@ -2338,8 +2339,14 @@ class _RichMessage(QTextBrowser):
self.document().contentsChanged.connect(self._fit)
def set_payload(self, raw: Any) -> None:
render_chat_payload(self, raw, role=self._role)
QTimer.singleShot(0, self._fit)
# Markdown parsing and block formatting both emit contentsChanged. Fit
# once after the complete update instead of laying out each mutation.
self._fitting = True
try:
render_chat_payload(self, raw, role=self._role)
finally:
self._fitting = False
self._fit()
def resizeEvent(self, event) -> None: # type: ignore[override]
super().resizeEvent(event)
@@ -3177,6 +3184,8 @@ class _AiStreamWorker(QRunnable):
if self.is_cancelled():
return
self.signals.event.emit(event)
if isinstance(event, Mapping) and event.get("event") == "done":
return
except Exception as error: # UI boundary: render transport/domain errors in place.
if not self.is_cancelled():
self.signals.error.emit(error)
@@ -3517,6 +3526,7 @@ class AiConsultDialog(QDialog):
self._detail: Mapping[str, Any] = {}
self._workspace_sections: dict[str, dict[str, Any]] = {}
self._generation = 0
self._session_closed = False
self._image_generation = 0
self._image_preview: QWidget | None = None
# 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。
@@ -3528,8 +3538,12 @@ class AiConsultDialog(QDialog):
self._stream_worker: _AiStreamWorker | None = None
self._stream_bubble: _ChatBubble | None = None
self._stream_text = ""
self._stream_prompt = ""
self._pending_chunks: list[str] = []
self._stream_meta: dict[str, Any] = {}
self._stream_started_at: float | None = None
self._stream_first_delta_at: float | None = None
self._stream_ended_at: float | None = None
self._patient_ai_context: str = ""
self._patient_ai_context_labels: list[str] = []
self._patient_ai_context_bubble: _ChatBubble | None = None
@@ -3546,6 +3560,9 @@ class AiConsultDialog(QDialog):
self._stream_timeout_timer = QTimer(self)
self._stream_timeout_timer.setSingleShot(True)
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
self._stream_elapsed_timer = QTimer(self)
self._stream_elapsed_timer.setInterval(1000)
self._stream_elapsed_timer.timeout.connect(self._update_stream_elapsed)
self.setObjectName("AiConsultDialog")
self.setWindowTitle("问诊详情")
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
@@ -3562,6 +3579,7 @@ class AiConsultDialog(QDialog):
self.setStyleSheet(AI_CONSULT_QSS)
mark_business_dialog(self, "AiConsultDialog")
self._build()
self.finished.connect(self._end_session)
def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override]
# QLineEdit emits returnPressed before the key reaches QDialog. The
@@ -3585,6 +3603,7 @@ class AiConsultDialog(QDialog):
source_title: str = "问诊列表",
) -> None:
self._cancel_stream()
self._session_closed = False
self.diagnosis_id = diagnosis_id
self.patient_id = _as_int(patient_id)
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
@@ -3792,7 +3811,7 @@ class AiConsultDialog(QDialog):
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
button.clicked.connect(
lambda _checked=False, text=prompt: self._ask(text)
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
)
chips.addWidget(button, 1)
more = QPushButton("更多")
@@ -3803,7 +3822,7 @@ class AiConsultDialog(QDialog):
for label, prompt in QUICK_PROMPTS[3:]:
action = more_menu.addAction(label)
action.triggered.connect(
lambda _checked=False, text=prompt: self._ask(text)
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
)
more.setMenu(more_menu)
chips.addWidget(more, 1)
@@ -3996,7 +4015,7 @@ class AiConsultDialog(QDialog):
for index, (kind, label, prompt) in enumerate(TOOL_ITEMS):
card = _ClickCard()
card.setObjectName("AiConsultToolCard")
card.clicked.connect(lambda text=prompt: self._ask(text))
card.clicked.connect(lambda text=prompt: self._ask(text, replace_active=True))
inner = QVBoxLayout(card)
inner.setContentsMargins(8, 10, 8, 10)
inner.setSpacing(6)
@@ -5976,11 +5995,14 @@ class AiConsultDialog(QDialog):
def _use_suggestion(self, question: str) -> None:
self.input.setText(question)
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}")
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}", replace_active=True)
def _ask(self, prompt: str) -> None:
def _ask(self, prompt: str, *, replace_active: bool = False) -> None:
text = prompt.strip()
if self._asking or not text:
if not text:
return
if self._asking and (not replace_active or text == self._stream_prompt):
show_toast(self, "AI 正在生成,请稍候或点击其他快捷工具切换。", "info")
return
if self.diagnosis_id <= 0:
show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning")
@@ -5997,8 +6019,12 @@ class AiConsultDialog(QDialog):
"warning",
)
return
self._cancel_stream()
self._cancel_stream(replaced=self._asking)
self._stream_started_at = monotonic()
self._stream_prompt = text
self._follow_chat = True
self.tabs.setCurrentIndex(0)
self._set_chat_filter("all")
self.input.clear()
self._append_bubble(
"doctor", text, time_text=datetime.now().strftime("%H:%M")
@@ -6019,6 +6045,8 @@ class AiConsultDialog(QDialog):
self._pending_chunks.clear()
self._stream_meta.clear()
self._asking = True
self._update_stream_elapsed()
self._stream_elapsed_timer.start()
self.send_button.setEnabled(False)
generation = self._generation
stream_generation = self._stream_generation
@@ -6047,26 +6075,65 @@ class AiConsultDialog(QDialog):
def _stream_is_current(self, generation: int, stream_generation: int) -> bool:
return generation == self._generation and stream_generation == self._stream_generation
def _update_stream_elapsed(self) -> None:
if self._stream_bubble is None:
return
model = display_text(
first_value(self._stream_meta, "model_label", "model_key"), ""
)
if self._stream_started_at is None:
self._stream_bubble.set_time_text(_join_meta(model, datetime.now().strftime("%H:%M")))
return
now = self._stream_ended_at if self._stream_ended_at is not None else monotonic()
elapsed = max(0.0, now - self._stream_started_at)
if self._stream_ended_at is None:
status = (
f"生成中 · 已用时 {int(elapsed)} 秒"
if self._stream_first_delta_at is not None else f"已等待 {int(elapsed)} 秒"
)
self._stream_bubble.set_time_text(_join_meta(model, status))
return
first_delta = (
f"首字 {max(0.0, self._stream_first_delta_at - self._stream_started_at):.1f} 秒"
if self._stream_first_delta_at is not None else ""
)
self._stream_bubble.set_time_text(
_join_meta(model, first_delta, f"耗时 {elapsed:.1f} 秒", datetime.now().strftime("%H:%M"))
)
def _stop_stream_clock(self) -> None:
if self._stream_ended_at is None:
self._stream_ended_at = monotonic()
self._stream_elapsed_timer.stop()
self._update_stream_elapsed()
def _stream_event(
self,
generation: int,
stream_generation: int,
event: Any,
) -> None:
if not self._stream_is_current(generation, stream_generation):
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
return
payload = dict(event) if isinstance(event, Mapping) else {}
kind = str(payload.get("event") or "").lower()
if kind == "start":
self._stream_meta.update(payload)
self._update_stream_elapsed()
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
if kind == "delta":
chunk = payload.get("text")
if isinstance(chunk, str) and chunk:
if self._stream_first_delta_at is None:
self._stream_first_delta_at = monotonic()
self._update_stream_elapsed()
self._pending_chunks.append(chunk)
if not self._flush_timer.isActive():
if not self._stream_text:
# The first received text replaces the waiting state now.
self._flush_stream_chunks()
elif not self._flush_timer.isActive():
self._flush_timer.start()
return
if kind != "done":
@@ -6074,6 +6141,7 @@ class AiConsultDialog(QDialog):
self._stream_meta.update(payload)
self._stream_completed = True
self._stream_timeout_timer.stop()
self._stop_stream_clock()
if not self._stream_text and not self._pending_chunks:
answer = first_value(payload, "answer", "content", default="")
if answer not in (None, ""):
@@ -6083,17 +6151,15 @@ class AiConsultDialog(QDialog):
if not self._stream_text:
self._stream_text = "未返回分析内容。"
self._stream_bubble.set_payload(self._stream_text)
model = display_text(
first_value(self._stream_meta, "model_label", "model_key"),
"",
)
self._stream_bubble.set_time_text(
_join_meta(model, datetime.now().strftime("%H:%M"))
)
self._stream_bubble.set_provenance(self._stream_meta)
self._stream_bubble.finalize_clinical_analysis()
# A terminal event completes the UI request even if transport cleanup
# has not returned yet. The next click must not depend on finished.
self._asking = False
self.send_button.setEnabled(True)
def _flush_stream_chunks(self) -> None:
self._flush_timer.stop()
if not self._pending_chunks or self._stream_bubble is None:
return
self._stream_text += "".join(self._pending_chunks)
@@ -6108,9 +6174,10 @@ class AiConsultDialog(QDialog):
stream_generation: int,
error: Exception,
) -> None:
if not self._stream_is_current(generation, stream_generation):
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
return
self._stream_timeout_timer.stop()
self._stop_stream_clock()
self._flush_stream_chunks()
message = friendly_error(error)
if self._stream_text:
@@ -6119,7 +6186,8 @@ class AiConsultDialog(QDialog):
self._stream_text = message
if self._stream_bubble is not None:
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
self._asking = False
self.send_button.setEnabled(True)
def _stream_finished(
self,
@@ -6130,6 +6198,8 @@ class AiConsultDialog(QDialog):
if not self._stream_is_current(generation, stream_generation):
return
self._stream_timeout_timer.stop()
self._flush_stream_chunks()
self._stop_stream_clock()
if self._stream_worker is worker:
self._stream_worker = None
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
@@ -6141,7 +6211,6 @@ class AiConsultDialog(QDialog):
):
self._stream_text = AI_STREAM_SILENT_TEXT
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
self._asking = False
self.send_button.setEnabled(True)
@@ -6151,34 +6220,53 @@ class AiConsultDialog(QDialog):
self._flush_stream_chunks()
bubble = self._stream_bubble
text = self._stream_text
self._stop_stream_clock()
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, *, replaced: bool = False) -> None:
if replaced and self._asking:
self._flush_stream_chunks()
self._stop_stream_clock()
if self._stream_bubble is not None:
notice = "已停止本次生成,已切换到新的请求。"
self._stream_bubble.set_payload(
f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice
)
self._stream_generation += 1
self._flush_timer.stop()
self._stream_timeout_timer.stop()
self._stream_elapsed_timer.stop()
if self._stream_worker is not None:
self._stream_worker.cancel()
self._stream_worker = None
self._stream_bubble = None
self._pending_chunks.clear()
self._stream_text = ""
self._stream_prompt = ""
self._stream_completed = False
self._stream_meta.clear()
self._stream_started_at = None
self._stream_first_delta_at = None
self._stream_ended_at = None
self._asking = False
self.send_button.setEnabled(True)
def closeEvent(self, event: Any) -> None: # type: ignore[override]
def _end_session(self) -> None:
if self._session_closed:
return
self._session_closed = True
self._generation += 1
self._image_generation += 1
_abort_pending_thumbnails(self)
self._close_image_preview()
self._cancel_stream()
def closeEvent(self, event: Any) -> None: # type: ignore[override]
self._end_session()
super().closeEvent(event)
def _open_full_record(self) -> None:
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from html import escape
from math import isfinite
from time import monotonic
@@ -402,6 +403,28 @@ def can_open_issued_ai(permissions: Any) -> bool:
return has_permission(permissions, "tcm.prescriptionAi/reports", default=False) and has_permission(permissions, "tcm.prescriptionAi/detail", default=False)
def agreement_safe_batch(value: Any, visible: bool) -> dict[str, Any]:
"""Discard restricted structured comparisons before retaining any UI cache."""
data = deepcopy(mapping(value))
if visible:
return data
fields = {"comparison", "score", "herb_score", "comparison_status", "comparison_reason_code", "comparison_type",
"algorithm_version", "normalization_version", "denominator", "dictionary_version", "dictionary_versions",
"baseline_eligible", "baseline_exclusion_reasons", "baseline_exclusions_json"}
for item in (data, *mapping(data.get("models")).values()):
if isinstance(item, dict):
for field in fields:
item.pop(field, None)
for model in mapping(data.get("models")).values():
if isinstance(model, dict) and not model.get("error_code"):
# Successful model reasons describe the comparison, while failed
# task reasons explain generation and remain useful with reports.
model.pop("reason", None)
data["agreement_visible"] = False
return data
def _html(value: Any, field: str = "") -> str:
"""Escape all stored/model content, including source URLs, before rendering."""
if isinstance(value, Mapping):
@@ -536,6 +559,8 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_frozen_seconds = 0
self._progress_stale = False
self._read_access_denied = False
self._agreement_visible = False
self._history_agreement_visible = False
self.setWindowTitle("诊断与药方 · AI 分析报告")
self.resize(1440, 940)
self.setMinimumSize(940, 640)
@@ -575,7 +600,7 @@ class IssuedPrescriptionAiDialog(QDialog):
identity_block.addWidget(self.breadcrumb)
title_row = QHBoxLayout()
title_row.setSpacing(10)
heading = QLabel("诊断与药方对照", top)
heading = self.heading = QLabel("诊断与药方对照", top)
heading.setObjectName("AiHeroTitle")
title_row.addWidget(heading)
self.chip_row = QHBoxLayout()
@@ -903,7 +928,7 @@ class IssuedPrescriptionAiDialog(QDialog):
footer_text = QVBoxLayout()
footer_text.setSpacing(4)
footer_text.addWidget(self.message)
note = QLabel(DISCLAIMER)
note = self.disclaimer = QLabel(DISCLAIMER)
note.setObjectName("AiFootnote")
note.setWordWrap(True)
footer_text.addWidget(note)
@@ -921,6 +946,7 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_timer.timeout.connect(self._render_progress)
self._sync_actions()
self._navigation_changed(self.tabs.currentIndex())
self._apply_agreement_visibility(self._agreement_visible)
REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
("differences", "只看不同段落"))
@@ -1063,6 +1089,8 @@ class IssuedPrescriptionAiDialog(QDialog):
return host
def _goto(self, key: str) -> None:
if not self._agreement_visible:
key = "candidate" if key == "per_herb" else "report" if key in {"comparison_raw", "history"} else key
page = self.tab_pages.get(key)
if page is not None:
self.tabs.setCurrentWidget(page)
@@ -1070,7 +1098,10 @@ class IssuedPrescriptionAiDialog(QDialog):
def _navigation_changed(self, index: int) -> None:
widget = self.tabs.widget(index)
current = next((key for key, page in self.tab_pages.items() if page is widget), "")
self.rail.set_current(current)
if not self._agreement_visible and current in {"per_herb", "comparison_raw", "history"}:
self._goto("report")
return
self.rail.set_current("per_herb" if current == "candidate" and not self._agreement_visible else current)
self._current_page = current
self._apply_density()
@@ -1101,6 +1132,36 @@ class IssuedPrescriptionAiDialog(QDialog):
if field in MODELS:
self.model_views[field]["report"].setFocus()
def _apply_agreement_visibility(self, visible: bool) -> None:
"""One server decision controls every comparison surface, without losing review."""
self._agreement_visible = visible
self.agreement.set_agreement_visible(visible)
self.agreement_note.setVisible(visible)
self.heading.setText("诊断与药方对照" if visible else "诊断与药方报告")
self.disclaimer.setText(DISCLAIMER if visible else "候选建议仅供医生复核。")
self.comparison_panel.set_agreement_visible(visible)
self.rail.names["overview"].setText("对比总览" if visible else "报告复核")
self.rail.names["per_herb"].setText("候选与逐味" if visible else "AI 候选处方")
self.nav_buttons["history"].setVisible(visible)
for key in ("differences", "consensus"):
self.rail.focus_values[key].parentWidget().setVisible(visible)
if not visible:
self._batch = agreement_safe_batch(self._batch, False) if self._batch else {}
self._history_scope = {key: agreement_safe_batch(row, False) for key, row in self._history_scope.items()}
self.history_page.set_history([])
self.candidates_page.set_batch({})
for views in self.model_views.values():
views["comparison"].clear()
self.comparison_panel.set_batch(self._batch)
self.rail.set_focus({})
self.rail.badges["overview"].clear()
self.rail.badges["per_herb"].clear()
self.rail.badges["overview"].hide()
self.rail.badges["per_herb"].hide()
if self._current_page in {"per_herb", "comparison_raw", "history"}:
self._goto("report")
@property
def save_review_button(self) -> QPushButton:
"""The review card's own save action for the model currently being annotated."""
@@ -1206,9 +1267,12 @@ class IssuedPrescriptionAiDialog(QDialog):
return
self._read_access_denied = False
data = mapping(result)
self._history_agreement_visible = data.get("agreement_visible") is True
self._apply_agreement_visibility(self._history_agreement_visible)
self._feature_enabled = data.get("enabled", True) is not False
rows = data.get("lists") or []
self.history_page.set_history([mapping(row) for row in rows])
rows = [agreement_safe_batch(row, self._history_agreement_visible and mapping(row).get("agreement_visible") is True)
for row in data.get("lists") or []]
self.history_page.set_history(rows if self._agreement_visible else [])
self.history.blockSignals(True)
self.history.clear()
self._history_scope = {}
@@ -1321,6 +1385,8 @@ class IssuedPrescriptionAiDialog(QDialog):
def _read_failed(self, error: Exception, prefix: str) -> None:
self._timer.stop()
self._history_agreement_visible = False
self._apply_agreement_visibility(False)
text = str(error).lower()
restricted = isinstance(error, (ValueError, AuthenticationExpiredError)) or getattr(error, "status_code", None) in {401, 403, 404} or any(
marker in text for marker in ("forbidden", "unauthorized", "permission", "access_revoked", "not found", "权限", "无权", "登录", "归属", "不存在")
@@ -1354,6 +1420,10 @@ class IssuedPrescriptionAiDialog(QDialog):
if int(batch.get("id") or 0) != batch_id or not identity_matches or (self.prescription_id and int(batch.get("prescription_id") or 0) != self.prescription_id):
self._detail_error(ValueError("报告归属与当前处方或诊单不一致"), generation)
return
visible = (self._history_agreement_visible and batch.get("agreement_visible") is True
and mapping(expected).get("agreement_visible") is True)
self._apply_agreement_visibility(visible)
batch = agreement_safe_batch(batch, visible)
first_load = not self._batch
self._batch = batch
self._read_access_denied = False
@@ -1368,7 +1438,7 @@ class IssuedPrescriptionAiDialog(QDialog):
self.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}")
self._set_chips([
(label(batch.get("validity")), "info" if current_batch(batch) else "warn"),
(label(batch.get("comparison_type")), "neutral"),
(label(batch.get("comparison_type")) if self._agreement_visible else "", "neutral"),
])
self.batch_summary.setText(f"{compact_state} {_reason(batch.get('error_message') or batch.get('error_code'))}\n资料截止:{format_record_time(batch.get('cutoff_at'))} · 批次建立:{format_record_time(batch.get('created_at'))}")
self._render_facts(batch)
@@ -1411,9 +1481,11 @@ class IssuedPrescriptionAiDialog(QDialog):
"risk_assessment": report.get("risk_assessment"),
}
_set_html(views["candidate"], _candidate_html(candidate_content))
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
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 = ""
if self._agreement_visible:
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
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"}})
_set_html(views["comparison"], comparison_html)
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)
@@ -1428,7 +1500,7 @@ class IssuedPrescriptionAiDialog(QDialog):
views["comment"].setPlainText(str(review.get("comment") or ""))
index = views["review_state"].findData(review.get("status"))
views["review_state"].setCurrentIndex(max(0, index))
self.candidates_page.set_batch(batch)
self.candidates_page.set_batch(batch if self._agreement_visible else {})
self.sources_page.set_batch(batch)
self.pipeline_page.set_batch(batch)
self._sync_failures(batch)
@@ -1488,6 +1560,12 @@ class IssuedPrescriptionAiDialog(QDialog):
"restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
"consensus": panel.consensus_count() if hasattr(panel, "consensus_count") else None,
})
if not self._agreement_visible:
for key in ("differences", "consensus"):
self.rail.focus_values[key].clear()
for key in ("overview", "per_herb"):
self.rail.badges[key].clear()
self.rail.badges[key].hide()
def _render_facts(self, batch: Any) -> None:
snapshot = mapping(mapping(batch).get("doctor_snapshot"))
@@ -1707,6 +1785,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
super().__init__(parent)
self.repository, self.permissions = repository, permissions
self._generation = 0
self._agreement_visible = False
self.setWindowTitle("医生处方 AI 一致度统计")
self.setStyleSheet(dialog_style() + rail_qss())
self.resize(1180, 860)
@@ -1742,6 +1821,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
self.report.document().setDefaultStyleSheet(_report_css())
self.report.setMinimumHeight(130)
root.addWidget(self.report, 1)
self.panel.hide()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
@@ -1750,9 +1830,19 @@ class PrescriptionAiStatisticsDialog(QDialog):
def hideEvent(self, event: Any) -> None:
self._generation += 1
self._clear_statistics()
super().hideEvent(event)
def _clear_statistics(self) -> None:
self._agreement_visible = False
self.panel.set_statistics({})
self.panel.hide()
self.report.clear()
def refresh(self) -> None:
self._generation += 1
generation = self._generation
self._clear_statistics()
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
self.summary.setText("没有查看医生统计的权限。")
return
@@ -1763,20 +1853,33 @@ class PrescriptionAiStatisticsDialog(QDialog):
if doctor and (not doctor.isdecimal() or int(doctor) <= 0):
self.summary.setText("请填写有效的医生编号。")
return
self._generation += 1
generation = self._generation
self.summary.setText("正在读取统计…")
self.report.clear()
self.summary.setText("正在核验一致度查看权限…")
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
if not callable(method):
self.summary.setText("当前账号无权查看一致度统计。")
return
date_from, date_to = self.date_from.date().toString("yyyy-MM-dd"), self.date_to.date().toString("yyyy-MM-dd")
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, int(doctor) if doctor else None), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
run_async(method, on_success=lambda result: self._capabilities_ready(result, generation, date_from, date_to, int(doctor) if doctor else None),
on_error=lambda error: self._error(error, generation))
def _capabilities_ready(self, result: Any, generation: int, date_from: str, date_to: str, doctor: int | None) -> None:
if generation != self._generation:
return
if mapping(result).get("agreement_visible") is not True or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
self._clear_statistics()
self.summary.setText("当前账号无权查看一致度统计。")
return
self._agreement_visible = True
self.summary.setText("正在读取统计…")
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, doctor), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
def _error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.summary.setText("统计暂不可用:" + _error_text(error))
self.report.clear()
self._clear_statistics()
def _ready(self, result: Any, generation: int) -> None:
if generation != self._generation:
if generation != self._generation or not self._agreement_visible:
return
data = mapping(result)
if data.get("enabled") is False:
@@ -1786,6 +1889,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
return
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
self.panel.set_statistics(data)
self.panel.show()
sections = []
for row in data.get("doctors") or []:
doctor = mapping(row)
@@ -190,6 +190,8 @@ class AgreementBar(QFrame):
layout = QHBoxLayout(self)
layout.setContentsMargins(22, 16, 22, 16)
layout.setSpacing(24)
self._agreement_visible = True
self._metric_chrome: list[QWidget] = []
self.blocks: dict[str, dict[str, Any]] = {}
for index, key in enumerate(("qwen", "openai")):
if index:
@@ -197,11 +199,14 @@ class AgreementBar(QFrame):
divider.setObjectName("AiFactDivider")
divider.setFixedWidth(1)
layout.addWidget(divider)
layout.addWidget(self._delta_block())
delta = self._delta_block()
layout.addWidget(delta)
self._metric_chrome.extend((divider, delta))
divider = QFrame(self)
divider.setObjectName("AiFactDivider")
divider.setFixedWidth(1)
layout.addWidget(divider)
self._metric_chrome.append(divider)
# Both heads line up at the top, so a model that failed does not slide its column down.
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
@@ -294,7 +299,7 @@ class AgreementBar(QFrame):
carrier.setFixedHeight(4)
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
carrier.setVisible(False)
self.blocks[key] = {"score": score, "coverage": coverage, "elapsed": elapsed, "track": track,
self.blocks[key] = {"score": score, "caption": caption, "coverage": coverage, "elapsed": elapsed, "track": track,
"status": status, "failure": failure, "failure_text": failure_text,
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
return holder
@@ -313,6 +318,30 @@ class AgreementBar(QFrame):
self.blocks[key]["retry_slot"].addWidget(button)
def set_agreement_visible(self, visible: bool) -> None:
"""Keep model states and retries available when the metric is restricted."""
self._agreement_visible = visible
for widget in self._metric_chrome:
widget.setVisible(visible)
for key, block in self.blocks.items():
for field in ("score", "caption", "track"):
block[field].setVisible(visible)
if visible:
block["carrier"].setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
if not visible:
block["score"].clear()
block["score"].setToolTip("")
block["track"].set_values(None, None, "")
block["track"].setAccessibleDescription("")
block["carrier"].setValue(0)
block["carrier"].setToolTip("")
block["carrier"].setAccessibleName("")
if not visible:
self.delta.clear()
self.delta_note.clear()
self.overlap.clear()
def set_failure(self, key: str, message: str, retryable: bool) -> None:
block = self.blocks[key]
block["failure_text"].setText(message)
@@ -351,7 +380,7 @@ class AgreementBar(QFrame):
elapsed: Mapping[str, str] | None = None) -> None:
data = _mapping(batch)
models = _mapping(data.get("models"))
scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")}
scores = {key: _score(_mapping(models.get(key))) if self._agreement_visible else None for key in ("qwen", "openai")}
for key in ("qwen", "openai"):
block = self.blocks[key]
value = scores[key]
@@ -364,6 +393,9 @@ class AgreementBar(QFrame):
spent = _mapping(elapsed).get(key, "")
block["elapsed"].setText(spent)
block["elapsed"].setVisible(bool(spent))
if not self._agreement_visible:
self.set_agreement_visible(False)
return
if scores["qwen"] is None or scores["openai"] is None:
self.delta.setText("—")
self.delta_note.setText("两个模型都可比后才给差值")
@@ -537,6 +537,9 @@ class DoseDiffPanel(QFrame):
item = self.body_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
extent = _axis_extent(deltas)
@@ -620,6 +623,9 @@ class ConclusionsPanel(QFrame):
entry = self.body_layout.takeAt(0)
widget = entry.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
for index, text in enumerate(items, start=1):
@@ -844,6 +850,9 @@ class RiskPanel(QFrame):
entry = self.body_layout.takeAt(0)
widget = entry.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
flagged = []
@@ -1015,6 +1024,9 @@ class ChecklistPanel(QFrame):
entry = self.body_layout.takeAt(0)
widget = entry.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
grouped: dict[str, tuple[str, str, int, str]] = {}
@@ -1132,6 +1144,7 @@ class PrescriptionReviewWorkspace(QWidget):
self._batch: dict[str, Any] = {}
self.rows: list[ReviewDoseRow] = []
self._review_widget: QWidget | None = None
self._agreement_visible = True
self._summary = "选择报告后查看诊断与药方"
self.setStyleSheet(f"""
QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
@@ -1150,7 +1163,7 @@ class PrescriptionReviewWorkspace(QWidget):
page = QWidget(self.page_scroll)
self.page_scroll.setWidget(page)
outer.addWidget(self.page_scroll)
root = QVBoxLayout(page)
root = self._workspace_layout = QVBoxLayout(page)
root.setContentsMargins(0, 0, 6, 0)
root.setSpacing(14)
@@ -1201,6 +1214,10 @@ class PrescriptionReviewWorkspace(QWidget):
layout.takeAt(0)
for index in range(layout.columnCount()):
layout.setColumnStretch(index, 0)
if not self._agreement_visible:
self.first.addWidget(self.checklist, 0, 0)
self.first.setColumnStretch(0, 1)
return
cards = (self.conclusions, self.attribution, self.checklist)
if columns == 1:
for row, card in enumerate(cards):
@@ -1289,7 +1306,31 @@ class PrescriptionReviewWorkspace(QWidget):
self._batch = deepcopy(data)
self._render()
def set_agreement_visible(self, visible: bool) -> None:
"""Use the existing checklist as a report-only review workspace."""
if visible == self._agreement_visible:
return
self._agreement_visible = visible
for widget in (self.conclusions, self.attribution, self.doses, self.risks, self.links["comparison"]):
widget.setVisible(visible)
self._workspace_layout.setStretch(0, 5 if visible else 1)
self._workspace_layout.setStretch(1, 4 if visible else 0)
self._columns = 0
self._reflow(1 if self.width() < self.STACK_BELOW else 3)
self._render()
def _render(self) -> None:
if not self._agreement_visible:
self.rows = []
self.doses.set_rows([])
self.risks.set_rows([])
self.attribution.apply({})
self.conclusions.set_items([])
self.checklist.set_items(checklist_items(self._batch))
self._summary = "请结合报告与资料缺口记录复核意见。"
self.summary_changed.emit(self._summary)
return
self.rows, _states = review_rows(self._batch)
deltas = dose_deltas(self.rows)
self.doses.set_rows(deltas)
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ...core import UserProfile
from ...core.appointment_modes import appointment_type_value, can_appointment_video
from .. import icons
from ..consultations_style import consultations_stylesheet
@@ -1493,6 +1494,7 @@ class ConsultationsPage(QWidget):
table_wrap_layout.setSpacing(0)
self.table_host = DiagnosisTableHost(
tech_blue=True,
video_entry_allowed=self._can_enter_list_call,
action_policy={
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
@@ -3053,8 +3055,11 @@ class ConsultationsPage(QWidget):
_as_int(payload.get(key), 0) > 0
for key in ("appointment_id", "patient_id", "diagnosis_id")
)
entry_allowed = has_record and self._can_enter_list_call(record)
self.video_button.setVisible(entry_allowed)
self.call_toolbar_button.setVisible(entry_allowed)
self.video_button.setEnabled(
has_record
entry_allowed
and is_video_available(record)
and (appointment_type_value(record) == "text" or video_call_is_live(record))
and valid_ids
@@ -3496,6 +3501,28 @@ class ConsultationsPage(QWidget):
self._checked_changed(len(self.table_host.selected_records()))
self._selection_changed()
def _can_enter_list_call(self, record: Any) -> bool:
if not self._native_video_capable or record is None:
return False
# Text appointments retain their IM entry; this restriction concerns
# receiving an existing video call from the diagnosis list.
if appointment_type_value(record) == "text":
return True
if not can_appointment_video(appointment_type_value(record)):
return False
roles = UserProfile.from_dict({
"role_ids": first_value(self.current_user, "role_ids", "role_id", "roles", default=[])
}).role_ids
if 1 in roles:
return False
user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0))
assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0))
return bool(
user_id > 0
and assistant_id == user_id
and _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall")
)
def _request_video(self) -> None:
record = self.table.current_data()
if not self._native_video_capable:
@@ -3503,6 +3530,9 @@ class ConsultationsPage(QWidget):
return
if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return
if not self._can_enter_list_call(record):
self.banner.show_message("仅本诊单指派且具有旁观权限的医助可进入视频会话。", "warning")
return
if appointment_type_value(record) != "text" and not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
@@ -52,6 +52,7 @@ from PySide6.QtWidgets import (
from .. import icons, motion
from ..dialogs.issued_prescription_ai import (
PrescriptionAiStatisticsDialog,
agreement_safe_batch,
agreement_text,
batch_running,
can_open_issued_ai,
@@ -1007,6 +1008,10 @@ class PrescriptionsPage(QWidget):
self._mutation_pending = False
self._ai_statuses: dict[int, dict[str, Any]] = {}
self._ai_enabled = False
self._agreement_visible = False
self._capability_agreement_visible = False
self._ai_capability_token = 0
self._ai_capability_pending = False
self._ai_request_token = 0
self._ai_pending = False
self._ai_timer = QTimer(self)
@@ -1048,7 +1053,7 @@ class PrescriptionsPage(QWidget):
self.orders_button.clicked.connect(lambda: self._open_orders())
header.add_action(self.orders_button)
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
self.ai_statistics_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/statistics", default=False) and callable(getattr(repository, "prescription_ai_statistics", None)))
self.ai_statistics_button.hide()
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
header.add_action(self.ai_statistics_button)
self.add_button = QPushButton("新增处方", header)
@@ -1437,6 +1442,7 @@ class PrescriptionsPage(QWidget):
self._refresh_pending = False
self._generation += 1
generation = self._generation
self._load_ai_capabilities()
page_size = self._page_size
start_time, end_time = self._date_filters()
filters: dict[str, Any] = {
@@ -1656,6 +1662,7 @@ class PrescriptionsPage(QWidget):
return
method = getattr(self.repository, "list_prescription_ai_statuses", None)
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
self._set_ai_columns(False)
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
return
if not callable(method):
@@ -1687,8 +1694,8 @@ class PrescriptionsPage(QWidget):
def _set_ai_columns(self, enabled: bool) -> None:
changed = enabled != self._ai_enabled
self._ai_enabled = enabled
for column in (11, 12):
self.table.setColumnHidden(column, not enabled)
self.table.setColumnHidden(11, not enabled)
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
if enabled:
header = self.table.horizontalHeader()
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
@@ -1699,10 +1706,54 @@ class PrescriptionsPage(QWidget):
if changed:
self._decorate_rows()
def _set_agreement_visible(self, visible: bool) -> None:
self._agreement_visible = visible
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
self.ai_statistics_button.setVisible(
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
if not visible:
self._ai_statuses = {key: agreement_safe_batch(row, False) for key, row in self._ai_statuses.items()}
for index in range(self.table.rowCount()):
item = self.table.item(index, 12)
if item is not None:
item.setText("")
item.setToolTip("")
item.setData(Qt.ItemDataRole.AccessibleTextRole, "")
def _load_ai_capabilities(self) -> None:
self._ai_capability_token += 1
token, generation = self._ai_capability_token, self._generation
self._ai_request_token += 1
self._ai_pending = False
self._capability_agreement_visible = False
self._set_agreement_visible(False)
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
self._ai_capability_pending = callable(method)
if not callable(method):
return
run_async(method,
on_success=lambda result: self._ai_capabilities_ready(result, token, generation),
on_error=lambda _error: self._ai_capabilities_ready({}, token, generation))
def _ai_capabilities_ready(self, result: Any, token: int, generation: int) -> None:
if token != self._ai_capability_token or generation != self._generation:
return
self._ai_capability_pending = False
self._capability_agreement_visible = _row_mapping(result).get("agreement_visible") is True
self._set_agreement_visible(self._capability_agreement_visible)
if self._capability_agreement_visible:
# A previous deny discarded scores, including completed cached batches.
# Fetch them again after a fresh grant rather than displaying that cache.
self._ai_statuses.clear()
if self.isVisible() and not self._loading:
self._load_ai_statuses()
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
return
data = _row_mapping(result)
self._set_agreement_visible(self._capability_agreement_visible and data.get("agreement_visible") is True)
self._set_ai_columns(data.get("enabled") is True)
if not self._ai_enabled:
self._ai_timer.stop()
@@ -1713,15 +1764,17 @@ class PrescriptionsPage(QWidget):
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
return
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告。"
+ ("可查看药味与剂量一致度。" if self._agreement_visible else ""))
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
+ ("可查看逐味对照。" if self._agreement_visible else ""))
for value in ids:
self._ai_statuses[value] = {}
for batch in data.get("items") or []:
value = _int(get_value(batch, "prescription_id"), 0)
if value in ids:
self._ai_statuses[value] = _row_mapping(batch)
self._ai_statuses[value] = agreement_safe_batch(batch, self._agreement_visible and _row_mapping(batch).get("agreement_visible") is True)
self._render_ai_statuses()
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
self._ai_timer.start()
@@ -1741,13 +1794,13 @@ class PrescriptionsPage(QWidget):
if batch is None and not blank:
continue
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
agreement = "" if blank else agreement_text(batch)
agreement = "" if blank or not self._agreement_visible else agreement_text(batch)
for column, text in ((11, state), (12, agreement)):
item = self.table.item(index, column)
if item.text() != text:
item.setText(text)
changed = True
item.setToolTip("" if blank else status_tooltip(batch))
item.setToolTip("" if blank or column == 12 and not self._agreement_visible else status_tooltip(batch))
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
if changed:
self.table.resizeRowsToContents()
@@ -1757,6 +1810,7 @@ class PrescriptionsPage(QWidget):
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
if token == self._ai_request_token and generation == self._generation:
self._ai_timer.stop()
self._set_agreement_visible(False)
self._set_ai_columns(False)
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
self.ai_status_notice.setToolTip(friendly_error(error))
@@ -1781,13 +1835,13 @@ class PrescriptionsPage(QWidget):
if _is_blank_prescription(row):
return
menu = QMenu(self.table)
action = menu.addAction("AI 报告 / 逐味对照")
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
self._ai_context_menu = menu
menu.popup(self.table.viewport().mapToGlobal(position))
def _open_ai_statistics(self) -> None:
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
if not self._agreement_visible or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
return
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
@@ -2207,12 +2261,14 @@ class PrescriptionsPage(QWidget):
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
elif not self._loading:
self._load_ai_statuses()
self._load_ai_capabilities()
def hideEvent(self, event: Any) -> None:
self._ai_timer.stop()
self._ai_scroll_timer.stop()
self._ai_request_token += 1
self._ai_capability_token += 1
self._ai_capability_pending = False
self._ai_pending = False
super().hideEvent(event)
@@ -321,6 +321,11 @@ QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel#ReceptionCurrentAssistant {
color: #273244;
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel#StatusBadge {
font-size: 13px;
font-weight: 400;
@@ -4394,8 +4399,17 @@ class ReceptionPage(QWidget):
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(14)
actions.addStretch(1)
patient_head.addLayout(actions)
patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom)
action_group = QVBoxLayout()
action_group.setContentsMargins(0, 0, 0, 0)
action_group.setSpacing(6)
self.current_assistant_label = QLabel("当前医助:—", hero)
self.current_assistant_label.setObjectName("ReceptionCurrentAssistant")
self.current_assistant_label.setTextFormat(Qt.TextFormat.PlainText)
self.current_assistant_label.setWordWrap(True)
action_group.addWidget(self.current_assistant_label)
action_group.addLayout(actions)
patient_head.addLayout(action_group)
patient_head.setAlignment(action_group, Qt.AlignmentFlag.AlignBottom)
self.complete_button = _ReceptionCompleteButton(hero)
self.complete_button.clicked.connect(self._complete_appointment)
@@ -7885,9 +7899,14 @@ class ReceptionPage(QWidget):
)
)
)
self.appointment_labels["assistant"].setText(
display_text(first_value(appointment, "assistant_name"))
)
# reception normalises these fields from the diagnosis's current
# assistant, matching the recipient of the notification action.
assistant_name = display_text(first_value(appointment, "assistant_name"), "")
assistant_id = _as_int(first_value(appointment, "assistant_id"), None)
missing_name = "姓名暂缺" if assistant_id else "未分配" if assistant_id == 0 else "—"
self.current_assistant_label.setText(f"当前医助:{assistant_name or missing_name}")
self.current_assistant_label.setToolTip(self.current_assistant_label.text())
self.appointment_labels["assistant"].setText(assistant_name or "—")
self.appointment_labels["type"].setText(
appointment_type_description(appointment_type_value(appointment))
)
@@ -8821,6 +8840,8 @@ class ReceptionPage(QWidget):
)
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
self.patient_meta_label.setToolTip("")
self.current_assistant_label.setText("当前医助:正在加载…" if seed else "当前医助:—")
self.current_assistant_label.setToolTip("")
self.detail_status.set_status("—", "neutral")
for label in getattr(self, "appointment_labels", {}).values():
label.setText("—")
@@ -8886,6 +8907,8 @@ class ReceptionPage(QWidget):
return
self.patient_meta_label.setText(message)
self.patient_meta_label.setToolTip("")
self.current_assistant_label.setText("当前医助:—")
self.current_assistant_label.setToolTip("")
self._set_case_placeholder(message)
self.prescription_hint.setText("处方:患者详情加载失败")
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
+355 -2
View File
@@ -2,13 +2,16 @@ from __future__ import annotations
import json
import os
from threading import Event
from time import monotonic
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtCore import QPoint, Qt, QThread
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs.ai_consult import (
AiConsultDialog,
@@ -1577,6 +1580,356 @@ def test_answered_stream_replaces_the_pending_placeholder(
dialog.close()
def test_stream_clock_covers_preparation_first_text_and_completion(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
now = [100.0]
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
dialog._ask("请生成问诊小结")
bubble = dialog._stream_bubble
assert bubble is not None
generation, stream_generation = dialog._generation, dialog._stream_generation
assert "已等待 0 秒" in bubble.stamp.text()
assert dialog._stream_elapsed_timer.isActive()
now[0] = 103.0
dialog._stream_elapsed_timer.timeout.emit()
assert "已等待 3 秒" in bubble.stamp.text()
dialog._stream_event(generation, stream_generation, {"event": "start"})
assert "已等待 3 秒" in bubble.stamp.text()
now[0] = 104.2
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"})
# No timer flush or done event is needed to display the first text.
assert bubble._raw_payload == "问诊小结"
assert "生成中" in bubble.stamp.text()
assert "已用时 4 秒" in bubble.stamp.text()
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"})
assert dialog._flush_timer.isActive()
now[0] = 108.7
dialog._stream_event(
generation, stream_generation, {"event": "done", "model_label": "千问"}
)
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
assert "千问" in bubble.stamp.text()
assert "首字 4.2 秒" in bubble.stamp.text()
assert "耗时 8.7 秒" in bubble.stamp.text()
assert not dialog._stream_elapsed_timer.isActive()
assert not dialog._flush_timer.isActive()
stamp = bubble.stamp.text()
now[0] = 130.0
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
dialog._update_stream_elapsed()
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"})
assert bubble.stamp.text() == stamp
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
assert dialog.send_button.isEnabled()
dialog.close()
@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"])
def test_stream_clock_stops_on_every_exit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
ending: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
now = [100.0]
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
dialog._ask("请总结病情")
bubble = dialog._stream_bubble
assert bubble is not None
now[0] = 112.4
generation, stream_generation = dialog._generation, dialog._stream_generation
worker = dialog._stream_worker
if ending == "error":
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
dialog._stream_finished(generation, stream_generation, worker)
elif ending == "timeout":
dialog._stream_timed_out()
elif ending == "silent":
dialog._stream_finished(generation, stream_generation, worker)
elif ending == "switch":
dialog.open_for(diagnosis_id=502, patient_id=302)
else:
dialog.close()
assert not dialog._stream_elapsed_timer.isActive()
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._asking
assert dialog.send_button.isEnabled()
if ending in {"error", "timeout", "silent"}:
assert "耗时 12.4 秒" in bubble.stamp.text()
stamp = bubble.stamp.text()
now[0] = 140.0
dialog._update_stream_elapsed()
assert bubble.stamp.text() == stamp
if ending == "switch":
dialog._ask("新患者的问题")
assert dialog._stream_bubble is not None
assert "已等待 0 秒" in dialog._stream_bubble.stamp.text()
assert dialog._stream_first_delta_at is None
dialog.close()
def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Exercise HTTP parsing, repository, real worker and Qt delivery together."""
release = Event()
waiting_for_end = Event()
class DelayedStream(httpx.SyncByteStream):
def __iter__(self):
first = (
'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n'
'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n'
).encode()
# A network chunk may end partway through a Chinese character.
split = first.index("先".encode()) + 1
yield first[:split]
yield first[split:]
waiting_for_end.set()
if not release.wait(5):
raise AssertionError("UI did not receive the first streamed text")
yield (
'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n'
'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n'
).encode()
client = ApiClient(
"https://example.test",
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, stream=DelayedStream()
)
),
)
dialog = AiConsultDialog(RemoteDoctorRepository(client))
dialog.diagnosis_id = 501
dialog._context_notice_shown = True
delivered_on = []
original_event = dialog._stream_event
def record_event(*args: Any) -> None:
delivered_on.append(QThread.currentThread())
original_event(*args)
monkeypatch.setattr(dialog, "_stream_event", record_event)
dialog.show()
try:
dialog._ask("请生成问诊小结")
deadline = monotonic() + 3
while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline:
QTest.qWait(10)
bubble = dialog._stream_bubble
assert bubble is not None and bubble.body is not None
assert waiting_for_end.is_set()
assert bubble.body.toPlainText() == "先收到的内容"
assert not dialog._stream_completed
assert dialog._asking
assert "生成中" in bubble.stamp.text()
release.set()
deadline = monotonic() + 3
while dialog._asking and monotonic() < deadline:
QTest.qWait(10)
assert dialog._stream_completed
assert not dialog._asking
assert bubble.body.toPlainText() == "先收到的内容,然后完成。"
assert delivered_on and all(thread == application.thread() for thread in delivered_on)
assert not dialog._stream_elapsed_timer.isActive()
finally:
release.set()
dialog.close()
client.close()
@pytest.mark.parametrize("terminal", ["done", "error"])
def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
terminal: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6
previous = []
for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True):
# Exercise the text surface the doctor clicks, including event propagation.
target = next(child for child in card.findChildren(QLabel) if child.text() == label)
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
worker = dialog._stream_worker
assert worker is not None and worker.prompt == prompt
assert dialog._asking
generation, stream_generation = dialog._generation, dialog._stream_generation
for old_generation, old_stream_generation, old_worker in previous:
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
assert dialog._stream_worker is worker
assert dialog._asking
previous.append((generation, stream_generation, worker))
if terminal == "done":
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"})
else:
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
# The transport may take longer to close; it must not keep the UI locked.
assert not dialog._asking
assert dialog.send_button.isEnabled()
dialog.close()
def test_clicking_another_quick_tool_switches_the_active_request(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
notices = []
monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1]))
dialog.show()
application.processEvents()
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton)
old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble
old_generation, old_stream_generation = dialog._generation, dialog._stream_generation
assert old_worker is not None and old_bubble is not None
dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"})
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
worker, bubble = dialog._stream_worker, dialog._stream_bubble
assert worker is not None and worker is not old_worker
assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2]
assert old_worker.is_cancelled()
assert "已收到的分析" in old_bubble._raw_payload
assert "已停止" in old_bubble._raw_payload
assert bubble is not None and bubble is not old_bubble
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
assert "已等待 0 秒" in bubble.stamp.text()
assert dialog._asking
dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"})
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误"))
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
assert dialog._stream_worker is worker
assert dialog._asking
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
# Repeated clicks on the same running tool do not submit duplicate requests.
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
assert dialog._stream_worker is worker
assert not worker.is_cancelled()
assert notices and "正在生成" in notices[-1]
dialog.close()
def test_stream_worker_does_not_wait_for_another_event_after_done() -> None:
continued_after_done = []
class DoneThenIdleRepository:
def stream_diagnosis_ai(self, *args: Any, **kwargs: Any):
yield {"event": "done", "answer": "已完成"}
continued_after_done.append(True)
raise AssertionError("the terminal event must end iteration")
worker = ai_consult_module._AiStreamWorker(
DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom"
)
events, errors, finished = [], [], []
worker.signals.event.connect(events.append)
worker.signals.error.connect(errors.append)
worker.signals.finished.connect(lambda: finished.append(True))
worker.run()
assert [event["event"] for event in events] == ["done"]
assert continued_after_done == []
assert errors == []
assert finished == [True]
@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"])
def test_quick_actions_switch_requests_and_reveal_the_chat(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
entry: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("之前的请求")
old_worker = dialog._stream_worker
assert old_worker is not None
dialog.tabs.setCurrentIndex(1)
dialog._set_chat_filter("doctor")
if entry == "tool":
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0]
prompt = ai_consult_module.TOOL_ITEMS[0][2]
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
elif entry == "chip":
target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情")
prompt = ai_consult_module.QUICK_PROMPTS[0][1]
target.click()
elif entry == "menu":
more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多")
prompt = ai_consult_module.QUICK_PROMPTS[3][1]
more.menu().actions()[0].trigger()
else:
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0]
prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}"
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
assert old_worker.is_cancelled()
assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt
assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0
assert dialog._chat_filter == "all"
assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible()
dialog.close()
@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"])
def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
exit_action: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("请分析病情")
worker = dialog._stream_worker
assert worker is not None
generation, stream_generation = dialog._generation, dialog._stream_generation
if exit_action == "back":
next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click()
elif exit_action == "side":
dialog.findChild(QPushButton, "AiConsultSideClose").click()
elif exit_action == "escape":
QTest.keyClick(dialog, Qt.Key.Key_Escape)
else:
getattr(dialog, exit_action)()
assert not dialog.isVisible()
assert worker.is_cancelled()
assert not dialog._asking
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._stream_elapsed_timer.isActive()
closed_generation = dialog._generation
assert closed_generation > generation
dialog.close()
assert dialog._generation == closed_generation
dialog.open_for(diagnosis_id=502, patient_id=302)
dialog.show()
dialog._ask("新患者的问题")
new_worker, bubble = dialog._stream_worker, dialog._stream_bubble
assert new_worker is not None and not new_worker.is_cancelled()
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"})
dialog._stream_finished(generation, stream_generation, worker)
assert dialog._stream_worker is new_worker
assert dialog._asking
assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
dialog.close()
def test_ai_consult_window_can_be_maximized(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
+99 -6
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Signal
from PySide6.QtCore import QDate, QEvent, Signal
from PySide6.QtWidgets import (
QApplication,
QDialog,
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.core import PermissionSet, UserProfile
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import (
@@ -669,7 +669,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
def test_list_video_entry_uses_watch_permission_not_video_qr_permission(
application: QApplication,
) -> None:
class VideoRepository:
@@ -686,13 +686,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
pass
live_row = _row(
assistant_id=66,
video_call_hint={
"state": "live",
"label": "视频通话进行中",
"start_time": 1787102100,
}
)
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
page = ConsultationsPage(
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 66, "role_id": 2},
)
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([live_row])
@@ -738,10 +742,13 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
page = ConsultationsPage(
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 66, "role_id": 2},
)
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row(video_call_hint=hint)])
page.table.set_rows([_row(assistant_id=66, video_call_hint=hint)])
page.table.selectRow(0)
application.processEvents()
@@ -752,6 +759,92 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
assert emitted == []
page.close()
application.processEvents()
@pytest.mark.parametrize("user", [
{"id": 66, "role_id": 1},
{"id": 66, "role_id": "1"},
{"id": 66, "role_ids": [1, 2]},
{"id": 66, "role_id": "1,2"},
{"id": 66, "roles": [{"id": 1}]},
{"id": 66, "role_id": 1, "root": True},
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
])
@pytest.mark.parametrize("state,status", [
("live", "通话中"), ("pending_room", "接通中"), ("none", "—"),
])
def test_doctor_video_column_is_readonly_even_with_full_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
user: Any,
state: str,
status: str,
) -> None:
from doctor_workstation.services import DemoDoctorRepository
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
# Even a mixed-role doctor assigned to this row must not get the receiver entry.
row = _row(assistant_id=66, video_call_hint={"state": state})
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=user)
emitted = []
page.video_requested.connect(emitted.append)
try:
page.table_host.set_rows([row])
page.table.selectRow(0)
page._selection_changed()
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert cell.findChildren(QToolButton) == []
assert status in [label.text() for label in cell.findChildren(QLabel)]
assert page.call_toolbar_button.isHidden()
assert not page.video_button.isEnabled()
page._row_video(row)
page.video_button.setEnabled(True)
page.video_button.click()
assert emitted == []
finally:
page.close()
page.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
@pytest.mark.parametrize("user,permissions,assistant_id,allowed", [
({"id": 66, "role_id": 2}, ["tcm.diagnosis/watchCall"], 66, True),
({"id": 66, "role_id": 2}, ["tcm.diagnosis/videoQr"], 66, False),
({"id": 66, "role_id": 2}, [], 66, False),
({"id": 66, "role_id": 2}, ["*"], 77, False),
({"id": 66, "role_id": 0, "root": True}, ["*"], 77, False),
({"id": 66, "role_id": 0, "root": True}, ["*"], 66, True),
({"id": 0, "role_id": 2}, ["*"], 0, False),
(None, ["*"], 66, False),
])
def test_list_video_entry_requires_the_assigned_user_and_watch_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
user: Any,
permissions: list[str],
assistant_id: int,
allowed: bool,
) -> None:
from doctor_workstation.services import DemoDoctorRepository
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
row = _row(assistant_id=assistant_id, video_call_hint={"state": "live"})
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(permissions), current_user=user)
emitted = []
page.video_requested.connect(emitted.append)
try:
page.table_host.set_rows([row])
page.table.selectRow(0)
page._selection_changed()
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert bool(cell.findChildren(QToolButton)) is allowed
assert page.video_button.isEnabled() is allowed
page._request_video()
assert bool(emitted) is allowed
finally:
page.close()
page.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
@pytest.mark.parametrize(
+70
View File
@@ -1121,6 +1121,76 @@ def test_appointment_history_phone_is_fail_closed_without_phone_plain(
dialog.close()
@pytest.mark.parametrize("locked", [False, True])
def test_doctor_can_save_clinical_fields_without_resubmitting_masked_identity(
application: QApplication,
locked: bool,
) -> None:
class MaskedRepository(VisualRepository):
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
raise AssertionError("a masked phone must not be checked as plaintext")
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
raise AssertionError("a masked identity must not be checked as plaintext")
repository = MaskedRepository(locked=locked)
for source in (repository.detail["diagnosis"], repository.detail["patient"]):
source["phone"] = "156****7862"
source["id_card"] = "1101**********1234"
dialog = _open_dialog(
application, (1024, 640), mode="edit", repository=repository,
permissions=PermissionSet(["tcm.diagnosis/detail", "tcm.diagnosis/edit"]),
)
saved = []
dialog.saved.connect(lambda: saved.append(True))
try:
assert dialog.edit_fields["phone"].isReadOnly()
assert dialog.edit_fields["phone"].toPlainText() == "156****7862"
assert dialog.edit_fields["id_card"].isReadOnly()
dialog.edit_fields["chief_complaint"].setPlainText("口渴较前改善")
dialog._save()
assert saved == [True]
assert len(repository.updates) == 1
payload = repository.updates[0]
assert payload["chief_complaint"] == "口渴较前改善"
assert "phone" not in payload and "id_card" not in payload
assert "156****7862" not in str(payload)
assert "1101**********1234" not in str(payload)
finally:
dialog.close()
def test_plain_phone_permission_keeps_validation_and_duplicate_checks(
application: QApplication,
) -> None:
class PlainRepository(VisualRepository):
def __init__(self) -> None:
super().__init__()
self.phone_checks = []
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
self.phone_checks.append(dict(payload))
return {"exists": False}
repository = PlainRepository()
dialog = _open_dialog(
application, (1024, 640), mode="edit", repository=repository,
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
)
try:
assert not dialog.edit_fields["phone"].isReadOnly()
dialog.edit_fields["phone"].setPlainText("156****7862")
dialog._save()
assert not repository.phone_checks
assert not repository.updates
dialog.edit_fields["phone"].setPlainText("15600007862")
dialog._save()
assert repository.phone_checks == [{"id": 501, "phone": "15600007862"}]
assert repository.updates[-1]["phone"] == "15600007862"
finally:
dialog.close()
def test_order_detail_action_requires_exact_permission(application: QApplication) -> None:
repository = VisualRepository()
dialog = _open_dialog(
@@ -0,0 +1,96 @@
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QEvent
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
from doctor_workstation.ui.reception_style import body_family
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("tech_blue", [False, True])
@pytest.mark.parametrize("size", [(1024, 640), (1710, 920)])
@pytest.mark.parametrize("with_channel", [False, True])
def test_public_video_status_fits_frozen_cell_with_inherited_item_padding(
application: QApplication, tech_blue: bool, size: tuple[int, int],
with_channel: bool,
) -> None:
root = QWidget()
font = QFont(body_family())
font.setPixelSize(13)
root.setFont(font)
# Match GLOBAL_QSS without changing the application's theme between tests.
root.setStyleSheet("QTableView::item { padding: 6px 8px; border-bottom: 1px solid #E1E9F4; }")
layout = QVBoxLayout(root)
host = DiagnosisTableHost(
parent=root, tech_blue=tech_blue,
action_policy={"video_call": True, "view": True, "edit": True},
video_entry_allowed=lambda _record: False,
)
layout.addWidget(host)
statuses = [
("ended", "视频已结束 · 10-17 14:19"),
("ended", "视频已结束 · 10-17 14:27"),
("ended", "视频已结束"),
("live", "通话中"),
("pending_room", "接通中"),
("missed", "上次通话未接听"),
("cancelled", "上次通话已取消"),
("none", "—"),
]
rows = [
{
"id": index + 1, "patient_id": index + 100, "patient_name": "测试患者",
"gender": 1, "age": 50, "has_appointment": 1,
"appointment_id": index + 200, "appointment_status": 1,
"appointment_type": "video",
"latest_appointment_channel_text": "自媒体4H" if with_channel else "",
"appointments": [{"id": index + 200, "status": 1, "doctor_name": "测试医生",
"time_text": "2026-10-17 14:15", "appointment_type": "video"}],
"video_call_hint": {"state": state, "label": text},
}
for index, (state, text) in enumerate(statuses * 4)
]
try:
host.set_rows(rows)
root.resize(*size)
root.show()
for _ in range(4):
application.processEvents()
for index, (_state, text) in enumerate(statuses):
cell = host.fixed.indexWidget(host.model.index(index, 10))
assert not cell.findChildren(QToolButton)
label = cell.findChild(QLabel)
assert label is not None
assert label.height() >= label.heightForWidth(label.width())
assert cell.rect().contains(label.geometry())
assert abs(label.geometry().center().y() - cell.rect().center().y()) <= 1
assert label.toolTip() == text
if " · " in text:
assert label.text() == text.replace(" · ", "\n")
assert label.fontMetrics().horizontalAdvance("10-17 14:19") <= label.width()
assert cell.width() >= host.fixed.columnWidth(10) - 2
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
for fraction in (2, 3):
host.main.verticalScrollBar().setValue(host.main.verticalScrollBar().maximum() // fraction)
application.processEvents()
assert host.fixed.verticalScrollBar().value() == host.main.verticalScrollBar().value()
row = host.main.indexAt(host.main.viewport().rect().center()).row()
assert row >= 0
assert host.main.visualRect(host.model.index(row, 0)).top() == host.fixed.visualRect(
host.model.index(row, 10)
).top()
finally:
root.close()
root.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
+230 -4
View File
@@ -23,7 +23,7 @@ from doctor_workstation.ui.pages import prescriptions as page_module
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]:
return {
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2,
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2, "agreement_visible": True,
"patient_id": 301, "diagnosis_id": 501, "status": status,
"validity": "current", "comparison_type": "independent_baseline",
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
@@ -48,10 +48,14 @@ class Repository(DemoDoctorRepository):
self.calls: list[tuple[str, Any]] = []
self.batches = [batch()]
self.enabled = True
self.agreement_visible = True
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": self.agreement_visible, "can_manage_visibility": False}
def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]:
self.calls.append(("reports", params))
return {"enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
self.calls.append(("detail", batch_id))
@@ -71,7 +75,7 @@ class Repository(DemoDoctorRepository):
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
self.calls.append(("statuses", list(ids)))
return {"enabled": self.enabled, "items": [{"prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "items": [{"agreement_visible": self.agreement_visible, "prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
@pytest.fixture(scope="module")
@@ -595,6 +599,9 @@ def test_list_ignores_response_after_filter_generation_changes(application: QApp
def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None:
class StatisticsRepository:
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": True}
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
return {"total_count": 3, "patient_count": 2, "doctors": [{"doctor_name": "张医生", "total_count": 3, "patient_count": 2, "models": {"qwen": {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": {"non_independent": 3}}, "openai": {"eligible_count": 1, "coverage_rate": 33.3, "mean": 0, "median": 0}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"])
@@ -635,6 +642,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
return {"saved": True}
client = Client()
repository = RemoteDoctorRepository(client)
repository.get_prescription_ai_capabilities()
assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []}
repository.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20)
repository.get_prescription_ai_report(40)
@@ -644,6 +652,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
repository.review_prescription_ai(40, "qwen", "reviewed", "核对完成")
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
assert client.calls == [
("GET", "tcm.prescriptionAi/capabilities", {}),
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}),
@@ -660,6 +669,217 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
repository.retry_prescription_ai(40, "other")
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
def test_agreement_denied_or_missing_keeps_reports_review_and_retry(
application: QApplication, immediate: None, flag: Any,
) -> None:
repository = Repository()
repository.agreement_visible = flag
value = repository.batches[0]
value["agreement_visible"] = flag
value["models"]["openai"].update(status="failed", error_message="上游超时")
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], diagnosis_id=501,
current_user={"root": 1, "role_ids": [1]})
dialog.show()
application.processEvents()
assert not dialog._agreement_visible
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
assert dialog.model_views["qwen"]["comment"].isEnabled()
assert dialog.model_views["qwen"]["save"].isVisible()
assert dialog.model_views["openai"]["retry"].isVisible()
assert dialog.model_views["openai"]["retry"].isEnabled()
assert dialog.history.count() == 1
assert dialog.comparison_panel.checklist.isVisible()
assert dialog.comparison_panel.conclusions.isHidden()
assert not dialog.comparison_panel.rows
assert not dialog.comparison_panel.doses.rows
assert dialog.nav_buttons["history"].isHidden()
assert dialog.agreement_note.isHidden()
for key, views in dialog.model_views.items():
assert not views["score"].text() and not views["score"].toolTip()
assert not views["agreement_bar"].toolTip() and not views["agreement_bar"].accessibleName()
assert views["gauge"]._value is None and not views["gauge"].accessibleDescription()
assert not views["comparison"].toPlainText()
assert "comparison" not in dialog._batch["models"][key]
assert not dialog.agreement.delta.text() and not dialog.agreement.overlap.text()
for key in ("differences", "consensus"):
assert not dialog.rail.focus_values[key].text()
assert dialog.rail.focus_values[key].parentWidget().isHidden()
dialog._goto("per_herb")
assert dialog.tabs.currentWidget() is dialog.tab_pages["candidate"]
assert "黄芪" in dialog.model_views["qwen"]["candidate"].toPlainText()
dialog._goto("comparison_raw")
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
dialog._goto("history")
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
dialog.close()
@pytest.mark.parametrize("missing", ["reports", "history_batch", "detail"])
def test_each_report_visibility_flag_is_required(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, missing: str) -> None:
repository = Repository()
reports, detail = repository.list_prescription_ai_reports, repository.get_prescription_ai_report
def read_reports(**params: Any) -> dict[str, Any]:
data = reports(**params)
if missing == "reports":
data.pop("agreement_visible")
if missing == "history_batch":
data["lists"][0].pop("agreement_visible")
return data
def read_detail(batch_id: int) -> dict[str, Any]:
data = detail(batch_id)
if missing == "detail":
data.pop("agreement_visible")
return data
monkeypatch.setattr(repository, "list_prescription_ai_reports", read_reports)
monkeypatch.setattr(repository, "get_prescription_ai_report", read_detail)
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
assert not dialog._agreement_visible
assert not dialog.model_views["qwen"]["score"].text()
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
dialog.close()
def test_agreement_revocation_and_failed_read_clear_cached_views_and_survive_theme(
application: QApplication, immediate: None,
) -> None:
repository = Repository()
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
assert dialog.model_views["qwen"]["score"].text() == "0.0%"
old_batch, old_generation = deepcopy(dialog._batch), dialog._generation
dialog.model_views["qwen"]["comment"].setPlainText("保留复核草稿")
repository.agreement_visible = False
dialog.refresh()
dialog._detail_ready(old_batch, old_generation, 40)
assert not dialog._agreement_visible
assert not dialog.model_views["qwen"]["score"].text()
assert dialog.history_page.table.rowCount() == 0
assert not dialog.history_page.chart._points
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
dialog._switch_theme()
application.processEvents()
assert not dialog._agreement_visible and not dialog.model_views["qwen"]["score"].text()
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
dialog._switch_theme()
repository.agreement_visible = True
dialog.refresh()
assert dialog._agreement_visible
dialog._detail_error(RuntimeError("offline"), dialog._generation)
assert not dialog._agreement_visible
assert not dialog.model_views["qwen"]["score"].text()
assert "comparison" not in dialog._batch["models"]["qwen"]
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
dialog._detail_ready(old_batch, dialog._generation, 40)
assert not dialog._agreement_visible
dialog.close()
def test_revoked_agreement_cache_discards_flat_comparison_metadata() -> None:
value = batch()
sentinel = "previous-private-comparison"
value["baseline_exclusions_json"] = sentinel
value["models"]["qwen"].update({
"reason": sentinel, "comparison_reason_code": sentinel,
"dictionary_version": sentinel, "error_code": "",
})
value["models"]["openai"].update({
"error_code": "UPSTREAM_TIMEOUT", "reason": "模型请求超时",
})
sanitized = ai.agreement_safe_batch(value, False)
assert sentinel not in str(sanitized)
assert "千问分析" in str(sanitized["models"]["qwen"]["report"])
assert sanitized["models"]["openai"]["reason"] == "模型请求超时"
assert value["models"]["qwen"]["reason"] == sentinel
def test_list_capability_failure_revocation_and_late_status_cannot_restore_scores(
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = Repository()
page = page_module.PrescriptionsPage(repository, ["*"], {"root": 1})
page.resize(1366, 800)
page.show()
application.processEvents()
assert not page.table.isColumnHidden(12)
old_statuses = repository.list_prescription_ai_statuses([801, 802])
old_token = page._ai_request_token
old_capability_token = page._ai_capability_token
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
page._load_ai_capabilities()
page._ai_statuses_ready(old_statuses, [801, 802], old_token, page._generation)
page._ai_capabilities_ready({"agreement_visible": True}, old_capability_token, page._generation)
assert page.table.isColumnHidden(12) and not page.table.isColumnHidden(11)
assert page.ai_statistics_button.isHidden()
assert page.ai_report_button.isVisible()
assert page._ai_statuses
for index in range(page.table.rowCount()):
item = page.table.item(index, 12)
assert not item.text() and not item.toolTip() and not item.data(Qt.ItemDataRole.AccessibleTextRole)
assert all("score" not in model for row in page._ai_statuses.values() for model in row.get("models", {}).values())
page.close()
def test_list_rechecks_completed_scores_after_hide_and_show(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None:
repository = Repository()
original = repository.list_prescription_ai_statuses
def statuses(ids: list[int]) -> dict[str, Any]:
data = original(ids)
for row in data["items"]:
row["status"] = "success"
for model in row["models"].values():
model["status"] = "success"
return data
monkeypatch.setattr(repository, "list_prescription_ai_statuses", statuses)
page = page_module.PrescriptionsPage(repository, ["*"])
page.resize(1366, 800)
page.show()
application.processEvents()
assert "0%" in page.table.item(0, 12).text()
before = len([call for call in repository.calls if call[0] == "statuses"])
page.hide()
page.show()
application.processEvents()
assert len([call for call in repository.calls if call[0] == "statuses"]) > before
assert "0%" in page.table.item(0, 12).text()
page.close()
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
def test_direct_statistics_requires_strict_server_capability(application: QApplication, immediate: None, flag: Any) -> None:
class StatisticsRepository:
calls = 0
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": flag, "can_manage_visibility": True}
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
self.calls += 1
return {}
repository = StatisticsRepository()
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
dialog.show()
assert repository.calls == 0
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
dialog.close()
def test_statistics_failed_capability_clears_old_charts_and_ignores_late_result(
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = ChineseStatisticsRepository()
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
dialog.show()
assert dialog.panel.isVisible() and dialog.report.toPlainText()
old_generation = dialog._generation
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
dialog.refresh()
dialog._ready(repository.prescription_ai_statistics(), old_generation)
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
assert not dialog.panel.distribution.has_data()
assert dialog.panel.doctors.rowCount() == 0
dialog.close()
def test_stale_list_score_is_not_attached_to_current_prescription() -> None:
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
assert ai.agreement_text(value) == "千问 —\nOpenAI —"
@@ -773,7 +993,7 @@ def test_actual_history_pagination_contract(application: QApplication, immediate
def reports(**params: Any) -> dict[str, Any]:
repository.calls.append(("reports", params))
page = params["page_no"]
return {"lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
return {"agreement_visible": True, "lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
monkeypatch.setattr(repository, "list_prescription_ai_reports", reports)
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
@@ -795,6 +1015,9 @@ def test_server_empty_batch_statuses_are_not_fake_scores(status: str, expected:
def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None:
class StatisticsRepository:
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": True}
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
strata = [{"versions": {"model_version": version, "algorithm_version": "v1", "prompt_version": "p1", "dictionary_version": "d1"}, "count": 1, "mean": mean, "median": mean, "distribution": {"[0,20)": 1 if mean == 0 else 0}, "sample_status": "insufficient_sample"} for version, mean in (("model-1", 0), ("model-2", 80))]
return {"total_count": 2, "patient_count": 1, "doctors": [{"doctor_id": 7, "doctor_name": "张医生", "total_count": 2, "patient_count": 1, "paired_count": 0, "models": {"qwen": {"eligible_count": 2, "coverage_rate": 100, "mean": None, "median": None, "excluded_reasons": {}, "strata": strata}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
@@ -965,6 +1188,9 @@ def test_internal_version_labels_keep_revision_numbers(version: str, expected: s
class ChineseStatisticsRepository:
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return {"agreement_visible": True}
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
+8 -3
View File
@@ -7,7 +7,7 @@ import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.core import PermissionSet, UserProfile
from doctor_workstation.core.appointment_modes import (
appointment_type_description,
appointment_type_value,
@@ -76,13 +76,18 @@ def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application
page.deleteLater()
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
@pytest.mark.parametrize("current_user", [
None,
{"id": 66, "role_id": 1},
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
])
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch, current_user):
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
"appointment_status": 1, "latest_appointment_id": 71,
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
"video_call_hint": {"state": "none"}}
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=current_user)
try:
page.table_host.set_rows([row])
page.table.selectRow(0)
@@ -0,0 +1,99 @@
# 业务订单辅方袋数:后端只读调查
调查日期:2026-10-17。范围为业务订单详情的服用参数修改链路;未修改业务代码、未读取业务数据库或配置中的敏感信息。仓库 `.trellis/` 不存在,已按现有代码约定调查。
## 结论
辅方「5g · 1袋」中的袋数已经持久化在 `zyt_tcm_prescription.aux_usage.dosage_bag_count`,无需新增数据库字段。业务订单的修改入口是 `POST /tcm.prescriptionOrder/patchPrescriptionUsage`,当前只更新辅方每天次数和开立天数,没有接受或保存辅方袋数。建议沿用此入口增加可选参数 `aux_dosage_bag_count`,限制为 1~5 的整数;未传时保留旧袋数,兼容现有调用方。
## 字段与读写链路
- `server/sql/1.9.20260520/add_prescription_aux_usage.sql`:处方表的 `aux_usage` 是已有 JSON 列。
- `server/app/common/model/tcm/Prescription.php:21`:`aux_usage` 配置为 JSON,关联数组读写。主方袋数为处方独立字段 `dosage_bag_count`;辅方袋数位于 JSON 中。`bags_per_dose` 是另一个字段,不应用来保存本次截图的袋数。
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:183`:`normalizeAuxUsage()` 维护 `dosage_amount`、`dosage_bag_count`、`need_decoction`、`bags_per_dose`、`times_per_day`、`usage_days`、`prescription_name`。`normalizeDosageBagCount()` 将袋数约束到 1~5,默认 1。
- `server/app/adminapi/validate/tcm/PrescriptionValidate.php:15`:通用处方主方袋数采用 `integer|between:1,5`,可复用该业务口径。
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php:240`:调用 `post()->goCheck('patchPrescriptionUsage')`,再传入管理员身份调用逻辑层。
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:5770`:公开方法进入受保护事务,内部 `patchPrescriptionUsageLocked()` 加载订单与关联处方。当前合并旧 `aux_usage` 后仅改 `times_per_day`、`usage_days`,其余键保持不变。
- 同文件 `detail():1212` 复用处方详情,导出函数 `formatUsageSegmentForExport():3756` 和 `normalizeAuxUsageForExport():3807` 已读取辅方 `dosage_bag_count`。保存正确 JSON 键后,无需另建展示或导出字段。
- 辅方存在性以 `herbs[].formula_type === '辅方'` 判定;残留 `aux_usage` 不代表仍有辅方。
## 校验现状与必须注意的问题
当前业务逻辑强制主方次数 1~6、主方开立天数 1~999、订单服用天数 1~999;存在辅方时要求辅方次数及开立天数,并分别检查 1~6、1~999。
发现既有验证器场景命名问题:`PrescriptionOrderValidate.php:136` 定义的是 `patchPrescriptionUsage()`,而非 `scenePatchPrescriptionUsage()`。ThinkPHP `vendor/topthink/framework/src/think/Validate.php:1673` 的 `getScene()` 只调用 `scene` 前缀方法。因此控制器当前场景校验不会执行该方法中的 `append()`;静态 `$scene` 虽然列了用量字段,但这些字段没有基础 `$rule`,实际主要只校验已有的 `id` 规则。逻辑层先 `(int)` 转换再判断范围,也会把部分非整数输入截断为整数。
已用只加载 Composer 和内存验证器的 PHP 探针复现:同一组非法次数/天数参数,`scene('patchPrescriptionUsage')->check(...)` 返回 `true`,手动先调用旧 `patchPrescriptionUsage()->check(...)` 返回 `false`。探针不初始化业务应用、不连接数据库。
最小修复应正确使用 `scenePatchPrescriptionUsage()`,把新参数加进场景并追加 `integer|between:1,5`。业务逻辑还应在保存前验证袋数确实为整数并在范围内,避免直接调用逻辑方法时发生小数截断。无需改变其他接口的验证场景。
## 权限、状态与事务
- 现有接口权限为 `tcm.prescriptionOrder/patchPrescriptionUsage`,菜单迁移在 `server/sql/1.9.20260708/add_prescription_order_patch_usage_menu.sql`;新增袋数应沿用此权限。
- `AuthMiddleware` 根据菜单 URI 校验管理员权限;现有框架对未注册在菜单表的普通 URI 存在历史放行逻辑,因此部署应确保已有菜单迁移已执行。本次无需新增权限或迁移。
- 逻辑层先检查订单存在、`canAccessOrder()`、药房快照可变、订单未取消、已关联有效处方,以及 `PrescriptionLogic::canViewPrescription()`。
- `canAccessOrder()` 沿用当前全量角色、处方订单菜单访问、订单创建人、开方人授权或数据域可见创建人等口径;处方可见性另检查超管、创建人、医助、共享、可见角色及现有相关权限等。
- `LockedPharmacySnapshotMutation::execute()` 在 `Db::transaction()` 中锁订单行及药房提交声明行;内部返回 `false` 会导致回滚。新增字段必须留在此现有事务内部。
- `PharmacyRemoteSnapshotPolicy` 在已有药房远端单号、提交时间或声明状态为 `PENDING`、`UNKNOWN`、`PENDING_RECONCILE`、`SUCCESS` 时锁定修改。新增袋数不可绕开该限制。
## 日志
当前写入 `zyt_tcm_prescription_order_log`,`action = patch_rx_usage`,摘要为主方次数/天数、辅方次数/天数及订单天数的前后值。建议在原辅方段落加上「袋数 旧值 → 新值」,沿用当前日志动作,确保能回溯这次更改。
`writeLog():5723` 默认是非严格日志,日志保存失败会吞掉异常;本接口沿用该默认行为。是否改成严格日志属于额外行为变更,不是实现辅方袋数所必需。
## 最小修改建议
1. 修改 `PrescriptionOrderValidate` 的正确场景方法,仅对新增可选 `aux_dosage_bag_count` 检查整数及 1~5 范围。
2. 在 `patchPrescriptionUsageLocked()` 中,存在辅方且新参数已提供时才更新 `aux_usage['dosage_bag_count']`;继续复制旧 JSON,保留用量、名称、代煎等所有其他键。不修改主方 `dosage_bag_count`。
3. 缺少新参数时保留旧袋数;无辅方时沿用现有忽略辅方修改的逻辑。不因编辑次数或天数而把旧袋数重置为默认值。
4. 扩展 `patch_rx_usage` 摘要记录袋数前后值。
5. 验证有效 1、5,非法 0、6、小数、非数字,省略字段兼容,JSON 其他键保留,无辅方不产生新辅方用法,权限/取消/药房锁定失败,以及保存后回读与日志。参数校验至少要通过真实 `scene('patchPrescriptionUsage')` 路径测试,不能仅直接调用场景方法。
## 现有测试与本次验证
- 未发现针对辅方袋数修改或 `patch_rx_usage` 摘要的既有功能测试。
- `server/tests/pharmacy/run.php:809` 对本接口的受保护事务、错误清理及回滚信息有源码契约检查;它不验证真实辅方袋数持久化。
- `server/tests/pharmacy/prescription_order_export_name_regression.php` 覆盖辅方存在性及名称回退,本次运行 **7 项通过**。
- `php tests/pharmacy/run.php` 本次基线运行在 `run.php:870` 失败,提示 `tracking correction must append a strict operation log containing old and new logistics values`。这是快递修改日志的源码文本断言,调查时未修改任何后端业务代码,应与本次新增功能分开判断。
- 真实 ORM/事务测试可参考 `PrescriptionOrderReleaseAndTimeTest.php` 或 `PrescriptionOrderTrackingAuditTest.php` 的临时数据库夹具;它们要求显式指定隔离 MySQL 端口,不能直接用业务数据库。目前未运行这些数据库测试。
推荐从仓库根目录执行:
```powershell
php -l server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php
php -l server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
php server/tests/pharmacy/run.php
php server/tests/pharmacy/prescription_order_export_name_regression.php
```
若补充独立辅方袋数行为测试,应使用可控临时数据库或现有内存替身,覆盖上述范围、合并保存及权限/状态拒绝;测试命令随新测试实际路径确定。已有真实 ORM 测试仅在已准备隔离 MySQL 时运行,并按对应文件头说明设置环境变量。
## 实施结果(2026-10-17)
调查完成后,按授权实现了最小后端扩展:
- `PrescriptionOrderValidate` 已改为正确的 `scenePatchPrescriptionUsage()`,新增可选 `aux_dosage_bag_count`。使用场景闭包严格验证 1~5 整数/整数字符串;缺少字段可通过,但显式空值、布尔值、小数或其他类型会拒绝,避免框架普通可选规则跳过空值。
- `PrescriptionOrderLogic` 仅在处方含辅方且 `prescription_type` 为「浓缩水丸」时处理新袋数,逻辑层独立重复严格校验;合并原 JSON 保存该键,省略字段保留原值,其他处方类型和无辅方处方忽略袋数变更。原有主方、次数/天数、权限、取消限制、药房锁及事务边界继续沿用。
- 在 `patch_rx_usage` 原日志中增加「辅方袋数 X袋 → Y袋」;只在实际提供并处理袋数字段时记录,不改变原日志失败容错行为。
- 控制器仅更新注释。未新增数据库迁移,未修改本分工之外的前端代码。
新增 `server/tests/PrescriptionOrderAuxBagCountTest.php`,直接运行真实场景验证器和公开业务逻辑入口,使用独立本地 MySQL 随机临时库,结束后自动删除该库;不加载业务数据库配置。覆盖整数边界、非法输入、JSON/主方字段保留、保存后数据库及模型回读、旧请求省略字段、旧 JSON 无袋数字段、非浓缩水丸、无辅方及残留 JSON、日志、权限/取消/药房快照拒绝,以及处方已保存后订单更新失败的真实事务回滚。
验证结果:
- 新增真实验证器/ORM/事务测试:**83 项断言通过**。
- 相关 4 个 PHP 文件语法检查通过。
- 辅方导出名称回归:**7 项通过**。
- 3 个修改过的现有 PHP 文件 `git -c core.whitespace=cr-at-eol diff --check` 通过。
- 上文记录的药房套件快递日志文本断言基线失败未扩大修改。
在已准备隔离 MySQL 实例后,可从仓库根目录重跑:
```powershell
$env:ZYT_AUX_BAG_TEST_MYSQL_PORT='13483'
php server/tests/PrescriptionOrderAuxBagCountTest.php
php server/tests/pharmacy/prescription_order_export_name_regression.php
```
本次测试使用 `.codex-tasks/aux-bag-count-mysql/data` 的专用实例,绑定本机回环地址与 13483 端口。测试会拒绝未指定端口或默认 3306 端口。
@@ -0,0 +1,75 @@
# Desktop AI quick-tools lifecycle review
审查时间:2026-10-17。范围:`app/src/doctor_workstation/ui/dialogs/ai_consult.py` 的工具绑定、流状态与关闭路径;辅助读取 repository / API SSE 实现和现有 UI 测试。审查时业务文件及测试已有未提交改动,未编辑这些文件,也未调用生产或任何外部服务。
## 结论
### 1. 已确认:done 后仍由 worker.finished 持有忙碌门控
- 六个工具在 `ai_consult.py:4009-4012` 用 `_ClickCard.clicked = Signal()` 绑定各自的默认参数 `prompt`。信号不携带 bool 参数,因此未发现常见的 lambda 参数覆盖或循环变量晚绑定问题。
- `_ask()` 在 `5996` 遇到 `_asking` 就无提示返回;卡片本身仍保留可点击外观,忙碌期间的工具选择被直接丢弃。
- `_stream_event(done)` 在 `6130-6143` 标记完成、停止总超时与耗时计时、渲染最终内容,但不清除 `_asking`,也不启用发送按钮。
- `_asking` 只在 `_stream_finished()` 的 `6196-6197` 恢复。`_AiStreamWorker.run()` 在 `3185-3188` 对 done 仍然继续迭代,直到底层迭代器退出才会进入 `finally` 发出 finished。
- 因而“已显示最终答案”和“允许再次点击工具”不是同一个时点。done 后迭代器或连接清理阻塞时,工具无反应可以持续;同时总超时已经停止,手工调用 `_stream_timed_out()` 也会被 `_stream_completed` 门控直接返回。
- 当前 `RemoteDoctorRepository.stream_diagnosis_ai()` 在 yield done 后有 `return`(`repository.py:1438-1442`),通常会很快触发 finished,所以不能仅凭该缺陷断言真实部署每次都会永久卡住。确定的客户端缺陷是 UI 终态仍依赖传输退出,并且在此期间没有超时恢复。
### 2. 已确认:返回、侧栏 ×、Escape 的 reject 路径绕过流清理
- 返回与侧栏 × 绑定 `self.reject`(`3710`、`3966`),但唯一关闭清理在 `closeEvent()`(`6231-6237`);没有覆盖 `done()` / `reject()` 或连接 `finished` 作统一清理。
- Qt 的 `QDialog.reject()` 隐藏对话框并结束 exec,不触发此处的 closeEvent 清理。离线调用已证实:reject 后 `_asking=True`、worker 未取消、两个流计时器仍运行、generation 未变化。
- `open_for()` 会先 `_cancel_stream()` 并递增会话 generation(`3601-3608`),所以复用同一对话框且按标准 open_for 重开时能够隔离旧回调。`present_ai_consult()` 每次新建一个对话框,旧对话框带父对象时隐藏后仍可能被保留,造成后台 worker / timer 继续存在。
- 这是生命周期缺陷;它本身不能证明同一已显示窗口的其他工具永久失效,但应与本次门控修复一起覆盖。
### 3. 其他退出路径
- `_stream_failed()` 与 done 相同,停止计时并显示错误,却只靠稍后的 finished 解锁。实际 worker 的 except 紧接 finally,常规错误路径间隙很短;仍建议终态入口统一恢复 UI 状态。
- `_stream_finished()` 对 generation / stream_generation 作检查;`_cancel_stream()` 与 `open_for()` 的 generation 隔离正确,迟到旧事件不会覆盖新流。
- 取消仅设置线程 Event,无法中断正在阻塞的 next()/网络读取;因此 UI 必须先解除对旧 worker 的依赖,再由 worker 完成清理。
- 未发现六个 prompt 绑定错误。连续点击无反馈的直接入口是 `_ask()` 的无提示忙碌返回,而非按钮失去信号连接。
## 离线复现
使用 `app/.venv/Scripts/python.exe`、`QT_QPA_PLATFORM=offscreen`,以 stdin 临时脚本运行,未新增或修改业务 / 测试文件。
1. 将线程池 start 替换成仅记录 worker 的函数,点击第一张工具卡,直接投递 done,不投递 finished,再点第二张卡。
```json
{"case":"done_before_finished","asking":true,"send_enabled":false,"timeout_active":false,"completed":true,"second_click_started_worker":false}
{"case":"forced_timeout_after_done","asking":true}
```
补发 finished 后再次点第二张卡,第二个 worker 正常创建。
2. 使用真实 QThreadPool / QRunnable 与完全本地的假 repository。假生成器 yield done 后在 Event 上等待,模拟终态数据已到但迭代器未退出。UI 事件循环实际收到 done 后立即发起第二问:
```json
{"case":"real_qrunnable_done_then_open_iterator","done_delivered":true,"iterator_resumed_after_done":true,"asking":true,"timeout_active":false,"prompts":["first"]}
{"case":"iterator_released","asking":false,"send_enabled":true}
```
3. fake pool 下 show 对话框并开始一问,调用实际 `dialog.reject()`:
```json
{"case":"reject","visible":false,"asking":true,"worker_cancelled":false,"generation_unchanged":true,"stream_generation_unchanged":true,"elapsed_timer_active":true,"timeout_active":true}
{"case":"open_for_after_reject","worker_cancelled":true,"asking":false,"generation_changed":true}
```
所有复现脚本 exit code 0,未访问 API。
## 建议的最小修复
1. 将 UI 终态处理集中为幂等 helper,确保 done、error、silent finished 都能停止计时、冲刷待显示文本、清除 `_asking` 并恢复发送按钮;done 不等待网络或 worker 退出。保留已完成气泡 / 元数据 / 最终耗时,避免用会清空这些字段的 `_cancel_stream()` 直接处理 done。
2. worker 将有效 done 视为终止事件,发出后不再拉取下一项;在 finally 中安全关闭拥有 close() 的迭代器。即使底层关闭有延迟,UI 在步骤 1 已恢复。
3. 选定忙碌时点击工具的明确策略:若允许切换,则工具入口取消当前流并发起新的工具请求;若保持串行,则提供可见的忙碌反馈或禁用状态。当前静默吞点击不利于判断应用是否失效。不要误将旧 finished 用于解锁新请求,继续依赖 stream_generation 隔离。
4. 在 `done(result)` 或其他统一的对话框结束入口中执行 generation 失效与 `_cancel_stream()`,覆盖 reject / accept / Escape / 窗口关闭;确保和 closeEvent 重入时幂等。
## 建议回归情境
- 按截图六个工具依次真实点击(包含卡片文字和图标区域),每个 done 到达即点下一个,不提前补发 finished;每次 prompt 与 worker 都应正确创建。
- done 后迭代器保持打开 / cleanup 延迟:UI 必须允许下一问,旧 worker 的迟到 delta/error/finished 不得改动新气泡或错误解锁新请求。
- 正在首帧等待或生成中点击另一工具,验证所选忙碌策略且用户可理解当前状态。
- error 到达但 finished 延迟、无 done 的静默结束、总超时、切换患者后迟到信号,均应恢复交互且不串患者。
- 对返回按钮、侧栏 ×、Escape、原生窗口关闭分别验证 worker 已取消、计时器停止、旧 generation 无效;重开后新请求正常。
- 保留首字即时显示、40ms 后续合并、完成后结构化渲染、首字/总耗时固定、一次性全量上下文提示的现有行为。
现有 `test_answered_stream_replaces_the_pending_placeholder` 与耗时完成测试在 done 后立即手工调用 finished,掩盖终态间隙;现有关闭耗时测试用 `dialog.close()`,未覆盖 reject 路径。
@@ -0,0 +1,55 @@
# 桌面端 AI 助手:服务端流式链路审查
日期:2026-10-17
## 结论
- 正常链路已经逐帧转发:`DifyChatService::sendStreamRequest()` 在 `CURLOPT_WRITEFUNCTION` 内解析 SSE 并立即调用 `onDelta`;`DiagnosisController::runAssistantSse()` 对每个 delta `echo` 后执行 `ob_flush()` / `flush()`,没有等模型全文完成才发送。
- 控制器清理 PHP 输出缓冲、关闭输出压缩、设置 `X-Accel-Buffering: no` 和 `Cache-Control: no-cache, no-transform`,并先发送 2048 字节注释和 start 事件。
- 首字之前仍有真实耗时:`prepareAssistant()` 在 SSE headers 前完成权限、DataScope、病例聚合和模型预检。病例正文超过 120,000 字节时,`fitContextForPrompt()` 同步调用 `PatientAiReportLogic::compactSourceForPrompt()`;后者依次通过 blocking 请求读取全部分片并可能归并摘要。保留这一流程以保持全量临床来源覆盖,不能用截断病例来换速度。
- 兼容重试也可能增加首字等待:通用 `/v1` 地址存在协议回退;`streamPreparedAssistant()` 在尚未交付任何 delta 且流失败为 `UPSTREAM_REJECTED` / `INCOMPLETE_RESPONSE` / `EMPTY_RESPONSE` 时,会额外做一次 blocking 兼容尝试。已经交付文本后不会重复生成。
- 因此前端需要在用户提交时立即反馈并开始计秒,不能等 SSE start 才开始计时。`AssistantSseTiming` 已提供不含患者正文的 prepare、first_delta、upstream_done 等日志,可定位实际延迟阶段。
## 修复
发现 SSE 分帧正则 `(?:\r\n|\r|\n){2}` 允许回溯,把一个 CRLF 拆成两个换行;仅收到一行 data 时就会提前解析。合法的多行 CRLF data 事件会被拆坏并丢失文本,可能进一步触发无 delta 的兼容重试。
将换行匹配改为原子组 `(?>\r\n|\r|\n){2}`,使 CRLF 始终作为一个换行。事件到达空行即转发,不等待 message_end 或网络 EOF。接口、权限、事件格式、错误码和凭据保护保持兼容。
修改文件:
- `server/app/common/service/DifyChatService.php`
- `server/tests/DifyChatStreamContractTest.php`
- `server/tests/DifyChatStreamDeliveryTest.php`(新增)
新增行为覆盖:
- data 行尚未结束事件时不解析;完整首帧在终止事件及 EOF 前立即交付。
- 多行 CRLF data 在 1 字节、2 字节及整段传输切块下保持为同一事件。
- 离线模拟实际 cURL header/write 回调:每帧在下一次网络读取之前、`curl_exec()` 返回之前到达消费者;Dify / OpenAI 均覆盖。
- HTTP 错误正文不进入 delta;消费者拒绝后停止继续读取和交付。
新增回归在修复前实际失败:`FAIL: a data line alone is not a complete SSE event`;应用修复后通过。
## 验证
以下命令全部退出码 0:
```powershell
php server/tests/DifyChatStreamContractTest.php
php server/tests/DifyChatStreamDeliveryTest.php
php server/tests/DifyChatAncientBooksReuseContractTest.php
php server/tests/DiagnosisAiAssistantStreamContractTest.php
php server/tests/DiagnosisAiAssistantTimingTest.php
php server/tests/DiagnosisAiAssistantContractTest.php
php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php
php server/tests/DiagnosisAiAnalysisSecurityTest.php
php server/tests/DiagnosisAiAnalysisContractTest.php
php server/tests/PrescriptionAiUpstreamContractTest.php
php -l server/app/common/service/DifyChatService.php
php -l server/tests/DifyChatStreamContractTest.php
php -l server/tests/DifyChatStreamDeliveryTest.php
git diff --check -- server/app/common/service/DifyChatService.php server/tests/DifyChatStreamContractTest.php server/tests/DifyChatStreamDeliveryTest.php
```
验证均离线进行,没有调用生产模型、读取凭据或测试生产代理配置;因此不提供生产首字时延数值。
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+53
View File
@@ -0,0 +1,53 @@
# 桌面 AI 对话即时反馈与秒数验证
日期:2026-10-17
## 行为
- 提交问题即显示“已等待 0 秒”,资料准备与模型等待期间每秒更新。
- 第一段有效文本到达时立即替换等待占位;后续文本按 40 ms 合并刷新,保留顺序。
- 生成期间显示“生成中 · 已用时 N 秒”;完成后显示模型、首字耗时、总耗时和时间。
- 完成、错误、超时、静默断连、切换患者、关闭窗口均停止秒数计时;新问题重新计时。迟到事件不会覆盖已结束的结果。
- 富文本解析和格式调整完成后统一测量高度,减少一次更新内的重复布局。
- 服务端修复 CRLF 分帧错误,详见 [后端审查](desktop-ai-stream-backend-review.md)。
## 验证
在 `app/` 下执行:
```powershell
.venv/Scripts/python.exe -m pytest -o addopts= tests/test_ai_consult_ui.py tests/test_ai_consult_workspace_ui.py tests/test_api_client.py tests/test_repository_parity.py
.venv/Scripts/python.exe -m ruff check src/doctor_workstation/ui/dialogs/ai_consult.py tests/test_ai_consult_ui.py
```
结果:156 passed in 4.75s;Ruff 通过。
新增端到端回归使用真实 httpx 解析器、仓库、线程池 worker 和 Qt 事件交付,暂停上游结束事件,确认首段中文文本已出现在界面;覆盖跨网络块的 UTF-8 字符、后续文本顺序、结束状态以及回调运行在 UI 主线程。未调用生产模型。
服务端 10 项相关契约/行为测试与 3 项 PHP 语法检查通过,命令及结果见后端审查。
1308 × 806 离屏检查三个状态,完成状态元信息没有截断:
- [等待](desktop-ai-stream-waiting.png)
- [生成中](desktop-ai-stream-generating.png)
- [完成](desktop-ai-stream-done.png)
本地一次富文本更新对比:同一离屏控件连续 20 次更新,原逻辑 157.9 ms,新逻辑 129.6 ms。该数值仅为本机布局检查,不代表生产模型首字时延。
## 扩展检查边界
额外运行 `test_dialog_detail_layout.py`、`test_list_detail_geometry.py`、`test_diagnosis_detail_contract.py`、`test_ui_detail_refinements.py`:34 通过,3 失败,分别为挂号列表横向溢出、患者状态按钮宽度、问诊筛选控件高度。这些模块未在本次任务中修改。将 AI 模块恢复为 Git HEAD 源码后再次运行同一组,仍是同样的 3 项失败(34 passed in 12.22s),确认不是本次修改引入。
AI 用例与其他布局用例连跑时,在后者 fixture 的 `apply_theme()` / `app.setStyle("Fusion")` 处停滞;独立运行布局组能完成。将 AI 模块恢复为 Git HEAD 源码后,该组合也会停滞,已停止诊断进程;没有修改全局主题或相应布局。
## 生效范围
已修改仓库源码,未生成安装包或部署生产服务。源码运行的桌面端重启后加载;已安装的打包版本需重新构建发布。服务端 CRLF 修复需部署。病例聚合及模型首字前的真实耗时仍然存在,当前以实时秒数明确展示。
## 追加:快捷工具后续点击无反应
同日修复后续反馈:完成/报错到达后立即解除忙碌状态,不依赖后台线程退出;worker 收到 done 后不再拉取下一个事件。生成中切换其他快捷工具、快捷指令或对话建议会停止当前请求,保留已有文本和耗时并明确标注停止;重复点击相同的进行中工具只提示,不重复提交。新请求自动展示问诊对话并恢复全部对话筛选,避免结果被其他资料页或筛选隐藏。
统一窗口结束清理,覆盖返回、侧栏 ×、Escape、accept/reject 和原生关闭;旧请求的 delta/error/finished 回调无法影响新请求。审查依据见 [快捷工具生命周期审查](desktop-ai-quick-tools-review.md)。
新增 4 项核心回归在修复前均实际失败,修复后通过;另覆盖 4 个快捷入口和 6 种窗口退出方式。相同桌面端测试命令最终结果为 **170 passed in 5.84s**,Ruff 与 `git diff --check` 通过。未修改服务端、打包或重启正在使用的桌面端进程。
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

@@ -0,0 +1,45 @@
# 诊单编辑:脱敏手机号缺省提交的后端修复
日期:2026-10-17。范围:后端编辑校验及离线 PHP 回归。
## 根因
桌面医生账户无 `tcm.diagnosis/phonePlain` 权限时只看到脱敏手机号,保存时会排除 `phone`/`id_card`。`DiagnosisController::edit()` 先执行 `DiagnosisValidate::sceneEdit()`,后调用 `DiagnosisLogic::edit()`。编辑场景原样继承全局 `phone = require|mobile`,因此合法的缺省 `phone` 请求在到达更新逻辑前就被“请输入手机号”拦截。
同一编辑场景还继承 `patient_name`、`gender`、`age` 的全局 `require`,即使这些基本信息被业务锁定、调用者仅修改临床信息,省略后也会被拦截。新增回归在修复前首先复现“请输入患者姓名”;另有单独省略 `phone`、其他基础信息照常提交的用例对应本次医生保存失败。
`DiagnosisLogic::edit()` 已直接用收到的 `$params` 更新,不会为缺省手机号补空值。基本信息变更比较也已使用 `array_key_exists` 跳过未提交字段,因此不需要修改 controller 或 logic。
## 修改
- `server/app/adminapi/validate/tcm/DiagnosisValidate.php`:仅在 `sceneEdit()` 中,将 `phone`、`patient_name`、`gender`、`age` 的无条件 `require` 替换为 `requireWhenPresent`。
- 新规则区分缺省键与显式提交:缺省表示保留;显式空串/null 仍拒绝,原有 `mobile`、姓名长度、性别范围、年龄类型/范围规则继续执行。`require` 前缀确保 ThinkPHP 对空串/null 也调用此规则。
- `server/tests/DiagnosisValidateEditSceneTest.php`:运行真实 validator 与真实 `DiagnosisLogic::edit()`,仅模型、数据库边界及权限读取使用内存替身,覆盖保存与业务锁行为。
## 边界与回归覆盖
- 编辑可省略所有受保护的基本信息,也可仅省略手机号而保留姓名/性别/年龄;省略的 `phone`、`id_card`、姓名/性别/年龄不会加入 update,原值保持。
- 显式手机号 `''`、null、`156****7862`、短号码、带空格号码、false、数组均拒绝;合法新号码仍通过格式校验。
- 显式姓名/性别/年龄为空仍拒绝;姓名超长、非法性别、非法或越界年龄仍拒绝;`gender = 0`、`age = 0` 仍有效。
- 编辑 ID 必填且诊单必须存在。`diagnosis_type`、`local_hospital_name` 等无关要求不变。
- 新增场景仍要求手机号、姓名、性别、年龄;新增空/脱敏手机号仍拒绝。
- 无额外权限的医生在最近订单未完成时,可以保存不含基本信息的临床修改;任何实际基本信息变更仍返回“最近业务订单未完成,无法修改患者基本信息”,且不会写入。原有 root 编辑能力保持。
- 未修改明文手机号权限、返回数据、创建权限、其他业务授权、统计或 AI 服务实现。
## 实际验证
执行目录:`D:/web/zyt`;PHP 8.2.9 CLI。测试未初始化应用、未读取生产配置,数据库/模型/权限边界为测试内存替身,没有数据库或外部服务调用。
| 命令 | 实际结果 |
| --- | --- |
| `php server/tests/DiagnosisValidateEditSceneTest.php`(修复前) | exit 1,`edit accepts omitted locked/basic/masked fields; got: "请输入患者姓名"` |
| `php -l server/app/adminapi/validate/tcm/DiagnosisValidate.php` | exit 0,No syntax errors detected |
| `php -l server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,No syntax errors detected |
| `php server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,Diagnosis edit-scene validation and masked/basic-field preservation: OK |
| `php server/tests/DiagnosisValidateAddSceneTest.php` | exit 0,Diagnosis add-scene validation: OK |
| `php server/tests/DiagnosisWorkspaceRowAuthorizationTest.php` | exit 0,Diagnosis workspace row authorization: OK |
| `php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php` | exit 0,Diagnosis AI prescription generation permission: OK |
| `php server/tests/DiagnosisAiAnalysisSecurityTest.php` | exit 0,Diagnosis AI analysis security: OK |
| `git diff --check -- server/app/adminapi/validate/tcm/DiagnosisValidate.php server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,无输出 |
本报告证明离线编辑校验、更新参数保留与原有业务锁行为;未进行生产登录或真实数据库写入。
@@ -0,0 +1,35 @@
# 医生编辑诊单提示“请输入手机号”验证
日期:2026-10-17。
## 结论与修改
无 `tcm.diagnosis/phonePlain` 权限的医生只能看到脱敏手机号,桌面编辑器按既有隐私规则在保存时省略 `phone`、`id_card`。后端编辑场景继承了手机号的无条件必填规则,导致保存被拦截。
已修改 `server/app/adminapi/validate/tcm/DiagnosisValidate.php`:编辑场景允许省略受保护或被锁定的基本字段,省略即保留原值;主动提交的字段仍须非空且格式正确。新增诊单必填规则、明文权限、订单基本信息锁保持。桌面业务代码无需修改,仅增加回归。
后端详细覆盖与结果见 [后端报告](diagnosis-edit-masked-phone-backend.md)。
## 实际跨层核对
使用桌面测试数据构造无明文权限的医生编辑器,从真实 `DiagnosisDialog` 保存,经真实 `RemoteDoctorRepository`、`ApiClient`,用 `httpx.MockTransport` 捕获发送到 `tcm.diagnosis/edit` 的 JSON。临床修改已保留,手机号和身份证字段均缺省。数据为测试样例,无真实网络请求。
将这份原样请求交给真实 PHP 编辑校验器,仅替换诊单存在性数据库查询:
- HEAD 原校验器稳定复现“请输入手机号”。
- 修复后校验通过。
- 测试脚本:`.codex-tasks/diagnosis-edit-cross-layer-check.php`;捕获请求为同目录 `diagnosis-edit-masked-payload.json`。
## 回归结果
| 验证 | 结果 |
| --- | --- |
| 桌面新增回归:无明文权限、有/无基本信息锁保存;有明文权限时格式及重复检查 | 3 passed |
| `pytest -o addopts= tests/test_diagnosis_drawer_visual.py tests/test_diagnosis_detail_contract.py tests/test_diagnosis_loading.py --tb=short` | 68 passed,exit 0 |
| `ruff check tests/test_diagnosis_drawer_visual.py` | 通过 |
| PHP 编辑、新增、行级授权、AI 处方权限、AI 分析安全离线回归 | 5 个脚本通过 |
| 真实桌面请求对照原/新 PHP 校验器 | 原校验复现错误,新校验通过 |
桌面完整回归期间 Qt 媒体加载记录过 `0xc0000139` 原生日志;进程继续完成全部测试并以 0 退出。本次未修改相关媒体代码。
未登录生产、未写真实数据库、未执行部署。本修复需服务端更新后生效,医生无需补录明文手机号。
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+102
View File
@@ -0,0 +1,102 @@
# 桌面问诊列表:医生角色视频入口契约核对
核对日期:2026-10-17。范围仅为本地源码只读审查;未访问生产系统,未修改业务代码或测试代码。
## 结论
1. **医生角色是 `role_id=1`,医助是 `role_id=2`。** `3` 不是医生角色;“我的患者”管理角色集合为 `[3,7,8]`。
2. 管理后台 PC/H5 的旁观入口均要求:**有已预约的视频挂号、当前用户等于诊单 `assistant_id`、拥有 `tcm.diagnosis/watchCall` 权限**。无入口资格时只能看通话状态。
3. 服务端 `watchCall` 业务层再次强制 `assistant_id == 当前登录 admin_id`,**没有 root 绕过**。root 仅在通用鉴权中绕过菜单权限。
4. 需要区分源码现状与本次新要求:**现有后台没有显式的“角色数组包含医生 1 即拒绝”分支**。混合医生/医助角色如果恰好被指派为助理且有权限,按现代码仍可能进入旁观;不能把这一规则误报为现有后端保证。
5. 桌面列表当前不检查角色或助理归属,仅检查 repository 视频能力、预约和通话状态,因此医生也会得到“进入视频问诊”。本次用户要求应在桌面显示和点击处都补上医生角色限制。
6. 桌面该入口实际发出 `mode="im"`,获取的是 `getCallSignature` 通话/聊天凭证,**不是**后台的 `watchCall` 纯拉流接口;不能仅根据文案把两者视为同一实现。
## 本次实施与验证
- 桌面列表对含医生角色 `1` 的账号(含混合角色及通配菜单权限)只显示 `通话中 / 接通中 / —` 等公开状态,不显示进入视频问诊;同类工具栏入口同步隐藏。
- 表格显示、选中行按钮状态和实际点击处理使用同一资格判断;直接调用旧入口或强行启用按钮也不能发出视频请求。
- 非医生账号仍须同时满足本人诊单医助归属和 `tcm.diagnosis/watchCall` 权限;超级管理员也不绕过医助归属。有效预约、live 状态与标识完整性仍由原有流程检查。
- 医生的图文预约继续显示“图文沟通”,不受视频入口限制;接诊台的医生通话流程未改动。未将既有 IM 接收流程改造成纯拉流旁观,也未修改后端接口。
- 新增角色/指派/权限矩阵回归在修改前 27 项失败,修改后全部通过;覆盖角色整数、字符串、数组、逗号字符串、对象列表及 UserProfile,以及混合角色、root、无身份、错误指派、仅二维码权限。
- 最终组合检查:165 通过,1 项既有分页组合用例失败(`test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable` 的请求页码为 `[1,2]`,预期 `[1,2,3]`)。恢复 Git HEAD 的两个业务模块并排除新增用例后,同一组合仍复现该失败(136 通过、1 失败)。该分页用例及整个视觉模块单独运行均通过,未修改分页实现。
- Ruff、修改范围的 `git diff --check` 通过;离屏检查已生成 [医生视角截图](doctor-video-column.png),使用本地示例数据,未连接生产服务。源码未打包发布,运行中的客户端需更新重启。
## 角色与身份数据
| 项目 | 源码契约 | 位置 |
| --- | --- | --- |
| 医生 | ID `1`;数据库角色关联按成员包含判断,而非要求只有单一角色 | `server/app/api/logic/DoctorLogic.php:96`、`server/app/adminapi/logic/firstvisit/MyPatientLogic.php:22` |
| 医助 | ID `2` | `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:23` |
| 管理角色 | `[3,7,8]`,用于“我的患者”的团队范围判断 | `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:24` |
| 服务端旧角色字段 | `Admin` 模型追加 `role_id`,值是当前用户所有 `admin_role.role_id` 数组 | `server/app/common/model/auth/Admin.php:29`、`:44` |
| 身份接口 | `auth.admin/mySelf` 的 `user` 包含 `id`、`root`、`role_ids`(整数数组),兼容模型追加的 `role_id` 数组;外层为 `user/menu/permissions` | `server/app/adminapi/logic/auth/AdminLogic.php:285`、`:319`、`:327` |
| 桌面模型 | `UserProfile.id`、`role_ids: tuple[int,...]`、`root: bool`、`permissions`、`raw` | `app/src/doctor_workstation/core/models.py:125` |
| 桌面转换 | 优先 `role_ids`,再 `role_id`,再 `roles`;支持数字、逗号字符串、集合/列表和 `{id: ...}` 元素,归一成整数元组;root 兼容 `is_root` | `app/src/doctor_workstation/core/models.py:94`、`:141` |
| 当前用户流向 | repository 的 `get_session()` 读取 mySelf 并建立 `UserProfile`;shell 取得 `current_user` 后传入页面,`ConsultationsPage` 已保留 `self.current_user` | `app/src/doctor_workstation/services/repository.py:902`、`app/src/doctor_workstation/ui/shell.py:899`、`:2014`、`app/src/doctor_workstation/ui/pages/consultations.py:944` |
混合角色应检查**集合包含 `1`**,不能只比较 `role_id == 1`,也不能只检查角色数组首项。现有 shell 的角色文案也以 `1 in roles` 优先显示“医生”:`app/src/doctor_workstation/ui/shell.py:1521`。
权限 `"*"` 与 root 不能互相等同。后台 root 会获得 `['*']`,拥有全部菜单权限的非 root 也可能得到 `['*']`;桌面 `PermissionSet.is_superuser` 仅表示存在全局通配符,不代表 `UserProfile.root` 为真。
- 后台授予逻辑:`server/app/adminapi/logic/auth/AuthLogic.php:56`。
- 桌面通配权限逻辑:`app/src/doctor_workstation/core/permissions.py:60`。
## 管理后台显示与点击逻辑
PC:`admin/src/views/tcm/diagnosis/index.vue`。
- `:362`:列标题为“视频旁观”。
- `:1545`:`isAssignedAssistant` 使用 `row.assistant_id ?? row.assistant` 与 `userStore.userInfo.id` 数字比较。
- `:1563`:`canWatchCallEntry = isAppointmentActiveForVideo && isAssignedAssistant && hasPermission(['tcm.diagnosis/watchCall'])`。
- `:1971`:从 `appointments` 中找 `status==1` 的有效挂号,视频条件另要求 `canAppointmentVideoCall(type)`。旧空类型按视频处理,`text/phone/unknown` 不允许旁观视频,见 `admin/src/utils/appointment-type.ts:12`。
- `:1579`:有入口资格且状态为 `live` 或 `pending_room` 时显示“进入旁观”;pending 状态点击后仅提示等待。
- `:1591`:点击重新校验入口资格,并要求状态必须为 `live`,才打开旁观弹窗。
- `:1609`:无入口资格时显示公开状态:`live=通话中`、`pending_room=接通中`、`none=—`,其他使用服务端 label。
H5:`admin/src/views/tcm/diagnosis/index_h5.vue`。
- `:210`、`:1035`、`:1040`、`:1044`、`:1075` 分别对应显示、指派关系、资格、状态和点击校验;规则与 PC 一致。
- `:1068`:仅有入口资格,或状态处于 live/pending 时才显示旁观条;无资格时为只读状态文案。与 PC 的小差别是 none 状态文本为空,且整条通常不显示。
`hasPermission` 对 `"*"` 放行,但**不会跳过助理归属**:`admin/src/utils/perm.ts:3`。
## 服务端旁观保护与超管
`server/app/adminapi/controller/tcm/DiagnosisController.php:609` 的 `watchCall()` 从 GET 读取 diagnosis_id,但把调用者身份绑定为控制器的当前登录 `$this->adminId`,不采用客户端传来的 admin_id。
`server/app/adminapi/logic/tcm/DiagnosisLogic.php:2678` 的 `getAssistantWatchRoomParams()`:
1. diagnosis_id/admin_id 必须为正数。
2. 诊单必须存在且未删除。
3. `diag.assistant_id` 必须等于 admin_id,否则“仅本诊单指派的医助可旁观该通话”。
4. 获取该诊单 `status=1` 的最新 CallRecord,room_id 必须存在,否则要求等医生接通。
5. 返回当前登录账号对应的 `doctor_{adminId}` UserSig 和房间参数。
**此函数没有读取角色或 root,也没有主动重复前端的有效视频挂号校验。** 角色/menu 权限由通用鉴权负责;归属和进行中房间由业务逻辑负责。`AuthMiddleware.php:70` 的 root 放行发生在通用菜单鉴权层,不能使 watchCall 业务层的 assistant_id 检查失效。非 root 的菜单权限检查还受菜单路由登记情况影响,不宜把权限名称硬编码成额外、已存在的业务断言。
对照通话/聊天接口:桌面 `get_call_ticket()` 调用 `tcm.diagnosis/getCallSignature`(`app/src/doctor_workstation/services/repository.py:2696`)。该接口经 `DiagnosisLogic.php:898` 的 `canManageDiagnosis` 校验患者/诊单访问关系,并解析挂号通话策略;它不是旁观资格接口。`canManageDiagnosis` 使用 `MyPatientLogic`:root 可看全部、管理角色用团队数据范围、一线医生/医助按本人接诊/归属关系;这不意味着医生能从通用问诊列表旁观其他医生。
## 桌面入口缺口
| 环节 | 当前实现 | 位置 |
| --- | --- | --- |
| 列能力 | `action_policy['video_call'] = _native_video_capable`,只证明 repository 有 get_call_ticket/start_call/bind_call_room/end_call 方法 | `app/src/doctor_workstation/ui/pages/consultations.py:177`、`:1504` |
| 表格按钮 | 有能力 + 已预约 +(图文类型或 live),显示“图文沟通”或“进入视频问诊”;只用 ID 完整性控制 enabled | `app/src/doctor_workstation/ui/diagnosis_index_widgets.py:1784` |
| 顶部通话 | 可见性只检查原生能力,enabled 来自兼容 video_button | `app/src/doctor_workstation/ui/pages/consultations.py:1385`、`:3056` |
| 兼容按钮 | “进入视频问诊”仍存在于隐藏兼容操作容器,可通过程序调用 | `app/src/doctor_workstation/ui/pages/consultations.py:1467` |
| 行点击 | 选择该行后进入统一 `_request_video()` | `app/src/doctor_workstation/ui/pages/consultations.py:2366` |
| 最终点击处理 | 检查能力、已预约、live(text 例外)、ID 完整,然后发出 video_requested;没有医生角色/助理归属/旁观权限校验 | `app/src/doctor_workstation/ui/pages/consultations.py:3499` |
| 发出内容 | `source='consultations'`、`mode='im'`,注释明确此页为助理/管理员接收现有医生邀请 | `app/src/doctor_workstation/ui/pages/consultations.py:288` |
导航目前“我的患者”为 `PatientsPage`,“问诊列表”为 `ConsultationsPage`,两者不同;本次所述“视频问诊”列实际在后者 `DiagnosisTableHost`,见 `app/src/doctor_workstation/ui/shell.py:159`。本次无需扩大为其他医生主动发起通话入口的限制。
## 本次新要求对应的修复边界
- 医生角色集合包含 `1` 时,问诊列表的视频列不显示可点击“进入视频问诊”,并在最终点击处理再次拦截,避免工具栏、隐藏兼容按钮、信号或直接调用绕过。
- 混合 `[1,2]`、`[1,3]` 等账号也属于含医生角色。用户要求是医生角色禁用该视频入口,不能依赖它是否还有其他授权角色或 `"*"` 权限来恢复入口。
- 已有后台没有“root 优先于医生拒绝”的列表旁观特例;从本次“医生角色不允许”的文字看,含医生角色的账号即使还拥有 `"*"` 也不应因通配权限恢复入口。若实现保留 root 特例,应明确这是另行产品规则,而非从现有 watchCall 业务代码推导出的例外。
- 医生主动接诊/接诊台的视频功能不在本次旁观入口限制范围内。
- 表格“图文沟通”共用同一 action_policy 和 `_request_video()`,用户反馈针对视频入口;若不另有要求,应保留 text 预约正常图文入口,避免把 `video_call=False` 当作全部沟通能力开关。
- 对不受限制的非医生账号,本次改动可保留既有流程;若还要完整对齐后台的指派助理 + watchCall 权限,则属于另外一项权限收敛,需要单独决定与验证,不能把现有 IM 接收入口直接替换成旁观拉流实现。
本文件为静态核对结果,无运行或生产授权验证结论。
@@ -0,0 +1,32 @@
# 处方 AI 一致度可见范围:后台实现
日期:2026-10-17。范围仅后台新文件与本报告、验证截图;没有修改 `server/` 或桌面 `app/`,没有提交、发布或连接生产接口。已读根 `AGENTS.md`,当前工作区无 `.trellis/`。
## 交付文件
- `admin/src/views/setting/prescription_ai_visibility/index.vue`:独立配置页面,沿用现有 Element Plus 设置表单与底部保存栏。
- `admin/src/api/setting/prescription_ai_visibility.ts`:专用 capabilities、配置读取/保存、角色与用户选项接口及类型;对配置响应进行运行时校验。
- `admin/tests/prescription-ai-visibility.test.cjs`:8 项 Node 测试,编译真实 Vue SFC、运行真实 setup 状态,并模拟 API;不调用真实后端。
- `artifacts/prescription-ai-visibility-admin-local.png`:本地模拟浏览器截图。
## 行为
角色多选可搜索名称;用户多选标签包含姓名与账号,因此均可搜索。选项完全来自 `tcm.prescriptionAi/visibilityOptions`,不额外请求角色列表或管理员列表接口。当前已配置但不在活跃选项中的 ID 以明确占位标签保留;只有用户显式移除后保存才取消该项。
页面明确说明:角色或指定用户任一命中即可;两份名单均为空时所有账号(包括 root)均隐藏一致度、逐味对照与一致度统计;root 可管理策略,但管理权限不自动赋予查看权。AI 报告继续按原有权限开放,已有报告不受影响,原有患者/处方数据范围继续适用。
先读取 `capabilities.can_manage_visibility`,只有严格为 `true` 才读取配置与选项。两者全部成功后才填入并启用表单;首次失败不展示空表单,重新加载失败保留既有选择并禁用保存。保存期间禁用输入、重载及重复提交;失败保留当前选择并给出重试/重新读取提示;成功应用后端返回的规范化名单。配置响应缺字段、含非数字/非正整数 ID 等异常会拒绝,不会降级成可保存的空策略。
后端菜单需指向 `setting/prescription_ai_visibility/index`,管理权限为 `tcm.prescriptionAi/manageVisibility`;菜单迁移及服务端自鉴权由后端任务实现。
## 验证
1. `node --test tests/prescription-ai-visibility.test.cjs`:8/8 通过。覆盖固定接口契约、配置坏响应、pending/失败禁止保存、选项失败不部分覆盖策略、缺失 ID 保留、姓名账号标签、空名单与规范化回显、防重复提交、保存失败保留、管理与查看能力分离、真实模板编译。
2. 对新增 API、Vue 页面、测试执行 Prettier `--check`:通过。
3. 针对新增 API 与页面的临时 `vue-tsc` 项目(严格模板,使用真实 Element Plus/Vue 类型,隔离 request/feedback 的外部依赖):通过,无类型错误。
4. 全后台 `vue-tsc --noEmit --pretty false --incremental false --composite false`:退出 2,报告 61 项新文件范围外的错误;没有错误涉及 `prescription_ai_visibility`。完整日志位于 `%TEMP%/zyt-prescription-ai-visibility-vue-tsc.log`。本任务没有扩大修改范围修复这些错误。
5. 独立 `127.0.0.1:5568` Vite 浏览器模拟,使用真实新页面、Element Plus、后台样式与 FooterBtns;所有 API 均映射到本地假数据模块,没有加载主应用或真实网络请求。确认成功读取、缺失 ID 标签、账号 `li.assistant` 搜索、保存 pending 禁用、保存成功提示、加载失败禁用保存、保存失败保留修改、空名单警示及无管理权限状态。
未运行 `npm run build`,避免其自带 release 脚本;实际 Vite 浏览器编译已成功。临时验证 harness 位于忽略的 `admin/node_modules/.cache/prescription-ai-visibility/`,不属于业务交付。
![本地模拟配置页](./prescription-ai-visibility-admin-local.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,63 @@
# 处方 AI 一致度授权:后端实施记录
日期:2026-10-17。仅修改 server 后端及本报告;未提交、未部署、未连接生产数据库或模型服务。
## 行为
- 新服务 `server/app/common/service/prescriptionai/PrescriptionAiVisibility.php` 使用 ConfigService 一条 JSON:`type=prescription_ai_visibility` / `name=agreement_policy`,字段仅 `role_ids:int[]`、`admin_ids:int[]`。
- 默认空、缺失或格式错误策略全部不可查看一致度。角色或单用户命中任一即可;查看没有 root、通配符或管理权限豁免。
- 账号每次重新检查 `disable=0`、`delete_time IS NULL`;角色关系实时查询并排除软删除角色。配置读取无长期缓存,授权或撤销下次请求立即生效。
- 独立管理权限为 `tcm.prescriptionAi/manageVisibility`。root 可管理,其余账号须持此权限;三个管理入口均在服务端统一验证,防止未注册路由中间件默认放行产生越权。所有入口仍由控制器重载有效账号,拒绝未知参数与错误 HTTP 方法。
- statuses 顶层及各 item、reports 顶层及各 batch、detail 顶层均返回 `agreement_visible`。未授权时共享 Logic 投影删除整个 comparison、扁平 score/herb_score、比较状态/原因/版本、比较类型/基线等派生数据;失败模型生成错误原因保留。
- full 模型正文使用固定字段及嵌套结构投影,拒绝历史正文未知 raw/result/score、调试对象、伪造 progress 等旁路。报告、候选方案、来源与覆盖说明、进度、医生原方、复核意见保留。
- statistics 在扫描/聚合之前拒绝没有白名单资格的账号;原有动作 RBAC 和处方/患者/来源范围校验仍执行。后台分析、比较、手动重试与复核的既有权限流程未加指标限制。
## 固定接口
| HTTP | `tcm.prescriptionAi/` 后 action | 数据 |
| --- | --- | --- |
| GET | capabilities | `{agreement_visible:boolean, can_manage_visibility:boolean}`,登录账号自查 |
| GET | getVisibilityConfig | `{role_ids:number[],admin_ids:number[]}`,仅管理者 |
| POST | setVisibilityConfig | 接收且仅接收上述两数组,返回去重排序后的策略;仅管理者 |
| GET | visibilityOptions | `{roles:[{id,name}],admins:[{id,name,account}]}`,仅管理者 |
选项及保存列表上界:500 个角色、5000 个后台用户。选项只返回活跃记录,超过上界明确报错,不静默截断。保存只验证新提交的名单,因此失效 ID 可从旧名单中删除后保存,不会被旧数据阻塞。角色无 disable/status 列,按软删除判断。
## 迁移
新增 `server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql`:
- 幂等创建系统设置下 type C 的“处方AI一致度授权”菜单,perms 为 `tcm.prescriptionAi/manageVisibility`,component 为 `setting/prescription_ai_visibility/index`。
- 幂等预置一条空 JSON 策略,不自动授权任何非 root 角色,不填入查看白名单。
- 不新增业务表,不改变既有报告或队列数据。上线时需要先执行该迁移;本次仅在隔离测试数据库中执行并重放验证。
## 修改文件
1. `server/app/common/service/prescriptionai/PrescriptionAiVisibility.php`(新):策略、管理校验、实时匹配、有限选项、输出投影。
2. `server/app/adminapi/controller/tcm/PrescriptionAiController.php`:四个接口。
3. `server/app/adminapi/logic/tcm/PrescriptionAiLogic.php`:统一能力标记、指标裁剪、统计授权、正文投影。
4. `server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql`(新):幂等菜单及空策略。
5. `server/tests/PrescriptionAiVisibilityTest.php`(新):无应用初始化的纯离线策略/投影回归。
6. `server/tests/PrescriptionAiQueueTest.php`:隔离真实 ORM、Cipher、Logic、Controller 及迁移回归。
## 验证结果
PHP 8.2.9;新增/修改的 5 个 PHP 文件全部 `php -l` 通过。`git diff --check -- server` 通过。全仓 diffcheck 中另有并行桌面端文件换行问题,已通知主代理,本任务未修改 app 文件。
纯离线:
- `php server/tests/PrescriptionAiVisibilityTest.php`:22 checks。
- `php server/tests/PrescriptionAiProgressTest.php`:38 checks。
- `php server/tests/PrescriptionAiComparisonTest.php`:266 checks。
- `php server/tests/PrescriptionAiStatisticsTest.php`:64 checks。
- `php server/tests/PrescriptionAiConfigurationTest.php`:通过。
隔离 MySQL:主代理建立的 `127.0.0.1:33447`,root 空密码,显式 `ZYT_AI_TEST_MYSQL_PORT=33447`。这些脚本不 App::initialize,不读生产 .env,创建并最终删除各自唯一 `prescription_ai_test_*` 临时库:
- `php server/tests/PrescriptionAiQueueTest.php`:113 checks。
- `php server/tests/PrescriptionAiQueueTest.php --legacy-progress-schema`:111 checks。
- `php server/tests/PrescriptionAiPipelineTest.php`:125 checks。
新增集成覆盖:默认空名单含 root 拒绝查看、root 管理与查看分离、独立非 root 管理权限、GET/POST/未知字段、伪 root/星号 token、配置越权拒绝、角色/个人任一授权、多角色、即时撤销、软删除角色、禁用/删除账号、无效策略、活跃最小选项与超限、菜单禁用即时撤销、allowlist 不替代动作 RBAC、真实加密旧正文注入及嵌套旁路、三个读取出口完整裁剪、统计拒绝、保留报告/候选/原方/复核/失败原因、限制指标后仍可重试/复核、迁移执行两次。
报告是实现交付,不表示生产迁移已经执行。测试 MySQL 进程由主代理负责最终关闭。
@@ -0,0 +1,118 @@
# 处方 AI 一致度授权:后端只读研究
日期:2026-10-17。范围:D:/web/zyt 本地源码;没有调用生产接口、连接数据库、调用模型或运行会初始化业务配置的测试。只新增本报告,未修改业务代码。
已读取根 AGENTS.md;工作区不存在 `.trellis/`。本文最初保留“仅一致度 / 整个 AI 模块”两种边界,随后按主代理明确的实现方向整理:**仅一致度独立授权,保留 AI 报告;角色或单个后台用户命中即可;默认空名单;root 可管理,但查看一致度也必须命中白名单。**
## 1. 现有权限模型
- `server/app/adminapi/logic/auth/AuthLogic.php:91`:`getAuthByAdminId` 经 `admin_role -> system_role_menu -> system_menu` 取权限,忽略 `is_disable=1` 的菜单。多角色权限取并集。
- `server/app/common/model/auth/Admin.php:52`:`role_id` 是 `AdminRole` 关联查询结果,不是单值;新策略必须支持任一角色命中。
- 没有发现通用“用户直接拥有菜单权限”表或管理机制。`AuthLogic:109` 会追加 `QywxPromotionOperatorAccess` 专用共享页面权限,是业务特例,不宜为了一个字段的可见性扩展全局 RBAC。
- `server/app/common/service/qywx/QywxPromotionOperatorAccess.php:38`:可参考其服务端动态单用户判断、软删除过滤、菜单启用检查;不应复用该业务表。
- `server/app/common/cache/AdminAuthCache.php:73`:菜单权限缓存最长 3600 秒;`:122` 清理具体账号缓存键。若本功能仅独立策略读取而不往 AuthLogic 注入权限,便不需要变更该全局缓存;每次请求实时读取策略可立即撤销。
- `server/app/adminapi/http/middleware/AuthMiddleware.php:129` 附近:未注册为菜单的 URI 有默认放行逻辑。因此**新配置接口必须自带服务端管理鉴权**,不能只加菜单或依赖前端 `v-perms`。
一致度的最终允许条件应是:账号有效 AND 现有该动作 RBAC 权限通过 AND 原有处方/患者/来源快照数据范围通过 AND 一致度白名单命中。名单只增加指标可见资格,不授予患者或报告访问权限。root 仍按既有逻辑拥有普通 AI 动作权限,但不能绕过新的指标白名单。
## 2. 可直接采用的存储与接口形态
`server/app/common/service/ConfigService.php:32` 支持将数组 JSON 存到 `config` 表;`:65` 读取并反序列化。已有 `setting/DesktopWorkstationController.php` 与 `DesktopWorkstationLogic.php:39` 展示独立 getConfig/setConfig 的配置管理范式。
建议仅存一条对象(名字为候选,不代表已定路由):
```json
{
"role_ids": [1, 7],
"admin_ids": [23, 91]
}
```
- 候选存储:`type=prescription_ai_visibility`, `name=agreement_policy`。
- 缺失、格式错误、空名单均解释为不可查看一致度,不回退到“全部可见”。对象包含两个键,避免 ConfigService 将空数组视为假值并走默认配置的语义造成意外。
- 写入要求两个字段均为数组;元素须正整数/规范十进制整数字符串,拒绝对象、浮点、布尔、负数及字符串拼接;去重排序,验证角色/账号实际有效;限制数量及拒绝未知字段。不要静默把任意字符串 `intval` 后授予权限。
- 一次覆盖单 JSON,避免分别保存角色、用户导致半更新。`config` 安装表没有 `(type,name)` 唯一索引(`server/public/install/db/like.sql:142`),可由幂等迁移预置这一条空策略,避免首次并发创建;不要为本功能顺带对全表增加唯一约束。
- 不存模型密钥、不新增业务表,不修改 `prescription_analysis.enabled` 或后台工作队列设置。
候选 API:
| 接口 | 返回/接收字段 | 必须执行的服务端鉴权 |
| --- | --- | --- |
| GET `tcm.prescriptionAi/capabilities` | `{agreement_visible:boolean, can_manage_agreement_visibility:boolean}` | 登录且实时有效账号;只返回当前账号布尔能力,不泄露名单 |
| GET 专用 `getVisibilityConfig` | `{role_ids:number[], admin_ids:number[]}` | root 或独立管理权限 |
| POST 专用 `setVisibilityConfig` | 严格上述两数组;返回规范化后的策略 | 同一管理鉴权;必须 POST |
| GET 专用 `visibilityOptions` | `roles:[{id,name}]`, `admins:[{id,name,account,role_ids}]`,可增加有界搜索/分页 | 同一管理鉴权;只投影必要字段 |
配置 API 既可设在 `PrescriptionAiController`,也可采用 `setting.prescriptionAiVisibility/getConfig|setConfig|options`;关键是最终 action/perms 一致且逻辑层鉴权。若采用一个专用“管理一致度授权”权限,所有 get/set/options 均调用共同 `requireManage`,默认不向普通角色自动授予。不要把名单加到 `ConfigController::getConfig`:该控制器 `server/app/adminapi/controller/ConfigController.php:27` 明确 `notNeedLogin=['getConfig','dict']`。
## 3. 角色和用户选项:已有 API 与 active 字段
| 对象 | 当前可用 API | 实现与约束 |
| --- | --- | --- |
| 系统角色 | GET `/auth.role/all`,前端 `roleAll()` | `RoleController:118` -> `RoleLogic:181`;`SystemRole` 使用 SoftDelete,自动排除软删除;按 sort/id 降序 |
| 后台用户 | GET `/auth.admin/lists?exclude_disabled=1&page_no=1&page_size=...`,前端 `adminLists()` | `AdminLists:165` 排除 disable;返回 `lists/count` 的列表封装,支持 name/account 模糊搜索 |
| 后台全部用户 | 前端有 `adminAll()` wrapper `/auth.admin/all` | **后台 AdminController 没有 all action,不可直接使用此 wrapper** |
模型事实:
- `Admin`:`delete_time IS NULL` 且 `disable=0` 才是活跃账号,见 `PrescriptionAiAccess::actor` 第 15 行;必要字段 `id/name/account/root`;角色经 `admin_role` 关联。
- `SystemRole`:`delete_time IS NULL`,没有发现 `disable` 或 `status` 字段;`server/public/install/db/like.sql:655` 及现有迁移仅见额外 `data_scope`。**不要对角色添加 `disable=0` 条件。**
- `system_menu.is_disable` 是菜单状态,与系统角色不同。
- `AdminLists` 返回手机号、登录 IP、头像等较多字段,授权下拉建议在新管理接口中直接复用模型查询,并只投影 id/name/account/role_ids。
- `AdminLists::queryWhere` 一般默认不套 data_scope,只有显式 `apply_data_scope=1` 才启用;管理授权选项须由共同管理鉴权保护。其 role_id 过滤在该角色无成员时不会强制空集,因此若需角色联动筛选,专用 options 应正确处理空集。
- `BaseDataLists.php:102`:`page_type=1` 表示分页,其它值只是返回最大条数,不是真正无限制;不能假定一次 `page_type=0` 可取完所有后台用户。
- 即使保存时验证过 ID,查看时也只匹配实时有效账号与有效角色;被禁用/软删除账号不可使用旧 token,角色变更不可依赖 token 的历史 role_id。
## 4. 现有处方 AI 数据保护与必须覆盖的出口
`server/app/adminapi/controller/tcm/PrescriptionAiController.php:71` 的 handle 拒绝未知入参,校验 GET/POST,调用 `PrescriptionAiAccess::actor` 重新加载有效账号,不直接信任请求内角色。`PrescriptionAiAccess.php` 还分别校验处方、诊单及来源诊单范围。应保留这些保护,并在新增 capabilities/配置入口也使用同等实时账号检查。
`PrescriptionAiLogic.php` 的重要出口:
| 入口 | 当前信息 | 仅一致度授权时的处理 |
| --- | --- | --- |
| `statuses:22` | 列表轮询调用 `models(...,false)`,有 `score/herb_score/comparison_status/comparison_reason_code/reason/algorithm_version/dictionary_version` | 返回 `agreement_visible`;未授权时使用统一投影去掉所有比较字段,保留模型生成状态/进度 |
| `reports:62` | 历史列表同样 `models(...,false)`,不能只保护 statuses | 同上;继续先按 visibleBatch 过滤再分页,不泄漏隐藏来源计数 |
| `detail:107` | `models(...,true)` 解密正文,又包含医生原方快照 | 未授权仍保留报告、候选方及医生原方(按保留报告的当前范围),统一去掉比较数据;不要返回原始解密 body |
| `statistics:206` | 均值、中位数、直方图、版本分层、paired_count 等全是这一指标的衍生信息 | 新白名单检查失败时直接抛 DomainException;必须在扫描/聚合前拒绝;不以 mean=null 等方式继续返回统计 |
| `regenerate/retry/review` | 返回批次状态或 saved,没有返回比较正文 | 仅指标限制不应阻止既有报告操作;仍保留现有动作 RBAC、处方与来源范围 |
具体易漏点:
1. `models:285` 的数据库摘要列不仅 `score`,还包括 `herb_score`、比较状态/原因与版本。
2. `models:302-315` 现为 `array_merge(task,result,body,progress)`。加一条 `unset($model['score'])` 不够:解密 body 里的 `comparison` 含同一分数,并可能包含历史未知字段或顶层别名;原始 body 不应不加选择地 merge 到对外模型。
3. `Store::complete:348` 当前加密 body 的固定键是 `report,candidate,comparison,coverage,usage,knowledge_source,ancient_book_sources`,不是必须输出的完整 API 对象。建议按这些已知业务字段构造响应,单独按能力投影 comparison。这样历史 body 即使含 `score`, `raw`, `result`, `response`, `body`, `comparison_dump` 等未知附加键,也不会直接公开。
4. `Comparison::compare:126` 的完整 comparison 除分数外还包含 `rows`、药量 ratio/贡献、`matched_count`、doctor/candidate_count、`usage_differences` 和 `normalization` 中的 denominator/contribution_sum,足以反推指标。未授权应移除**整个 comparison 对象**。
5. `models:310` 的扁平 `reason` 取自 `body.comparison.reason`,也是比较派生信息;无结果任务的 `reason` 则是生成错误信息。应区别处理或在统一投影中使用保留的 `error_message` 展示模型失败原因,避免比较 reason 保留下来。
6. `formatBatch:348` 还输出 `comparison_type`, `baseline_eligible`, `baseline_exclusion_reasons`,也应从未授权响应中移除。`coverage_status/source_summary/missing` 属于报告资料覆盖信息,可保留。
7. 不把 `body_cipher` / `context_cipher` / `progress_cipher` / 原始 AI 调用响应增加到任何返回值。现有 Progress 采用固定字段投影并本地生成 label/notice,可继续复用;比较阶段名称本身不包含指标结果。
8. 受保护字段应该被完全省略,而非伪装为 0 或“未生成”;布尔 `agreement_visible=false` 告诉 UI 不渲染指标功能。
保留医生原方与候选方案意味着用户仍可人工比较药味;本需求保护的是系统计算的一致度与相关比较/统计数据。若用户后续改为整个模块不可见,则应在所有 AI 动作的业务入口一并拒绝,前端隐藏全部入口;不能把当前字段裁剪方案误当作整模块授权。
## 5. MCP 与其它旁路
`server/app/mcp/catalog/review/tcm.php:185` 已开放 `detail/reports/statuses/statistics`,并说明依赖各入口内置数据范围保护。必须将授权/投影放在共享业务层,HTTP 控制器最终输出前才过滤会遗漏直接 Logic 测试或其它调用通路。新管理配置接口不应自动加入开放 MCP catalog。
对 `server/app` 的引用检索未发现这四个入口之外直接返回 `prescription_ai_result/body_cipher` 的业务出口;Store/Worker 为写入与后台计算。仍应在实现完成后检索新增调用与 MCP generated 元数据,确认统计内置保护说明匹配新规则。
## 6. menu 迁移惯例
- 当前处方 AI 迁移 `server/database/migrations/2026_09_09_prescription_ai_analysis.sql:132`:通过现有处方列表 perms 查父级;`INSERT ... SELECT ... WHERE NOT EXISTS(perms)` 幂等创建 type A 动作。
- 原有 7 个动作分别注册 statuses/reports/detail/regenerate/retry/review/statistics;读权限继承自 patientAiReports,写权限继承自 generatePatientAiReport。**本需求不应把管理授权能力自动继承给这些读写角色。**
- 现有设置页范式 `2026_08_21_desktop_workstation_update_menu.sql`:查询系统设置 type M 父级;type C 页面使用 getConfig perms/path/component,子级 type A 保存动作;时间戳 UNIX_TIMESTAMP,空 path/component 给动作,is_show=1/is_disable=0,事务包裹。
- 可参考其幂等菜单写法,但本需求管理菜单默认 root 可用;非 root 只有明确授予独立管理权限后可管理。不要复制“所有设置角色自动继承”的授予部分。
- 新 capabilities 仅返回当前身份能力且必须登录,是否注册菜单权限需与中间件设计一致;避免将自查接口变成必须先拥有一致度权限才可调用的循环依赖。
## 7. 可复用测试模式(本次未运行)
1. **纯函数/契约测试**:`server/tests/PrescriptionAiProgressTest.php`。直接 require autoload、不初始化 App,用固定 fixture 检查允许字段;策略匹配、规范化、投影最适合此方式。
2. **无环境隔离桩**:`server/tests/DiagnosisValidateEditSceneTest.php:9` 创建 `new think\App()` 但不 initialize;在真实类加载前以 class_alias 安装内存模型/DB/Auth double。适合验证真实入口会拒绝未授权 statistics 与配置写入,避免初始化业务 .env。
3. **真实加密/数据库 API 集成**:`server/tests/PrescriptionAiQueueTest.php:5` 明确禁止 initialize,必须显式 `ZYT_AI_TEST_MYSQL_PORT`,只连接 `127.0.0.1` 空密码临时 MySQL,数据库名只允许 `prescription_ai_test_[a-f0-9]{12}`,显式注入隔离 DB/config 并最终清理;现有 fixture 已覆盖 statuses/reports/detail/statistics、结果 Cipher、失败模型和旧 body 注入。扩展时可添加 config/system_role 夹具、非 root 多角色及账号。
4. **迁移幂等**:QueueTest 重放 migration 两次并检查菜单数,可直接复用新菜单和空策略迁移的验证方式。
5. **多角色缓存边界**:`AdminMultiRoleRegressionTest.php` 说明角色 ID 去重排序及撤销角色后 token/cache 失效;新指标策略应按实时 role_id 与新策略判断,不从旧 token 缓存白名单结果。
6. **本地配置文件测试**:`PrescriptionAiConfigurationTest.php` 使用自定义 env fixture,绝不 bootstrap。可用于确认本次没有意外改变模型启用/生成配置。
最低必要用例:默认空/非法策略全部不可见(包括 root);只选角色/只选用户/任一匹配/多角色并集/其它账号不匹配;禁用或软删除账号、删除角色、实时撤销;未授权仍能读既有报告内容但 statuses/reports/detail 任何层均不存在比较字段或未知原始 body;授权用户数值保持不变;未授权 statistics 拒绝而不聚合;非管理者不能读名单、改名单或列选项;root 可管理但不会因此获得 view;已有处方/来源权限撤销仍拒绝报告。加入带唯一哨兵的嵌套 comparison/unknown raw body fixture,序列化整个响应检验哨兵不存在,不能只验证 UI 字段或顶层 score。
不建议直接运行会 `App::initialize()` 的旧测试(例如 PrescriptionAiSecretSafetyTest)来证明本特性;本功能可以完全使用上述隔离模式完成有效验证。
@@ -0,0 +1,57 @@
# 处方 AI 一致度可见范围:桌面实现交接
日期:2026-10-17。范围仅 `app/` 与本报告/离线截图;未修改 `admin/`、`server/`,未 commit、部署或调用生产接口。
## 完成行为
- 新增 `DoctorRepository` / `RemoteDoctorRepository.get_prescription_ai_capabilities()`,GET `tcm.prescriptionAi/capabilities`。
- 一致度只接受服务端明确的 `agreement_visible is True`。没有本地 root、角色 ID、`*`、管理策略能力的豁免;缺失字段、非布尔 true、能力请求失败均拒绝。
- 列表把 AI 状态与第 12 列一致度分离:状态仍按现有权限及分析开关加载,能力查询不阻塞状态请求。分数另需 capability + statuses 顶层 flag + 每批次 flag;统计按钮另需原 `statistics` 权限。
- 列表隐藏/重显、刷新都会重查能力;重新授权会重新取已完成批次的分数,避免从已清理的缓存展示空白。
- 共用报告(包括诊单入口)要求 reports 顶层、历史批次、detail 均明确授权。一致度受限时仍保留报告正文、模型筛选、AI 候选原文、来源与资料缺口、处理进度、历史批次选择与分页、重新分析、重试和复核。
- 受限时总览使用原有复核清单作为“报告复核”安全骨架,隐藏结论、组成归属、剂量图、风险剂量图及差异/共识计数。原“候选与逐味”导航改为“AI 候选处方”,跳到现有候选原文页;逐味页、比较原文和历史趋势不可导航进入。
- 顶部保留模型状态、资料状态与失败重试行,只隐藏分数、重合度、差值和仪表刻度;因此没有丢失原来附着在仪表卡上的重试按钮。
- 未授权数据进入缓存前清除 comparison、score、herb_score、比较状态/类型、算法版本及相关聚合元数据。撤权和读取失败同时清空旧数字、图表数据、比较 HTML、历史图/表、tooltip、accessibility 值和派生计数。
- 请求 token / generation 拒绝旧异步响应恢复分数;主题重建保持能力状态和复核草稿。
- 统计对话框直接构造也先请求 capability;未明确授权不发 statistics 请求。每次查询先清图,能力/统计失败、隐藏窗口、旧响应都会清理或拒绝旧结果。
## 修改文件
- `app/src/doctor_workstation/services/repository.py`:能力接口协议及远程实现。
- `app/src/doctor_workstation/ui/pages/prescriptions.py`:独立列/统计能力、列表缓存清理、异步 guard、受限入口文案。
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py`:共享数据清理、报告各入口统一切换、统计 capability 门禁。
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py`:仪表指标切换,同时保留状态与重试。
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py`:复核安全骨架;旧行先清文本、hide、detach、delete,防窗口瞬闪和残留轴控件。
- `app/tests/test_issued_prescription_ai.py`:既有授权 fixture 明确 true;新增拒绝/缺失字段/非布尔 true、root 不豁免、撤权、失败、旧回调、主题、列表重显与统计直接入口回归。
- `app/scripts/render_issued_prescription_ai.py`:原有授权预览 fixture 明确 true。
- `app/scripts/render_prescription_ai_visibility.py`:可重复生成授权/受限离线截图。
变更行使用 LF,未改变的行按 HEAD 保留原始行尾 bytes,避免仓库原有混合换行导致整文件 diff。
## 验证
1. 核心 AI 测试,249 项通过(独立进程):
`app/.venv/Scripts/python.exe -m pytest app/tests/test_issued_prescription_ai.py app/tests/test_issued_prescription_ai_workspace.py app/tests/test_issued_prescription_ai_pages.py app/tests/test_issued_prescription_ai_comparison.py -q --tb=short`
2. 外围权限与处方界面测试,32 项通过(独立进程):
`app/.venv/Scripts/python.exe -m pytest app/tests/test_permissions.py app/tests/test_prescription_security_ui.py app/tests/test_prescription_list_density.py app/tests/test_prescriptions_tech_blue.py -q --tb=short`
3. Ruff 对上述 8 个变更 Python 文件检查通过。
4. `git diff --check -- app` 通过。
5. 新截图脚本离线执行通过;已目视检查受限列表、正文、候选原文、复核/重试和统计拒绝截图。
联合运行这 281 项时,核心及前面外围测试共 272 项无失败后,最后一组 `ShellWindow` 交互测试出现 Qt 同进程状态阻塞,已中断该联合进程;同一外围组单独执行通过。先前定位到的两个真实回归(旧控件瞬闪、旧轴残留)均已修复,相关定向测试通过。本交接采用上述两个独立进程验证结果,不宣称联合进程完整通过。
## 截图
目录:`artifacts/prescription-ai-visibility/`。全部为内存演示数据,无生产读取。
- `list-authorized.png` / `list-restricted.png`
- `report-authorized.png` / `report-restricted.png`
- `review-restricted.png`:复核清单、表单、保存与模型重试仍可用。
- `candidate-restricted.png`:候选原文可读,无贡献/一致度。
- `statistics-restricted.png`:直接统计入口拒绝,空图表且未请求统计。
服务端仍需按既定策略在 read API 屏蔽结构化比较,并在 statistics 强制校验;桌面不会从角色或登录快照重建授权。授权策略变更在下一次能力/状态/报告读取时更新显示,当前没有新增服务端推送通道。
@@ -0,0 +1,113 @@
# 已开处方 AI 一致度可见范围:前端只读研究
日期:2026-10-17。范围:桌面 `app/`、后台 `admin/` 的入口、登录元数据、配置承载点和测试;未修改业务代码,未调用任何生产接口。已阅读根目录 `AGENTS.md`;当前工作区 `.trellis/workflow.md` 不存在。
主任务已确定实施默认:仅一致度独立授权、保留报告;新独立设置页;角色或个人任一命中允许;空名单全隐藏;root 可管理策略,但查看分数也必须命中名单。API 使用 `GET tcm.prescriptionAi/capabilities -> {agreement_visible: bool}`,statuses/reports 顶层及每批次附该 flag,detail 顶层附 flag;未授权剔除 score/herb_score/comparison,statistics 拒绝。下文早期建议中的命名/承载页/初始行为均以上述约定为准。
## 结论
- 截图对应桌面 `PrescriptionsPage` 的第 12 列 `与 AI 一致度`。当前列显隐只跟随服务端 AI 总开关 `enabled`,没有按角色或个人控制一致度的能力。
- 现有权限只拆分 `statuses/reports/detail/statistics/regenerate/retry/review`。`reports + detail` 共同允许打开报告,不区分“能看报告”和“能看分数”。单靠收回现有权限无法实现“保留 AI 报告、隐藏一致度”。
- 仅隐藏列表列不完整:报告顶部双模型分数、逐味原文、总览结论、历史趋势、医生统计也展示一致度。
- 桌面已经从 `auth.admin/mySelf` 得到用户 ID、多个角色 ID、root 和权限集合;但运行中的会话权限不适合单独承担可见范围判定。推荐服务端每次读 AI 数据返回当前账号的明确 capability,并在响应中屏蔽未授权分数;桌面统一使用这个判定渲染。
- 后台没有现成处方 AI 配置页。最小配置入口可放现有“医生工作站”设置页单独配置区;该页目前专门管理升级包,建议新建独立读写配置接口,而不是把授权策略挂到其免登录更新检查响应。
## 桌面入口与显示面
| 位置 | 当前行为 | 仅一致度控制的接入点 |
| --- | --- | --- |
| `app/src/doctor_workstation/ui/pages/prescriptions.py:1050` | 顶部 `AI 一致度统计`;当前只查 `tcm.prescriptionAi/statistics` | 叠加一致度 capability;响应拒绝时清空既有统计 |
| 同文件 `:1200`、`:1583`、`:1765`、`:1773` | 工具栏 AI 报告、每行报告按钮、右键 `AI 报告 / 逐味对照` | 若保留报告应继续可用;文案可缩成 `AI 报告`,避免暗示全部对比可见 |
| 同文件 `:1255`–`:1273` | 第 11 列 AI 状态、第 12 列与 AI 一致度;两列初始隐藏 | 分离两列显隐,不能继续用 `_set_ai_columns` 同时切换 |
| 同文件 `:1289`、`:1344` | 单击两列打开同一报告;自定义 cell widget 的事件过滤也处理两列 | 隐藏分数时保留状态打开报告的行为 |
| 同文件 `:1647`–`:1762` | 只给可见处方批量拉 `statuses`,缓存后渲染;`enabled` 同时开启两列 | 读 capability、清掉旧第 12 列文本/tooltip/accessible text 及缓存中的分数,保留状态轮询 |
| `app/src/doctor_workstation/ui/dialogs/diagnosis.py:1080`、`:1726`、`:1983` | 诊单只读页也能按 `diagnosis_id` 打开已开处方 AI 历史 | 必须走同一共享报告权限与 capability |
| `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py:401`、`:1817` | `can_open_issued_ai` + `present_issued_prescription_ai` 是共享报告入口 | 保留 reports/detail guard,另设 `can_view_agreement`;不要把报告 guard 改成分数 guard |
| 同文件 `:664`、`:1377`–`:1417`、`:1459` | `AgreementBar`、model score、可访问名/tooltip、原文 `comparison` 都有分数 | 分数不授权时不构建/隐藏分数区域,不填分数到隐藏控件,并清理原文内容 |
| 同文件 `:1210`;`issued_prescription_ai_pages.py:1156`、`:1191` | `HistoryPage` 从 `reports.lists` 渲染历史分数趋势,兼容 `comparison.score` 与模型顶层 `score` | 历史仍可选;关闭趋势分数,服务端历史摘要也需要去分数 |
| `issued_prescription_ai_workspace.py:674`–`:687` | 总览结论有双方一致率、纯药味重合度和百分点差 | 一致度受限时不得拼出这些结论 |
| `issued_prescription_ai_pages.py:364`、`:464` | 候选与逐味页说明算法、读取贡献行 | 要确认“仅一致度”是否包括每味贡献;至少不能泄露 contribution 与算法分子/分母 |
| `issued_prescription_ai_console.py:260`、`:295`、`:378` | 顶部仪表、无障碍名称、纯药味重合度 | 与主报告 capability 保持一致 |
| `issued_prescription_ai.py:1705`–`:1813`;`issued_prescription_ai_pages.py:1374` | 医生统计对话框;分布、均值、中位数、明细 HTML | 现有 statistics API 权限以外还应要求一致度可见;拒绝后整个统计关闭 |
补充:原处方详情 `dialogs/prescription.py` 没有独立已开处方一致度入口;目前可见入口集中在上述列表和诊单共享报告。后台 `admin/src/views/consumer/prescription/components/PrescriptionAiReportDialog.vue` 是另一个旧的处方解读报告流程,当前 `admin/src/views` / `admin/src/api` 没搜到 `tcm.prescriptionAi` 或一致度 UI,不应把它误当成本次双模型已开处方分析界面。
## 请求边界与元数据
`app/src/doctor_workstation/services/repository.py:1609`–`:1648` 透传以下服务端字典,新增 capability 无需先扩大类型 DTO:
- `statuses(ids)`:模型顶层 `score`、`comparison_status`,用于列表。
- `reports(prescription_id XOR diagnosis_id)`:历史摘要;传入 `page_no/page_size`。
- `detail(batch_id)`:完整 model `comparison`,用于报告全部页面。
- `statistics(date_from,date_to,doctor_id?)`:独立统计读接口。
- `regenerate/retry/review` 是显式写接口,此需求若只限制读分数,不必连带禁止已有 AI 生成和复核动作。
现有登录承载:
- `core/models.py:127`–`:176` 的 `UserProfile` 已包含 `id`、`role_ids`、`root`、`permissions`、完整 `raw`;多角色解析支持 `role_ids` / `role_id` / `roles`。
- `services/repository.py:902`–`:927` 的 `get_session()` 读取 `auth.admin/mySelf`,构造 `PermissionSet` 并存 session。
- `core/permissions.py:63`–`:83` 支持全局 `*` 与路由前缀 `/*`。`ui/widgets.py` 的 `has_permission` 已统一前端权限语义,新增功能不要另写角色硬编码。
- 后台 `admin/src/stores/modules/user.ts:110` 左右同样读取 `data.user/data.permissions/data.menu`。服务端 `AdminLogic.php:319` 已填 `role_ids`。
- 注意:`mySelf` 的 metadata 只是登录时快照;服务端改授权后应通过 AI read 响应刷新 capability,不能等用户下次登录才隐藏分数。
## 后台配置与选择器复用
### 可承载配置的页面
`admin/src/views/setting/desktop_workstation/index.vue` 有成熟 `el-form` + `getData` + `handleSubmit` + `footer-btns` 模式,当前管理升级策略;API 在 `admin/src/api/setting/desktop_workstation.ts`,对应 `setting.desktop_workstation/getConfig,setConfig`。可新增独立“处方 AI 一致度可见范围”设置区,提供角色多选 + 用户多选,并明确 OR 语义(命中任一角色,或被指定用户,均可见)。保存权限宜独立管理,避免升级包操作员默认拥有授权分配能力。
若希望未来扩展整个 AI 模块范围,新建 `setting/prescription_ai` 页面更清晰;当前没有可直接扩展的同名页。`ai_mcp/grant` 只管理员工外部 AI 助手令牌撤销,并非本功能授权。
### 角色与用户
- `admin/src/api/perms/role.ts:8` 的 `roleAll({})` → `/auth.role/all` 可复用。角色多选 UI 在 `views/permission/admin/edit.vue:73`–`:91`,`optionsData.role` 来自 `roleAll`;处方页 `consumer/prescription/index.vue:3303` 也有获取及失败降级模式。
- 当前角色配置 `views/permission/role/auth.vue` 是菜单树,保存 `menu_id` 到 `roleEdit`;适合给配置页面授予管理权限,但无法直接表达个人白名单。
- `admin/src/api/perms/admin.ts:4` 的 `adminLists` → `/auth.admin/lists` 是已实现的人员来源。`AdminLists.php:82` 支持 `name/account` 搜索,`:158` 支持角色筛选,`:165` 支持 `exclude_disabled=1`,列表带 `id/name/account/role_id`。建议用户多选下拉做姓名/账号远程分页,显示 `姓名(账号)`。
- **不要直接复用 `adminAll`**:前端虽导出了 `/auth.admin/all` 包装,但当前 `AdminController.php` 无 `all()`,全仓也没找到该路径映射。
- `admin/src/components` 没有通用后台用户多选组件。可用 Element Plus `el-select multiple filterable remote` 现有模式(处方页已有远程患者选择,但其数据类型和接口不能直接当员工选择器)。
- 现有处方模型 `visible_role_ids` 是处方审核/数据可见范围,不是分数可见范围;不要复用该字段造成整个处方行被隐藏。
## 仅一致度、保留 AI 报告的最小实现建议
1. 服务端策略保留为独立 role IDs + admin IDs;服务端返回 `can_view_agreement`(名称仅建议)而不是让客户端拼白名单。root 的默认行为、未配置的初始行为须与产品决策一致。
2. `statuses/reports/detail` 每个响应显式带当前 capability;未授权时剔除顶层/嵌套 `score`、`herb_score`、逐味 `contribution` 以及对应聚合分母,避免旧客户端或缓存旁路。统计接口直接拒绝未授权读。`enabled` 仍只代表分析功能总开关。
3. 桌面以独立布尔能力驱动第 12 列和统计按钮;第 11 列分析状态、AI 报告按钮与 reports/detail 权限保持现状。`_set_ai_columns` 应拆开或接受第二个参数。改变能力或请求失败时清理旧数据,不能只 `hide()`。
4. 共享报告统一把 capability 传至 AgreementBar / Workspace / HistoryPage / CandidatesPage;受限时保留完整报告、候选处方、来源、进度、复核与历史批次选择,但去掉一致度数字、趋势、贡献和相关说明。完整报告存量文本是否可能内嵌一致度,需要服务端研究确认;如允许模型原文包含此数值,仅过滤结构化字段不够。
5. 新后台配置区复用现有表单样式与 roleAll;人员选择用 adminLists 或专门受配置管理权限保护的最小字段 options 接口,避免配置管理员为了选人还必须拥有完整管理员列表权限。
待用户确认:“仅一致度”是只控制分数/重合度/贡献统计,还是连逐味剂量比较也一起隐藏;后者需要隐藏总览和候选逐味相关导航,但仍可保留报告正文。整个 AI 模块方案则简单得多:按新策略门禁所有 AI 入口和 API;不能在未确认时用此方案替代仅一致度方案。
## 可复用测试
本次只读研究未运行测试。现有测试使用内存 fake repository + `run_async` immediate fixture,不需要生产 API。
- `app/tests/test_issued_prescription_ai.py:440` `test_list_batches_visible_ids_and_stops_on_hide`:可延伸验证受限账号仍拉状态、只隐藏第 12 列。
- 同文件 `:462`、`:500`、`:525`、`:564`:空处方、总开关关闭、缺少 statuses 权限的入口及列显隐基线。
- 同文件 `:428` `test_view_permission_cannot_mutate`:保持 reports/detail 与写权限解耦。
- 同文件 `:1178` `test_access_revocation_clears_reports_and_all_progress`:可延伸为只撤销分数能力后清空数字,但正文保留。
- 同文件 `:596`、`:796`、`:977`:统计 null/版本分层/排除原因;补未授权统计不发请求且清空旧图。
- 同文件 `:626` `test_repository_exact_read_and_mutation_contracts`:可加服务端 capability 透传及不误改 mutation 请求。
- 同文件 `:697`、`:714`、`:1236`:追加列表缓存、右键目标、tooltip;可防分数在缓存/无障碍文本中残留。
- `app/tests/test_issued_prescription_ai_pages.py:288`–`:326`:历史读取嵌套和扁平两种分数;`:361`–`:418`:统计图表。新增受限 capability 时两种数据形状都不显示分数。
- `app/tests/test_issued_prescription_ai_workspace.py:239`:不捏造一致率差值;可加受限时不生成现有分数结论。
- `app/tests/test_permissions.py`:`*`、前缀通配符、AND/OR、空权限语义;如果新增菜单按钮权限应保持兼容。
- `app/tests/test_repository_parity.py`:可复用登录/session 的用户 ID、role_ids 解析测试结构。
- 后台当前无处方 AI 授权配置测试;`admin/tests/*.test.cjs` 是 Node 内建测试模式,`admin/package.json` 有 `type-check`,没有统一 test script。应新增只针对配置读写归一化、空选择与人员搜索的必要测试,避免写只镜像模板的快照测试。
## 实施交接:最小切换与 fixture
1. `issued_prescription_ai.py` 增加统一 `_apply_agreement_visibility()`(建议名)。`_build_ui` 默认隐藏;`_history_ready` / `_detail_ready` 应用 `agreement_visible is True`。缺失 flag 按 false;主题切换 `_switch_theme` 会重建 UI,必须继承能力并重新应用。从有权切成无权时,先清空旧 payload/图,再渲染正文。
2. 顶部 `self.agreement` 与 `self.agreement_note` 一起隐藏。注意每个模型的 **retry 按钮目前经 `self.agreement.attach_action(key,retry)` 放在仪表区域**(`:891`);如要求保留重试,需要移到进度页/独立状态区域。
3. 导航为 `NAV_PRIMARY`(`:68`),`self.nav_buttons` 是 `self.rail.buttons`(`:738`);overview/per_herb/history 都不是普通可见 tab,而是 rail 控制隐藏 tabBar 的 `QTabWidget`。隐藏导航后还要给 `_goto`(`:1065`)加拒绝/回落到 report,防 `_open_supporting_report`(`:1097`)和主题恢复旧页绕过。
4. 最小完整隔离方式:受限时隐藏 overview 与 per_herb 导航/页面、comparison_raw 原文页;默认切 report。报告原文、candidate、sources/gaps、pipeline、original 继续可用。`HistoryPage` 可保持批次列表但隐藏图和 3/4 分数列;更省改动可隐藏 history 页面,顶部 `self.history` 批次选择与分页仍保持完整历史访问。
5. **不能直接隐藏 overview 就结束**:`review_model` 与完整 review 表单在 `:898`–`:899` 被 reparent 到 `comparison_panel.checklist`。隐藏总览会丢失现有复核 UI。若保留复核,需把 selector + review widget 移到正文旁/独立复核页;或保留 overview 骨架,只隐藏 `conclusions/attribution/doses/risks` 及 comparison 链接,保留 checklist。`PrescriptionReviewWorkspace` 的这些已命名属性在 `issued_prescription_ai_workspace.py:1159`–`:1173`,有 `set_review_selector/set_review_widget`(`:1245`、`:1252`)。
6. `StepRail` 的 `focus_values`(`issued_prescription_ai_console.py:475`)与 `_render_console` 的 badges/focus 含差异、共识药味等派生计数。若 comparison 整体移除,清空/隐藏相关焦点行,避免把它们误显示为 0。保留来源缺口和报告数量即可。
7. `HistoryPage`(pages.py:1145)左 card 当前是局部变量 `left`;如仅隐藏趋势需将其保存为实例属性。表格 3/4 列是两模型分数,5 列算法;`strata` 和 `note` 也有“分数可以比较”等文案,受限时应清理。`set_history` 即使拿到剔除 comparison 的历史,也会保留这些文案,不能依赖没有数字自然隐藏。
8. `CandidatesPage` 的 `cards` 显示原方/候选处方;下方表格同时混合剂量与贡献。若保留候选卡片,则只隐藏/跳过逐味 body;否则整页隐藏最少。候选独立原文页 `tab_pages['candidate']` 依旧能保留原始候选数据。
9. `issued_prescription_ai.py:1414`–`:1417` 的 comparison HTML 应清空并跳过填充,而非仅藏页面。主界面 `model_views[*]['score']` 的 text/tooltip/accessibility、AgreementBar 内 gauge 和历史 `TrendBars` 都要拿空数据重置,避免 capability 撤销后留下旧值。
可直接扩展 fixture:
- `app/tests/test_issued_prescription_ai.py:24` 的 `batch()`,`:45` 的 `Repository`、`:86` 的 `no_network`(拒绝 socket)、`:95` 的 `immediate`;Repository 加 `agreement_visible` 属性和 capabilities 方法,read 字典统一附 flag。该组合最适合测从有权到无权的异步回调、缓存清理和主题重建。
- `app/tests/test_issued_prescription_ai_pages.py:35` 的 `batch(**overrides)`、`:66` 的 `per_herb`、`history` fixture,可用同一批次先 true 再 false,确认 score fallback 两种形状都无残留。
- `app/tests/test_issued_prescription_ai_workspace.py:25` 的 `batch(count)` 与 `:43` 的 `workspace` fixture 带真实保存逐味 identity 和 review slot;适合确认受限骨架不影响 review、report links 与布局。
@@ -0,0 +1,51 @@
# 处方 AI 一致度授权:验收记录
日期:2026-10-17。
## 用户确认范围
仅控制“与 AI 一致度”、一致度详情(逐味对照、贡献、差异等)与一致度统计,保留 AI 分析报告。授权对象可以是指定系统角色,也可以是具体后台用户,任一命中即可。
## 完成行为
- 新增后台“系统设置 → 处方AI一致度授权”页面,使用角色、用户两个可搜索多选框配置名单。
- 默认空名单对所有账号隐藏,包括 root;管理策略的权限与查看分数的权限分开。
- 服务端每次读取检查有效账号与实时角色归属。原有报告动作权限、患者/处方/来源范围继续执行,白名单不会扩大数据范围。
- 未授权账号的状态、历史和详情响应移除一致度及其派生数据,统计请求在聚合前拒绝;模型报告、AI 候选方案、进度、来源、重试、重新分析与复核保留。
- 桌面按服务端明确的布尔能力控制列、详情、导航和统计;缺失、失败按不可见处理。撤权后清理旧图表、比较原文、缓存、提示与可访问数据,旧异步回调不会恢复分数。
- 最终复核补齐了桌面缓存中比较原因、原因码、药材字典版本与基线序列化字段的清理,并增加保留报告/生成失败说明、不修改原输入的回归。
策略变更在下一次能力/状态/报告读取时反映到桌面;没有新增推送通道。
## 验证
| 范围 | 实际结果 |
| --- | --- |
| 最终桌面核心 AI 组合(主代理复跑) | 250 passed,14.57s,exit 0 |
| 桌面外围权限/处方界面(独立进程) | 32 passed |
| 后台实际 Vue setup/API 模拟(主代理复跑) | 8/8 passed |
| PHP 纯离线权限/投影(主代理复跑) | 22 checks passed |
| 隔离 MySQL 真实 ORM/Cipher/Logic/Controller(主代理复跑) | 113 checks passed |
| 后端 worker Pipeline / 旧进度 schema | 125 / 111 checks passed |
| 后端比较、统计、进度、配置回归 | 通过,详见后端报告 |
| Ruff、PHP lint、限定任务文件 diff 检查 | 通过 |
| 本地后台浏览器与桌面离屏截图 | 已检查授权/受限、报告保留、重试/复核、统计拒绝 |
临时 MySQL 仅绑定 127.0.0.1:33447,独立初始化且不读取生产配置;临时数据库由测试清理,进程已由主代理正常关闭。未调用生产数据库、模型接口或发布流程。
已知验证边界:将桌面核心与 ShellWindow 外围组放在同一进程运行时,最后一组发生 Qt 阻塞;分进程全部通过。后台全量类型检查有 61 项范围外错误,新增文件的严格模板类型检查通过。当前工作区还有其它任务在修改订单辅方袋数,本验收没有修改其文件;最终 diff 检查限定本任务文件。
## 上线与配置
代码尚未部署。需要执行 [幂等迁移](../server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql),部署服务端及后台,并更新桌面端。迁移创建菜单和空策略,不自动把任何角色或账号加入查看名单。
管理员进入“系统设置 → 处方AI一致度授权”,选择角色或用户并保存后,相应账号在下次读取时显示一致度。已有处方和报告无需重新生成。
## 交付与预览
- [后端实施与测试](prescription-ai-visibility-backend-implementation.md)
- [后台实施与测试](prescription-ai-visibility-admin-implementation.md)
- [桌面实施与测试](prescription-ai-visibility-desktop-implementation.md)
- [后台授权配置预览](prescription-ai-visibility-admin-local.png)
- [受限账号处方列表](prescription-ai-visibility/list-restricted.png)
- [受限账号保留的报告](prescription-ai-visibility/report-restricted.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -0,0 +1,61 @@
# 接诊台当前医助数据契约
审查范围:只读静态代码;未连接生产数据,未修改业务代码。仓库 `.trellis/` 不存在。
## 结论
接诊台头部应显示聚合详情的 `detail.appointment.assistant_name`。它的业务来源是当前诊单 `tcm_diagnosis.assistant_id → admin.name`,与“通知医助”的实际接收人一致。`detail.appointment.assistant_id` 也已由服务端覆盖为当前诊单医助 ID。
不要回退到历史挂号 `doctor_appointment.assistant_id` / `appointment_assistant_id`、指派日志前任医助、处方医助、`doctor_name` 或当前登录人姓名。成功加载详情后,空的当前医助字段应保持空态,不应再从旧队列记录补出姓名。
## 请求与结构
- 桌面端真实方法名是 `RemoteDoctorRepository.get_reception(appointment_id)`,不是 `get_reception_detail`;GET `doctor.appointment/reception`,参数 `{id: appointment_id}`,返回映射原样保留。见 [repository.py:1102](D:/web/zyt/app/src/doctor_workstation/services/repository.py:1102)。`remote_repository.py` 只是重导出模块。
- 服务端返回 `{appointment, diagnosis, doctor_notes, tracking_notes}`,见 [AppointmentLogic.php:722](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:722)。桌面端兼容外层 `{data: ...}`,见 [reception.py:7392](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7392)。
- 关联关系特别注意:`appointment.patient_id == tcm_diagnosis.id`,不是基础患者表 ID;诊单另有自己的 `patient_id`。见 [AppointmentLogic.php:678](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:678)。
```json
{
"appointment": {
"id": 123,
"patient_id": 456,
"assistant_id": 2001,
"assistant_name": "某医助",
"doctor_name": "某医生"
},
"diagnosis": {"id": 456, "assistant_id": 2001},
"doctor_notes": [],
"tracking_notes": []
}
```
以上仅是字段结构示例,不是线上数据。
## 服务端字段语义及通知一致性
| 位置 | 确切行为 |
| --- | --- |
| [AppointmentLogic.php:489](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:489) `detail()` | 选择 `a.*` 并关联患者姓名、电话、`ad.name as doctor_name`;本身不补医助姓名,原始 `a.assistant_id` 属于挂号记录。 |
| [AppointmentLogic.php:687](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:687) `reception()` | 按 `diagnosis.assistant_id` 查 Admin,再写入 `appointment.assistant_name`;`appointment.assistant_id = diagnosis.assistant_id ?? 0`。这一覆盖使聚合 appointment 的医助变为当前诊单口径。 |
| [AppointmentLogic.php:887](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:887) `notifyAssistant()` | 按挂号 `patient_id` 找诊单,再读诊单当前 `assistant_id`,查 Admin 的 `work_wechat_userid` 发送通知。桌面端仅 POST `{id: appointment_id}`,见 [repository.py:1164](D:/web/zyt/app/src/doctor_workstation/services/repository.py:1164)。 |
| [AppointmentLists.php:241](D:/web/zyt/server/app/adminapi/lists/doctor/AppointmentLists.php:241) 队列列表 | `u.assistant_id as assistant_id`、`asst.name as assistant_name` 来自当前诊单;另外保留 `a.assistant_id as appointment_assistant_id`,两者语义不同。 |
未分配时,聚合详情姓名为 `""`,ID 为诊单原有空值(缺少时 `0`);`notifyAssistant()` 对 `assistant_id <= 0` 返回“该患者尚未指派医助”。若 ID 存在但 Admin 查不到/姓名为空,显示姓名同样为空;不能仅根据姓名为空断言未分配。通知另会返回“医助账号不存在”或未绑定企业微信的具体错误。UI 可用“未分配”表示明确的无 ID 状态,有 ID 无姓名可显示“—”或“医助 #ID”。
## 管理端与桌面端现状
- 管理端患者信息卡没有多级姓名回退:`apt = detail.appointment`,显示 `apt.assistant_name || '—'`(当前文案为“客服”);见 [index.vue:271](D:/web/zyt/admin/src/views/patient/reception/index.vue:271)、[PatientInfoCard.vue:19](D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientInfoCard.vue:19)。队列也显示 `row.assistant_name || '—'`,见 [index.vue:72](D:/web/zyt/admin/src/views/patient/reception/index.vue:72)。
- `Appointment` 模型已有 `assistant_id: int | None`、`assistant_name: str`,`from_dict` 直接读取同名键,默认 `None` / `""`;见 [models.py:195](D:/web/zyt/app/src/doctor_workstation/core/models.py:195)、[models.py:233](D:/web/zyt/app/src/doctor_workstation/core/models.py:233)。`Consultation` 也有同名字段,未把医生姓名作为医助别名。接诊聚合没有专用 dataclass,直接用 dict。
- 桌面端详情已把聚合 `appointment` 传入身份/预约渲染;见 [reception.py:7661](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7661)。兼容字段 `appointment_labels['assistant']` 已读取 `appointment.assistant_name`,见 [reception.py:7888](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7888);但其所在 `appointment_card` 被移到隐藏兼容容器,见 [reception.py:4740](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:4740)。因此修复可以在头部直接复用现有详情字段,无须新增 API 或修改 core model。
建议展示优先级:已成功取得接诊聚合详情时,以其 `appointment.assistant_name` 为准;加载前可使用当前选中队列的同口径姓名作临时展示。切换患者、取消选择、详情失败时按当前患者生命周期清空/重置,避免沿用上一位患者的名字。
## 实施与验证
已在“通知医助”按钮上方增加独立的“当前医助:姓名”文本,读取已核实的聚合详情字段。无医助 ID 时显示未分配;有 ID 但无姓名时显示姓名暂缺;加载与失败状态会清空上一位患者姓名。未修改 API、通知收件人或服务端代码。
离屏验证 816、1080、1340 三种页面宽度,文字与通知按钮不重叠且不产生横向滚动;检查了姓名、未分配、姓名暂缺、未知、患者切换和详情失败。截图:[头部预览](reception-current-assistant-header.png),其中姓名来自本地示例数据。
现有检查:蓝色布局 15 项通过;单独运行接诊与刷新模块 77 passed in 11.92s;结束问诊按钮 3 项通过;Ruff 和 `git diff --check` 通过。
组合运行全局主题布局模块与接诊模块时,原有 AI 放大按钮尺寸断言出现 28 × 36 对 28 × 28 的差异。恢复 Git HEAD 接诊模块后,在同一主题条件下仍复现该断言失败;接诊模块单独运行全部通过。该问题未在本次姓名显示修改中扩展处理。
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,62 @@
# 复诊接诊率:指派 / 继承写入与兼容性研究
2026-10-16。只读检查产品源码;未读取 `.env` 或凭据;未连接数据库。项目存在 `AGENTS.md`,但 `.trellis/workflow.md` 不存在,无法读取 Trellis 流程与分层规范。
## 关键结论
- 现有报表是“当月指派分母 + 当月成交分子”,不是“指派日期区间内患者的后续接诊队列”。仅把 `month` 换成起止日期会继续产生区间外患者进入分子的错误。
- 新指派、继承已有可用事件字段,无需为重新起算修改患者全局诊次。应在统计层根据入组指派事件重新计算后续订单;`RevisitVisitSlotService` 同时服务诊单节点、到期快照,改变它会影响其他业务。
- 自动指派和个人业绩都直接依赖当前统计实现。页面改队列口径时要明确兼容入口;尤其不要悄悄改变自动指派档位。
## 写入侧与数据字段
| 入口 | 现有行为 | 注意点 |
| --- | --- | --- |
| `DiagnosisLogic::assign`(约 673 行) | 诊单行 `FOR UPDATE`;更新 `assistant_id`、`assign_read_at`;同事务插入指派日志。`is_inherit=1` 仅在目标医助大于 0 时成立。 | `from==to` 允许写入;当前 slot 统计忽略该行,旧 exclude 仍计入新指派。新队列避免把自指派当成新承接事件。 |
| 同上,继承 | 同事务用 `slotAtTime($id,$now)` 写 `inherit_visit_slot` 和 `inherit_visit_slot_calc_time`。 | 快照只用于展示 / 对账。现有统计通过有效订单实时重算 k,所以事后退款、取消、offset 修改会联动。不能把快照当唯一订单数依据;历史回退没有快照。 |
| `AutoAssignPendingDiagnosis::applyAssignment`(约 567 行) | `is_inherit=0`、操作人 0、名称“系统自动分配”;同事务改医助并写日志;`from` 可推断为一中心原医助。 | 自动指派属于新指派,不能以 `from>0` 判定继承。 |
| `AutoAssignLogLogic::rollbackOne`(约 72 行) | 锁自动指派日志和诊单;仅当前医助仍为自动分配目标时允许回退;回到前任 / 0。写指派日志 `is_inherit=0`,并写自动分配日志 rollback 字段。 | 保留写入规则;新口径需把历史回退识别为继承,目标 0 的释放行不入组。 |
| `ExpressTrackingService`(约 702 行) | 发货释放写 `to_assistant_id=0`,未显式写 `is_inherit`(列默认 0)。 | 释放不是新指派,但会终止原医助持有时间段。 |
主要表 / 模型:
- `DiagnosisAssignLog` 只设置表名 `tcm_diagnosis_assign_log`,关闭自动时间戳,无字段白名单。指派核心数据:`id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit, inherit_visit_slot, inherit_visit_slot_calc_time`;还含最新订单创建人 / 时间快照与操作人信息。
- `tcm_diagnosis`:`assistant_id` 是当前归属,不应代替历史持有时间线;`revisit_slot_start_offset` 为全局诊次偏移(钳制 0~20)。
- `tcm_prescription_order`:有效业务单剔除软删、履约状态 4/9/10;统一调用 `YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery`;序列稳定排序 `(diagnosis_id, create_time, id)`。
- 迁移:`server/sql/1.9.20260601/add_diagnosis_assign_log_is_inherit.sql`;`server/sql/1.9.20261012/06_add_diagnosis_assign_log_inherit_visit_slot.sql`。继承标记引入前的数据无法可靠恢复继承类型,不应按 `from>0` 猜测。
- 现有订单复合索引迁移:`server/sql/1.9.20261012/01_add_prescription_order_diag_ct_index.sql`,`(diagnosis_id, delete_time, fulfillment_status, create_time)`。源码内未找到指派日志建表 DDL,不能据此断言线上索引情况。
## rollback 与重新起算
- `RevisitRateLogic::fetchRollbackKeySet` 使用 `diagnosis_id | rollback_time | rollback_admin_id` 关联到指派日志 `diagnosis_id | create_time | operator_admin_id`;同一事务同一 `$now` 保证当前写入可匹配。需要继承独立页时复用这一兼容规则,不能只筛 `is_inherit=1`。
- 现有 `RevisitRateCalculator::compute` 在 slot 模式将命中回退的日志当继承;exclude 模式完全保留其 `is_inherit=0` 的旧语义。回退无继承快照,订单实时计算仍可用。
- 新继承“重新起算”应按继承事件时间切开序列,继承前历史单不进入继承后的接诊次数。保留同一秒约定:日志时间 `t <= order.create_time` 时归新医助,继承前完成单计数用严格 `< t`。
- 转派 / 释放 / 再次继承会形成多个持有段;要先明确入组去重,再把订单归属到有效段,避免同一订单被多个队列事件重复领取。当前实现按同一医助 × 诊单去重,继承取区间内最早,且同区间新指派优先覆盖继承;这一合并规则不适合直接复用到“新指派和继承分别统计”。
- 当前拒收 / 退款处理是“该诊单存在任意时间状态 9/10 有效订单,则整诊单从分母剔除”,分子只是剔除对应无效单。若沿用需在两个独立页面和明细保持一致;不要无意中改变这一业务规则。
## 当前统计调用方
1. `AutoAssignPendingDiagnosis::execute` 调 `RevisitRateLogic::overview(['month'=>上个自然月,'inherit_mode'=>'slot'])`,再从部门 children 中取医助率。配置实际值为 `project.revisit_rate.auto_assign_tier_field='visit2_rate_new_assign'`,页面默认实际是 `default_inherit_mode='exclude'`(部分注释仍写默认 slot)。
- 分档字段白名单仅 `visit2_rate_new_assign` / `visit2_rate`,对应 `rate_version=v1/v2`。无分母必须继续返回 `null`,否则可能改变谁参与自动指派。
- 自动日志同时记录两套率。历史 v1 行无 `visit2_rate_new_assign` 时,前端回落 `visit2_rate`。不要重解释历史日志字段;如变更分档口径要单独版本化。
2. `AssistantPerformanceController::revisitRate` 只收 `month`,Logic 从配置取 mode,再调 `RevisitRateLogic::assistantOverview`,只返回本人行。
- 本人统计有 600 秒缓存,key 为 `month + assistantId + mode`。改统计口径需更新缓存 key 版本;加日期范围 / 队列类型需纳入 key。
- 前端个人业绩卡片仍按月,响应类型依赖 `month, inherit_mode, slots, in_scope, row`。保持 month 兼容可避免页面破坏,但卡片文案和规则应同步说明指派队列口径。
3. `RevisitRateLogic::overview` 目前不处理 `assistant_id`;只有两个下钻方法调用 `applyRowScope`。新增医助筛选需同时约束 overview 的医助行、部门汇总、总计以及下钻集合。部门归属取当前 `admin_dept` 中最小有效 dept_id,默认限二中心子树。
4. `YejiStatsLogic` 的“被指派数”仍独立按 `is_inherit=0`、区间指派、医助×诊单去重;避免此次顺带修改整个业绩看板。
新独立页还需菜单 / 权限迁移与 MCP review 文案。`AuthMiddleware` 对未登记接口默认放行,因此新增接口须登记权限。现有 `deptOptions` 还被复诊到期页复用,不应改成仅某一队列的选项。
## 测试运行方式与已验证基线
无根级 PHPUnit 配置,`server/composer.json` 无 test script / PHPUnit;相关测试是独立 PHP CLI 脚本,仅加载 `server/vendor/autoload.php`,不启动应用、不连库。当前 PATH PHP:`D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe`。
已从项目根目录运行并通过:
```powershell
php server/tests/RevisitRateContractTest.php # 45 checks
php server/tests/RevisitRateInheritSlotTest.php # 57 checks
php server/tests/RevisitVisitSlotServiceTest.php # 20 checks
```
`RevisitRateContractTest` 是源码字符串 / Reflection 契约,明确锁住“分子按当月下单”“继承从 k+1 列入分母”等旧实现,新队列实现需要合理更新这些断言,不能仅为通过保留过时语义。建议纯函数覆盖:区间指派后跨月成交、区间外患者同月成交不计入、自指派、转派终止归属、多次继承与重置、回退继承、同秒排序、医助筛选汇总和两类明细对账;保留全局诊次服务现有测试。
@@ -0,0 +1,54 @@
# 复诊指派队列后端独立审阅
2026-10-16。范围:`RevisitCohortCalculator` / `RevisitCohortLogic`、两个 Controller、菜单 SQL、两项新增测试。未修改产品代码、未读凭据、未连接业务数据库。
主代理处理结果:下述释放返还问题已修复,计算器以实际时间线判断持有人是否变化;新增两项纯计算及两项 MySQL 集成回归通过。性能观察也已处理:先按周期(及选中医助)查询候选诊单,再分块读取对应完整时间线。
## 需要修复:1 项
### [P2] 释放后重新指派给原医助被误当作自指派,患者和后续订单全部漏计
- 位置:`server/app/adminapi/logic/stats/RevisitCohortCalculator.php:39`,`$from === $aid` 直接跳过整条事件,既不入组也不更新持有时间线。
- 写入侧证据:`DiagnosisLogic::assign` 第 714~715 行在诊单当前 `assistant_id=0` 时调用 `inferPreviousAssistantIdFromLatestAssignLog`;该方法在最近日志为 `A→0` 时回落其 `from_assistant_id=A`。因此用户实际执行的“未分配→A”会写成 `from=A,to=A`,这与真实自指派有相同字段,但不是无操作。
- 最小复现(同一诊单,日志 / 订单按要求排序):
| 时间戳 | 事件 |
| --- | --- |
| 10 | 指派 `0→7`,新指派 |
| 11 | 第 1 笔有效订单 |
| 20 | 释放 `7→0` |
| 30 | 从未分配重新指派给 7;现有写入逻辑记录 `7→7`,`is_inherit=0` |
| 31 | 第 2 笔有效订单 |
调用 `compute($logs,$orders,[],[],[],25,35,100,'new')` 后,实测 `assigned_count=0, visit2_count=0`,应各为 1。若重新承接时勾选继承,日志为 `7→7,is_inherit=1`,同样漏掉本次继承患者和其首单二诊。
这还会影响区间更早的原医助队列:重新承接事件被跳过后,时间线一直停在释放状态,后续成交不再归属任何有效持有段。
建议根据同一诊单此前真实时间线的最后目标判断是否“持有人没有变化”,不能仅比较日志的 `from/to`;至少识别前一有效事件为 `to=0` 的重新承接。保留真实 `A→A` 的不入组、不重置行为。增加“释放后返还同一医助”的新指派及继承两个纯函数 / 数据库用例,区别于已有“转到其他人后再次继承”测试。
## 其余检查
- 已确认日期筛选约束入组事件,成交累计到 `time()`;区间外患者不会进入分子,后续转派 / 释放终止该持有段。
- 继承后订单以段内序号 `+1` 从二诊起算,忽略全局诊次 / offset;新指派保留全局诊次。真实自指派当前测试覆盖且不重置。
- 历史 rollback 关联键参与继承识别,`is_inherit=0` 回退不会混入新指派。被拒收 / 退款的诊单同时从队列分母、分子剔除,无孤立分子。
- 医助筛选先作用于整个 context,再构造部门 / 总计及明细;部门仍限制二中心。部门 / 总计按患者、诊次去重,明细与指标一致。
- 继承 Controller 用 `static::COHORT_TYPE` 固定类型,不能通过请求参数切换到另一类;继承 Controller 可正常 autoload。
- 菜单 SQL 按对应权限逐项授予原角色,未扩大只有患者明细角色的订单明细权限;页面与三个接口都登记。部署仍须执行 SQL 并清理 `AdminAuthCache`,否则系统现有“未登记接口默认放行”规则会作用于新接口。
- 旧 `RevisitRateLogic` 仅将共用读取方法改为 protected,自动指派 / 个人业绩仍调用旧口径,未发现行为改动。
## 性能观察(未作为阻塞问题)
`RevisitCohortLogic::cohortContext` 第 188~198 行先把所有未删诊单截至当前的全部历史指派日志读入 PHP,再计算候选诊单并过滤;即使只查一天、单个医助甚至空队列,首个查询扫描 / 返回的数据量仍与全量历史指派数相关。随后订单扫描已收窄到候选诊单。建议后续先在 SQL 查区间内候选诊单,再按候选诊单拉全量持有时间线,保持释放 / 转派语义;本次没有业务库规模和执行计划,无法证明已有请求达到超时 / 内存上限,因此不将其列为已复现缺陷。
## 验证
独立运行通过:
```text
RevisitCohortCalculatorTest OK (32 checks)
RevisitRateContractTest OK (45 checks)
RevisitRateInheritSlotTest OK (57 checks)
RevisitVisitSlotServiceTest OK (20 checks)
```
四个新增 / 修改核心 PHP 文件 `php -l` 通过。额外通过 PHP 标准输入执行上述最小复现,确认实际输出为全零。主代理报告的隔离 MySQL 5.7 数据库测试 20 checks 已阅读源码,本次未重跑其临时数据库。数据库测试目前没有“释放后返还原医助”场景。
@@ -0,0 +1,21 @@
{
"passed": 15,
"checks": [
"新指派页可见且独立命名",
"默认新指派汇总加载",
"日期范围允许未来结束日",
"医助筛选传入汇总请求",
"患者明细沿用日期和医助筛选",
"切换部门清空范围外医助",
"医助选项按部门限制",
"重置清空部门医助",
"独立继承页加载",
"继承页说明首单二诊",
"继承页人数列不混合新指派",
"继承订单使用独立接口",
"继承后诊次明细可见",
"底栏展示复诊合计率",
"浏览器无运行时异常"
],
"errors": []
}
+31
View File
@@ -0,0 +1,31 @@
> 2026-10-17 更正:以下为10月16日实施记录。原页面及原 API 已恢复,指派周期新报表迁至 revisit_assign_rate / stats.revisitAssignRate,继承报表仍独立。最新验证见 revisit-legacy-source-proof.json、revisit-legacy-restored-compile.json、revisit-legacy-restored-browser-check.json 及部署计划。
# 复诊统计前端交付与验证
日期:2026-10-16
## 实现
- `admin/src/views/stats/revisit_rate/index.vue`:原入口保留,专门展示新指派患者。
- `admin/src/views/stats/revisit_inherit_rate/index.vue`:独立继承复诊入口。
- `admin/src/views/stats/revisit_rate/components/CohortReport.vue`:共享 Element Plus 报表,按 `cohortType` 分离接口、权限、患者人数列及说明。不再保留旧/新继承混算切换。
- `admin/src/api/stats.ts`:以第二参数选择 `/stats.revisitRate` 或 `/stats.revisitInheritRate`;保留个人业绩依赖的 `RevisitRateInheritMode`。
日期筛选默认当月 1 日至当天,支持任意范围及未来结束日期(如 10 月 30 日);日期范围选患者,订单累计至查询当天。继承页明确写明:继承后第一笔有效订单算二诊,第二笔算三诊,不使用继承前全局诊次或偏移。
保留部门树、医助/部门层级明细、服务端去重合计、患者只读详情、订单只读抽屉;新增可搜索和清空的医助筛选,按选中部门及其下级过滤,切换部门会清空范围外医助。展示服务端 `observed_until`。
下钻复用已展示报表的查询快照,顶层 `assistant_id` 与医助行 `scope_assistant_id` 独立传递。汇总和明细分别用请求序号丢弃旧响应;筛选重新查询会关闭并清空明细。
## 验证
1. 使用项目现有 `@vue/compiler-sfc` 编译两页入口和共享组件:脚本、模板、SCSS 均通过。
2. 使用真实 Vue `ref/computed`、编译后的 SFC setup 及受控异步 API 验证 26 项行为,全部通过:
- 默认日期、未来结束日期保留、部门子树医助过滤、超出范围自动清空。
- 查询快照、保留顶层医助、独立行级医助/部门范围、返回 count、动态诊次排序去重。
- 汇总/明细乱序响应、筛选后迟到明细的丢弃、重新查询清空明细。
- 继承文案、单类人数列、独立继承权限、两套接口各四个 action 路径。
3. `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit --pretty false`:全项目有 61 条既有类型诊断;本次 API、两页入口及共享组件的诊断为 **0**。存量错误包括聊天组件、编辑器声明、录像 API 及既有处方表单类型等。
4. 前端负责文件统一 LF;`git diff --check` 通过。
未执行含 release 的 `npm run build`,未发布。浏览器与真实后端联调由主代理继续完成。
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

@@ -0,0 +1,72 @@
# 原复诊接诊率入口恢复只读审查
日期:2026-10-17。基线:`HEAD=c67b3eff69d9a3ac2046861751f159692a0abcd7`。仅审查本地源码、Git 和既有验证记录;未读 `.env` / 凭据,未连接业务库,未发布,未修改产品文件。`.trellis/workflow.md` 在当前工作区不存在。本报告是唯一写入文件。
## 结论
最小可靠恢复是将原 Controller、原 `/stats/revisit_rate/index.vue` 及原 API 函数恢复为 HEAD 的完整契约,保留原 `RevisitRateLogic`、`RevisitRateCalculator`、`RevisitVisitSlotService`、配置、自动分配及个人业绩调用链。新队列功能另外使用 `stats.revisitAssignRate/*` 和 `/stats/revisit_assign_rate`;继承继续使用 `stats.revisitInheritRate/*` 和 `/stats/revisit_inherit_rate`。
仅保留旧计算器还不能保持原功能:上一轮将 `stats.revisitRate/*` Controller 改为队列逻辑,现有页面和外部调用者即使 URL 不变也会获得不同的患者集合、时间范围、分子与分母。因此恢复必须覆盖入口及 API 契约。
审查开始时,原 `RevisitRateLogic` 的差异仅为 7 个 `private` 方法改为 `protected`,计算器与诊次服务无 Git 差异。审查过程中主代理已将旧 Controller、旧页面、旧 MCP review 项恢复,旧 Logic 也恢复为 HEAD,并将队列数据源抽为独立 trait。该并行状态只是观察结果,最终应以主代理收尾 diff 为准。
## HEAD 原入口与依赖
| 层 | 原契约 |
| --- | --- |
| 菜单页面 | 名称「复诊接诊率」,`paths=/stats/revisit_rate`,`component=/stats/revisit_rate/index` |
| 菜单/API 权限 | `stats.revisitRate/overview`、`stats.revisitRate/deptOptions`、`stats.revisitRate/assignLines`、`stats.revisitRate/visitOrderLines` |
| Controller | 四个 action 直接调用 `RevisitRateLogic` 同名方法;overview / 两个明细保留 120 秒执行时间 |
| 查询参数 | overview:`month, dept_ids, inherit_mode`;患者明细另带 `assistant_id, dept_id, kind=new|inherit|all`;订单明细另带 `slot=0|2..50` |
| 前端 API | `revisitRateOverview`、`revisitRateDeptOptions`、`revisitRateAssignLines`、`revisitRateVisitOrderLines`;保留 `RevisitRateInheritMode` 和 `RevisitRateAssignKind` 导出 |
| 默认口径 | `server/config/project.php` 的 `default_inherit_mode='exclude'`;页面首次请求不传 inherit_mode,接收服务端返回口径;用户可手动选择 slot |
| 原页面筛选 | 月份、部门、exclude / slot 口径;重置回默认口径;包含新指派 / 继承 / 合计及订单下钻 |
| 下钻权限 | 患者数:`stats.revisitRate/assignLines`;订单数:`stats.revisitRate/visitOrderLines`;患者详情:`tcm.diagnosis/detail`;订单详情:`tcm.prescriptionOrder/detail` |
| 原个人业绩 | `AssistantPerformanceLogic::revisitRate` → `RevisitRateLogic::assistantOverview`;本人结果按月 / 医助 / 口径缓存 600 秒 |
| 原自动分配 | `AutoAssignPendingDiagnosis` 使用旧 `RevisitRateLogic`;取 slot 模式 overview,但分档字段配置仍为 `visit2_rate_new_assign` |
| 原 MCP 项 | `server/app/mcp/catalog/review/stats.php` 中原四个 `stats.revisitRate/*` 说明和参数白名单必须继续描述按月统计 |
原菜单来源为 `server/sql/1.9.20260610/add_revisit_rate_menu.sql`。恢复旧入口无需改原菜单记录、原权限或执行旧菜单脚本。Vue 路由由 `admin/src/router/index.ts` 的 `import.meta.glob('/src/views/**/*.vue')` 按数据库 component 解析,新增独立入口只需相应组件文件和菜单 component 正确对应。
## 需保持的算法含义
- 分母取所选月的指派事件;原 exclude 模式仅按 `is_inherit=0` 构造医助×诊单集合,保留自指派及历史回退的原处理方式。原 slot 模式另外计算继承起算诊次与每列分母。
- 分子取所选月的有效订单,诊次为跨月累计的全局有效实单序号加原诊单偏移;订单按当时持有人归属。往月指派、当月成交仍可进入分子,率可超过 100%。
- 有拒收 / 退款业务订单的诊单整体剔除;有效订单过滤仍委托原业绩口径;二中心组织范围、当前人事部门归类和去重规则保持 HEAD。
- 队列统计把“入组指派周期”和“累计观察时刻”分开,继承重新起算诊次。这些是另一张统计表的含义,不能复用原四个 API 名称替换原算法。
## 新入口权限与发布边界
`AuthMiddleware::handle` 当前对未登记到菜单权限集合的普通 API 默认放行;只增加 PHP Controller 而漏掉菜单权限登记,会使新增数据接口对登录用户过宽。新增 `stats.revisitAssignRate` 的 overview 和 3 个子接口都应登记;继承入口同理。角色授权应逐 action 从原对应权限复制,不能因拥有 overview 就自动授予患者或订单明细。执行菜单迁移后需按现有流程刷新 `AdminAuthCache`。
原菜单无需改名或搬迁。新指派周期菜单应新增独立 path / component,避免覆盖旧 `stats.revisitRate/overview` 的记录。若已有继承菜单脚本会引用旧页作为父目录与授权来源,可保留这种引用;新增指派周期脚本也可按同样逐权限方式授权。
## 本地证据能确认的发布状态
- `git diff -- server/public/admin`、该目录的 `git status --short` 均为空:当前仓库的公开管理端产物没有相对 HEAD 被替换。
- 公开产物 `server/public/admin/assets/stats-Bz24MGMf.js` 仍包含原四个 `stats.revisitRate/*` 路径;在公开 JS 中未找到 `stats.revisitInheritRate`、`scope_assistant_id` 或新队列标题。不能据此推断远端服务器文件状态。
- 既有验证计划记录生产构建输出为 `.codex-tasks/revisit-cohort-build`,该隔离目录确实存在;记录明确写了未运行 release、业务库迁移未执行。它是先前代理的本地记录,不是生产部署审计。
- `admin/package.json` 的 `npm run build` 实际执行 `vite build && node scripts/release.mjs`;release 会移除并重拷贝 `../server/public/admin`。只做验证构建时应继续使用单独输出目录,并避免运行 release。
- 源码改动在当前工作区尚未提交。Git 未提交、公开目录未变和临时构建存在,可以支持“本地未完成正式 release”的判断,不能单独证明生产环境从未部署。此次未查询生产、不读凭据、不连业务库,故不能声称已经确认线上恢复或线上未受影响。
## 已执行验证
以下测试只加载 autoload / 纯函数或反射源码,不初始化应用数据库,全部通过:
| 命令 | 结果 |
| --- | --- |
| `php server/tests/RevisitRateContractTest.php` | 45 checks |
| `php server/tests/RevisitRateInheritSlotTest.php` | 57 checks |
| `php server/tests/RevisitVisitSlotServiceTest.php` | 20 checks |
合计 122 项旧链路检查。本次未执行数据库测试。
## 主代理收尾需要覆盖的检查
1. 旧 Controller / 旧页面与 HEAD 比对无差异;原 Logic / Calculator / 诊次服务 / 配置 / 自动分配 / 个人业绩无差异。API 文件可以新增独立函数,但原函数签名、URL、参数类型及旧类型导出应保持 HEAD。
2. 原四个 Controller action 明确调用 `RevisitRateLogic`;新增四个指派周期 action 调用 `RevisitCohortLogic` 的 new 类型;继承 action 固定 inherit 类型。禁止通过请求参数切换到另一报表类型。
3. 重跑上述 122 项旧检查;新增队列纯函数检查继续通过。隔离数据库验证若运行,只能显式指定独立本机临时库,并扩展到新指派菜单的 4 项登记、幂等及逐权限复制。
4. 三页及共享组件编译成功;旧页仍调用旧 API,新指派页调用 `stats.revisitAssignRate/*`,继承页调用 `stats.revisitInheritRate/*`。新共享组件迁移后不得留下从原页面目录导入队列实现的误导路径或错误权限前缀。
5. 原页月份 / 默认 exclude / 手动 slot / 患者与订单下钻至少验证一次;新两页的日期、医助筛选及 scope 筛选保持原队列验证。已有 `artifacts/revisit-cohort-browser-check.json` 使用虚构接口响应,只证明旧一轮 UI 行为,不能证明此次入口分离或真实业务数据一致。
6. 同步计划和前端说明中“原入口只统计新指派”“新指派使用 stats.revisitRate”的陈旧描述;原 MCP review 恢复按月参数,新两个 API 项按实际路由声明。运行现有 MCP 目录测试,确认没有旧路径重新被队列说明覆盖。
7. `git diff --check`;构建输出到隔离目录;收尾报告明确本地恢复完成、验证范围及未发布状态,不以虚构数据浏览器测试声称生产数据已核对。
@@ -0,0 +1,26 @@
{
"passed": 20,
"checks": [
"原页面仍使用原按月接口和参数",
"原月份筛选恢复",
"原口径选择恢复",
"原页面仍能切换原有slot口径",
"原患者明细保留month/kind/口径契约",
"新指派页可见且独立命名",
"默认新指派汇总加载",
"日期范围允许未来结束日",
"新增医助筛选使用独立指派周期接口",
"患者明细沿用日期和医助筛选",
"切换部门清空范围外医助",
"医助选项按部门限制",
"重置清空部门医助",
"独立继承页加载",
"继承页说明首单二诊",
"继承页人数列不混合新指派",
"继承订单使用独立接口",
"继承后诊次明细可见",
"底栏展示复诊合计率",
"浏览器无运行时异常"
],
"errors": []
}
@@ -0,0 +1,5 @@
{
"sfcFiles": 4,
"apiCalls": 12,
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

@@ -0,0 +1,56 @@
{
"baseline": "c67b3eff69d9a3ac2046861751f159692a0abcd7",
"files": [
{
"file": "admin/src/views/stats/revisit_rate/index.vue",
"identical": true,
"sha256": "7bbd3e94e974e49367379ff69325ca93a7f903eb01020e4c7dfafd415a57a4df"
},
{
"file": "server/app/adminapi/controller/stats/RevisitRateController.php",
"identical": true,
"sha256": "272f437a220f393f6b0c9c4833ccadaf30de1915777c25da906b6e9eb0c662e4"
},
{
"file": "server/app/adminapi/logic/stats/RevisitRateLogic.php",
"identical": true,
"sha256": "86bac0184a8fda25e1b295527687568e5950b1c66a1a8f5a901617cc8624c3a1"
},
{
"file": "server/app/adminapi/logic/stats/RevisitRateCalculator.php",
"identical": true,
"sha256": "c03d1440783695f6e094f16417697821fe8e793094f3070bca8461d5f2c9ddc6"
},
{
"file": "server/app/common/service/tcm/RevisitVisitSlotService.php",
"identical": true,
"sha256": "2991166354b34964603557ab057ec1eb63e8c7cd09d42e6f525b9f89af93bb85"
},
{
"file": "server/config/project.php",
"identical": true,
"sha256": "b2fb9c7f82ea57dfbe141a72c1f56f546db8206fc361b1b203470830cddd67a8"
},
{
"file": "server/app/command/AutoAssignPendingDiagnosis.php",
"identical": true,
"sha256": "82366bc39f7d247dbdccdbe1b37c39f21cbeffb0fd46166455da81d97966ec2c"
},
{
"file": "server/app/adminapi/logic/stats/AssistantPerformanceLogic.php",
"identical": true,
"sha256": "e81482157e8708d00da66f421416b1ba18de4dcec59443d22c9fb0ff28e21a72"
},
{
"file": "server/app/adminapi/controller/stats/AssistantPerformanceController.php",
"identical": true,
"sha256": "a30f1e6cd2ab38c51fc7b6642d840278f465dca1c2b7542e8dfa912c15900ada"
},
{
"file": "server/app/mcp/catalog/review/stats.php",
"identical": true,
"sha256": "cf2f97cb3e5d41c829f0d5c509e6ceba980297af69dfa061968a3a0bb55d5cc1"
}
],
"original_api_bytes_preserved": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+87
View File
@@ -0,0 +1,87 @@
# 桌面问诊列表:视频固定列布局审查
核对日期:2026-10-17。仅审查本地源码并运行离屏几何探针,未连接生产服务或使用 UI 账号;未修改业务代码和现有测试。已有 dirty 文件均保留。
## 规范与范围
- 已读取仓库根目录 `AGENTS.md`;本地 `.trellis/` 不存在,无法读取其 workflow/spec。`app/` 下未发现额外 `AGENTS.md`。
- 对照 `app/research/diagnosis_index_visual_spec.md`:固定双表共享行高及垂直滚动,文本换行受实际列宽、字体和 DPR 影响。该文件记录的是早期后台复刻参考,当前桌面问诊列表的尺寸以实现和测试为准。
- 任务提及的 `_call_cell` 在当前仓库不存在。实际入口为 `DiagnosisTableHost._video_widget()`,位于 `app/src/doctor_workstation/ui/diagnosis_index_widgets.py:1786`(以下行号为审查时快照;并行修复可能改变行号)。
## 根因
这是单元格内部可用宽度和标签高度协商不一致,不能单纯归因为行高不足或固定表滚动错位。
1. 问诊列表采用 tech-blue 固定列宽 `92 + 158 = 250px`,视频列声明宽度为 92px;见 `_BLUE_COLUMN_WIDTHS` 和 `DiagnosisTableHost.__init__()`。
2. `app/src/doctor_workstation/ui/theme.py:701-703` 的全局 `QTableWidget::item, QTableView::item { padding: 6px 8px; ... }` 仍作用于固定表。Qt 为 index widget 分配的是 item 内容区,所以声明 92px 的视频列实际只给 `_FixedCell` 76px 宽。
3. `_video_widget()` 内又有左右各 3px、上下各 2px 的布局边距,标签实际宽度进一步降至 70px。
4. 视频布局整体设置 `QHBoxLayout.setAlignment(AlignCenter)`,状态标签同时 `setWordWrap(True)`。布局把标签约束到其 `sizeHint()` 高度,但受限宽度下实际 `heightForWidth()` 更高,产生标签内部上下裁切;`QLabel.AlignCenter` 使文字在不足的高度中居中,更容易出现首尾被切掉的观感。
5. `_sync_row_heights()` 只根据前十列/挂号内容计算行高,不考虑视频列。它是未来异常长文案的潜在缺口,但在本次现有服务端文案中,即使整行已有足够空间,标签也仍会被裁切。单纯增高整行无法修复标签高度仍被限制的问题。
### 本地实测
环境:Windows、PySide6 6.11.1、`QT_QPA_PLATFORM=offscreen`,调用真实 `apply_theme()`,tech-blue `DiagnosisTableHost`,Microsoft YaHei UI 13px。使用已预约视频诊单、只读视频状态、挂号含最近渠道,使行高为 82px。
| 状态文案 | 表格 visualRect | index widget | 标签尺寸 | 标签实际所需高度 |
| --- | --- | --- | --- | --- |
| `视频已结束 · 10-17 13:30` | 92 × 82 | 76 × 69,偏移 (8,6) | 70 × 33 | 50 |
| `上次通话已取消` | 92 × 82 | 76 × 69 | 70 × 16 | 33 |
| `上次通话未接听` | 92 × 82 | 76 × 69 | 70 × 16 | 33 |
因此 82px 行内仍有空间,但结束状态缺 17px 标签高度。未应用全局主题的独立 host 不会受到这层 item padding,结束状态可能恰好不裁切,容易给出错误的验证结论。
另一个无主题探针也确认:即便不受全局 padding 影响,整体 `AlignCenter` 仍能使长标签在宽度收缩后维持错误高度。例如含较长日期/标点的结束文案,标签 86 × 50,而 `heightForWidth(86)` 为 67;取消/未接听文案标签 86 × 16,实际需要 33。取消布局整体对齐后,82px 行中的标签可填满 86 × 78,已有文案能正常放下。
## 最小修复建议
1. 在固定表自己的局部 QSS 中清除 `::item` padding,让固定列的 index widget 使用真实列宽。范围应限定 `#DiagnosisFixedTable`,无需改变全局主题或临床数据主表的绘制规则。固定表两列均为 index widget,可同时避免操作按钮被额外压缩。
2. 去掉视频 `QHBoxLayout` 的整体 `AlignCenter`,让只读 `QLabel` 填充布局可用宽高。继续保留标签自己的 `AlignCenter`、`wordWrap=True`;交互按钮使用单个布局项对齐来保持居中。
3. 对服务端 `状态 · 时间` 文案,将这一明确分隔符变成展示换行,形成状态与时间层次;不要按任意字符截断,也不要丢弃时间。标签 tooltip 保存完整原文,便于查看完整时间或未来较长说明。
4. 保留现有状态、角色/权限、点击流程、列宽和双表行高同步规则。无须为本次已知文案扩大固定区域、改全局字号或统一增高所有行。
### 是否需要动态行高
服务端 `server/app/adminapi/lists/tcm/DiagnosisLists.php:500-540` 的完整现有状态契约为:
| state | 服务端 label / 桌面公开展示 |
| --- | --- |
| none | 空 label / `—` |
| live | `视频通话进行中` / 桌面简写 `通话中` |
| pending_room | `通话发起中,待同步房间` / 桌面简写 `接通中`,可进入但等待的医助路径为 `等待接通` |
| ended | `视频已结束 · m-d H:i`,无 end_time 时仅 `视频已结束` |
| missed | `上次通话未接听` |
| cancelled | `上次通话已取消` |
清除外层 item padding 后,92px 列减去内部 6px 边距,标签宽 86px;已知最长状态最多需要约 33px(两行),tech-blue 当前最小 72px 行可提供 68px 标签高度。因此当前契约无需增加动态行高。
截图叙述中的完整年份/秒并非当前服务端 `m-d H:i` 的固定格式;它可以作为兼容性测试数据,但不应据此修改后端时间格式。对于未来任意超长 label,如果产品要求完整正文始终可见,则需让状态展示与高度测量复用同一文本和实际宽度,并把其高度纳入双表 `max(...)` 行高;这属于额外防御,不是修复已知状态裁切的前提。
## 现有测试缺口
- `app/tests/test_diagnosis_index_visual.py:92-94` 的 QApplication fixture 没有调用 `apply_theme()`,无法复现全局 item padding。
- `test_dedicated_model_fixed_columns_selection_and_sort` 验证 92/158px 和 250px 的声明尺寸,未验证 index widget 真正得到的矩形。
- `test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable` 已覆盖双表像素滚动和行顶对齐,但不检查标签内部几何。
- `test_required_reference_artifacts_exist` 只验证参考图片存在和尺寸,没有检查状态字形或标签是否裁切。
- `app/tests/test_consultations_parity_ui.py` 的角色/权限回归只断言按钮、标签文字和信号。医生只读状态矩阵目前覆盖 live/pending_room/none,未覆盖 ended/missed/cancelled 及实际显示尺寸。
- `app/scripts/render_diagnosis_visual.py:72` 的常规样本使用字符串 `video_call_hint`,并没有 `ended + label + end_time` 的真实复合数据;已有 `artifacts/doctor-video-column.png` 也只展示三个短状态。
## 应覆盖的几何场景
| 场景 | 必要断言 |
| --- | --- |
| 真实全局主题 + ConsultationsPage + 82px 行 | 视频列 index widget 的宽度等于对应 visualRect 的宽度;不能只断言 columnWidth=92 |
| 最小 72px 行、不含最近渠道 | 状态标签处于单元格内部;`label.height() >= label.heightForWidth(label.width())`,并核对 contentsRect/文本所需高度 |
| ended 当前完整 label、无时间 label | 状态与时间均可读;全文 tooltip 保留;不生成可点击视频入口 |
| missed/cancelled 七字 label | 不因单行 sizeHint 只有 16px 而裁切两行正文 |
| full-year/seconds、显式换行、未知较长 label | 不出现横向越界;已承诺完整显示的文本高度满足要求,其余由全文 tooltip 可访问 |
| live/pending/none、未挂号、已完成、医助等待状态 | 原有状态及按钮规则保持;短文案仍居中 |
| 合法医助 live 按钮、医生 text 按钮、操作列 | 按钮完整可见且居中,操作项不被新布局拉伸或挤出 |
| 多预约/长挂号产生的较高行 | 标签居中不裁切;主表和固定表 rowHeight 仍相等 |
| 1024×640、1280×800、1366×768、1920×1080 | 窗口改变不压缩固定列;横向滚动条显隐前后视频 index widget 与 visualRect 一致 |
| 双表垂直滚动到中段/末尾 | 两表 viewport 和行顶保持同步;行本身被 viewport 正常截断应与标签内部错误裁切区分 |
| 100% / 125% / 150% 缩放及实际加载字体 | 使用真实主题与字体测量,允许平台像素取整误差,不能依赖单一截图像素数 |
| 静默相同刷新与状态变化刷新 | 同样数据继续保留既有 fixed widget 的零重建预算;变化后的标签按新文本重新布局 |
如果修改共享 `_video_widget()`,可再补 legacy 96px 视频列与 54px 最小行的轻量几何检查,避免布局更改引入其他页面回归;当前问诊列表验收以 tech-blue 92px 固定列为主。
本轮未运行既有 pytest,也未修改现有测试;以上数值来自离屏几何探针,不是生产客户端验收结果。
@@ -0,0 +1,36 @@
# 视频问诊固定列错位修复验证
日期:2026-10-17。范围:桌面问诊列表固定列布局。
## 修复
`app/src/doctor_workstation/ui/diagnosis_index_widgets.py`:
- 仅在固定表清除继承的 item padding,避免 92px 列被压成 76px 内容区。
- 状态标签填充单元格并居中绘制,避免布局把标签限制在旧的 sizeHint 高度。
- 将服务端 `状态 · 时间` 分成两行,完整原文留在 tooltip;使用纯文本显示。
- 视频入口按钮继续单独居中;列宽、行高及双表滚动机制保持。
详细根因审查见 [审查记录](video-column-layout-review.md)。
## 截图核对
使用真实 `apply_theme()`、`ConsultationsPage`、医生角色和离线测试数据渲染;无真实账号或网络请求。
- [修复前](video-column-before-page.png):单元格为 76×69,结束状态标签为 70×33,而所需高度为 50;首尾被裁切。
- [修复后](video-column-after-page.png):单元格为 92×81,标签可用区为 86×77;结束状态与时间两行所需高度 33,完整可读。
截图生成脚本保存在 `.codex-tasks/video-column-layout-check.py`,脚本当前读取修复后的源码。
## 实际回归结果
| 检查 | 结果 |
| --- | --- |
| 新布局回归修复前运行 | 4 个案例失败,复现标签高度不足/缺少全文提示 |
| `test_diagnosis_video_status_layout.py` | 8 passed:两种表格风格、1024×640 / 1710×920、有/无渠道最小行高;覆盖结束状态有/无时间、通话中、接通中、未接听、取消、空状态;检查真实可用尺寸、标签高度、居中、全文提示及滚动行顶同步 |
| `test_diagnosis_index_visual.py` | 26 passed |
| `test_consultations_parity_ui.py` + `test_text_consult_ui.py` | 87 passed,含医生/医助权限入口及图文流程 |
| Ruff:变更源码与新增测试 | 通过 |
| 变更源码 `git diff --check` | 通过 |
共 121 个相关 UI 回归通过。未打包、未更新正在运行的客户端;桌面端更新后生效。
@@ -0,0 +1,89 @@
# 医助复诊跟进(接诊率 / 触达节点 / 到期标红 / 到期弹窗)线上部署步骤
日期:2026-10-15。对应方案:`assistant-revisit-reach-reminder-2026-10-12.md`。代码在分支 `tongji-10-12`(工作区尚未提交)。本地 phpstudy 环境已完成 SQL 执行、全量快照初始化与真库交叉验证。
## 0. 发布前
1. 提交并推送 `tongji-10-12`,按团队流程合并到 `master`。
2. 本地没有 Node,前端必须在有 Node 的机器或 CI 上构建:
```bash
cd admin && npm install && npm run lint && npm run type-check && npm run build
```
`npm run build` 会把 `dist` 复制到 `server/public/admin`(`admin/scripts/release.mjs`),生产 API 地址取 `admin/.env.production` 的 `https://admin.zhenyangtang.com.cn/`。构建产物随后端代码一起发布。
3. 确认 `server/config/project.php` 两个开关的上线取值(文件随代码发布):
- `revisit_reach.toast_enabled`:仓库当前为 `true`(本地测试用)。建议上线首日改为 `false`,由主管先用诊单列表「复诊到期」chip 清理存量,再改为 `true`;改配置不需要发版。
- `revisit_reach.wecom_enabled`:保持 `false`,企微推送命令会空跑。
- `revisit_rate.default_inherit_mode`:保持 `exclude`,复诊接诊率页面与自动分配沿用原口径。
4. 线上 PHP 必须 8.0+,且所有 php-fpm/php-cgi 工作进程都已加载 `pdo_mysql`(本地曾出现单个 worker 未加载扩展导致接口间歇性报 could not find driver,重启 PHP 进程即可)。
## 1. 数据库(先于代码执行)
先备份:
```bash
mysqldump -u<user> -p zyt zyt_tcm_prescription_order zyt_tcm_diagnosis_assign_log zyt_tcm_diagnosis_auto_assign_log zyt_dev_crontab zyt_system_menu zyt_system_role_menu > backup_before_revisit_reach.sql
```
按序执行 `server/sql/1.9.20261012/01~10`(无自动迁移器):
```bash
for f in server/sql/1.9.20261012/*.sql; do mysql -u<user> -p --default-character-set=utf8mb4 zyt < "$f" || break; done
```
| 文件 | 内容 | 幂等 |
|---|---|---|
| 01 | `zyt_tcm_prescription_order` 覆盖索引 `idx_diag_del_fs_ct` | 否,重复执行报 1061 可忽略 |
| 02 / 03 | 快照表 `zyt_tcm_diagnosis_revisit_due`、流水表 `..._log` | 是 |
| 04 | 定时任务:全量 08:30、增量 */30 | 是 |
| 05 | 弹窗 6 个按钮权限,自动授予已有「中医诊单」权限的角色 | 是 |
| 06 | `zyt_tcm_diagnosis_assign_log` 新增 `inherit_visit_slot` 两列(代码查询依赖,必须先于代码) | 否 |
| 07 | `zyt_tcm_diagnosis_auto_assign_log` 新增 `visit2_rate_new_assign` / `rate_version`(自动分配命令依赖) | 否 |
| 08 | 「数据统计 → 复诊到期跟进」页面与流水权限,不自动授权 | 是 |
| 09 | 定时任务:企微 daily 09:00、tick */30(`wecom_enabled=false` 时空跑) | 是 |
| 10 | 个人业绩页「我的复诊接诊率」权限,自动授予已有个人业绩页的角色 | 是 |
本地 MySQL 5.7 已验证 10 个文件全部执行成功。
## 2. 发布代码
1. 同步 `server/`(含 `public/admin` 构建产物)到服务器;无新增 Composer 依赖。
2. 重载 php-fpm 刷新 opcache;执行 `php think clear` 清理运行时缓存。
3. 后台权限缓存按管理员单独缓存 1 小时,菜单 SQL 执行后最多 1 小时生效,或让相关账号重新登录。
## 3. 初始化与定时任务
```bash
cd /path/to/server
php think tcm:revisit-due-scan --mode=full --dry-run # 先看耗时与候选数(本地 4721 张诊单约 20 秒)
php think tcm:revisit-due-scan --mode=full # 正式初始化快照
php think tcm:backfill-inherit-visit-slot --dry-run # 继承诊次快照回填预览
php think tcm:backfill-inherit-visit-slot # 正式回填
php think tcm:auto-assign-pending --dry-run # 两套口径分档对照,确认默认仍为旧口径(v1)
```
确认系统 cron 每分钟触发 `php think crontab`(既有约定);后台「系统设置 → 定时任务」应出现 4 条新任务,首次 `last_time` 为空只登记下次执行时间。
## 4. 验收清单
1. 诊单列表「复诊节点」列显示第 N 诊、开药天数与来源、预计复诊日、状态 chip;到期未挂号/过号整行标红;顶部「复诊到期 N」角标与筛选后列表条数一致;只读详情页显示同一信息。
2. 以持有到期患者的医助登录,右下角出现汇总卡;延迟、已联系、人工关闭、去挂号四个动作可用;挂号成功后卡片与红行同时消失。
3. 「数据统计 → 复诊到期跟进」主管视图可筛选、导出、看流水;非授权角色打不开。
4. 「复诊接诊率」页面默认数字与上线前一致(原口径);切换「新口径」后出现继承数等列。
5. 自动分配 dry-run 的 v1 分档与上线前一致。
6. 个人业绩页出现「我的复诊接诊率」卡片(二中心医助),非二中心显示「暂无数据」。
## 5. 回滚
- 前端:恢复上一版 `server/public/admin`。
- 后端:回退代码并重载 php-fpm;新表、新列、新索引保留无害,不需要回滚 SQL。
- 定时任务:后台把 4 条新任务 `status` 改为停止,或 `UPDATE zyt_dev_crontab SET status=2 WHERE command IN ('tcm:revisit-due-scan','tcm:revisit-due-notify')`。
- 只想关弹窗:`revisit_reach.toast_enabled=false`,不需要发版。
## 6. 已知事项
- `server/app/mcp/catalog/generated.php` 已重生成,顺带暴露 4 个尚无审核条目的 `tcm.followupAudio/*` 只读接口(既有缺口,`AiMcpUnitTest` 只计数不失败)。
- 首次全量初始化会把历史逾期患者一次性置为到期,弹窗数量会很多,建议先关弹窗清存量。
- 本地验证中修正了一处口径:下单当天及更早的面诊不算这笔药的复诊(`revisit_reach.appointment_min_gap_days`,默认 1)。
@@ -0,0 +1,50 @@
# 复诊接诊率:保留原算法,新增统计使用独立入口
2026-10-17 更正:用户要求原来的算法保持不变。原页面、原接口及原计算逻辑已恢复至修改前版本。10 月 16 日曾将原 Controller 接入指派周期计算器,虽然旧计算器仍保留,但原页面的数据口径已改变;本次恢复覆盖了完整调用链。
## 三个独立入口
| 页面 | 路径 | 接口 | 口径 |
| --- | --- | --- | --- |
| 原复诊接诊率 | `/stats/revisit_rate` | `stats.revisitRate/*` | 原按月算法、默认配置及 exclude/slot 切换保持原样 |
| 按指派周期复诊率 | `/stats/revisit_assign_rate` | `stats.revisitAssignRate/*` | 按新指派日期范围及医助选患者,复诊累计至查询当天 |
| 继承复诊接诊率 | `/stats/revisit_inherit_rate` | `stats.revisitInheritRate/*` | 按继承日期及医助选患者,继承首笔有效订单算二诊、次笔三诊 |
原四个 action(overview、deptOptions、assignLines、visitOrderLines)继续直接调用原 RevisitRateLogic。原月份筛选、参数 month / inherit_mode / kind、分母、分子、全局诊次、订单持有人及部门汇总规则全部恢复。原 API 函数原字节保留,新接口函数以 revisitCohort 前缀追加,避免旧页面误用。
RevisitCohortLogic 使用独立 RevisitCohortDataSource 读取数据,不再继承或要求修改原 RevisitRateLogic 的方法可见性。自动指派、个人业绩、全局诊次服务和项目统计配置均与修改前逐字节一致。
## 独立新增报表
日期范围包含起止日,订单累计到查询时刻。继承前全局诊次及偏移不影响继承重新起算。转派/释放终止旧持有段;释放后重新分回原医助正常统计。新报表的去重及继承规则只作用于新接口。
两个新报表分别接受 start_date / end_date、dept_ids、assistant_id;明细可附 scope_assistant_id / dept_id,与顶层筛选取交集。继承类型由服务端固定,不能通过参数切换。
## 发布
原页面恢复需要发布恢复后的 PHP 与管理端构建,原菜单无需修改;本次未执行正式 release,未连接业务库,不能据本地证据声称线上已恢复。
启用独立新增页面时,依次执行:
1. `server/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql`
2. `server/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql`
3. 按部署流程刷新 AdminAuthCache 并重新登录。
脚本幂等,按原各项权限逐项授权,原角色没有订单明细权限时不会额外获得。两个脚本均不改写原菜单、原权限记录或业务数据。
## 本轮验证
- 10 个原链路文件(原页面、Controller、Logic、Calculator、诊次服务、配置、自动指派、个人业绩及 MCP 目录)与 HEAD=c67b3eff69d9a3ac2046861751f159692a0abcd7 字节一致;原 API 文件完整原字节作为前缀保留。证据:artifacts/revisit-legacy-source-proof.json。
- 原统计、继承诊次及诊次服务测试 122 项通过;新增队列纯计算 34 项通过。
- 本机隔离 MySQL 5.7 集成测试 37 项通过:实际 Controller 调用验证原 exclude/slot 的汇总及两类明细与原 Logic 完全一致;新增查询不改变原响应;独立新接口固定类型;两个菜单脚本幂等、逐权限授权且不改写原菜单。
- 四份 Vue SFC 编译及 12 个真实 API wrapper 路径调用通过;浏览器 20 项检查通过,包含原月筛选、原口径切换、原明细参数及新增两页入口隔离。浏览器使用虚构接口响应,数据库测试使用独立本机临时库。
- MCP 目录单元测试及 PHP 语法检查通过。
- 最终 Vite 生产构建通过,输出到 `.codex-tasks/revisit-legacy-restored-build`,未执行 release。
集成测试须在本机独立临时 MySQL 上显式启用:
```powershell
$env:REVISIT_COHORT_TEST_DISPOSABLE = '1'
$env:REVISIT_COHORT_TEST_MYSQL_PORT = '33817'
php server/tests/RevisitCohortDatabaseTest.php
```
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\RevisitCohortLogic;
/**
* 新指派复诊接诊率(按指派日期选患者,订单累计至查询时刻)
*
* - GET stats.revisitAssignRate/overview N 诊接诊率(部门 → 医助分组 + 合计)
* - GET stats.revisitAssignRate/deptOptions 部门下拉(前端组树)
* - GET stats.revisitAssignRate/assignLines 「被指派数」明细(按诊单聚合)
* - GET stats.revisitAssignRate/visitOrderLines 「N 诊单数」订单明细
*/
class RevisitAssignRateController extends BaseAdminController
{
protected const COHORT_TYPE = 'new';
public function overview()
{
// 涉及指派日志 + 全量订单序列扫描,放宽执行时间兜底
@set_time_limit(120);
$params = $this->request->get();
try {
return $this->data(RevisitCohortLogic::overview($params, static::COHORT_TYPE));
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage());
}
}
public function deptOptions()
{
return $this->data(RevisitCohortLogic::deptOptions());
}
/** 「被指派数」点击下钻:诊单维度明细 */
public function assignLines()
{
@set_time_limit(120);
$params = $this->request->get();
try {
return $this->data(RevisitCohortLogic::assignLines($params, static::COHORT_TYPE));
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage());
}
}
/** 「N 诊单数」点击下钻:具体订单明细 */
public function visitOrderLines()
{
@set_time_limit(120);
$params = $this->request->get();
try {
return $this->data(RevisitCohortLogic::visitOrderLines($params, static::COHORT_TYPE));
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage());
}
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
/** 继承复诊接诊率独立接口,统计类型由服务端固定,权限与新指派页分开登记。 */
class RevisitInheritRateController extends RevisitAssignRateController
{
protected const COHORT_TYPE = 'inherit';
}
@@ -93,6 +93,19 @@ final class FollowupAudioController extends BaseAdminController
$this->items($p), $this->adminId, $this->adminInfo), true);
}
public function delete()
{
return $this->handle(true, ['id', 'version'], fn (array $p): array =>
Logic::delete($this->positive($p, 'id'), $this->positive($p, 'version'), $this->adminId, $this->adminInfo));
}
public function reextract()
{
return $this->handle(true, ['id', 'version', 'channel_roles'], fn (array $p): array =>
Logic::reextract($this->positive($p, 'id'), $this->positive($p, 'version'),
$p['channel_roles'] ?? null, $this->adminId, $this->adminInfo), true);
}
public function retry()
{
return $this->handle(true, ['id'], fn (array $p): array =>
@@ -6,10 +6,32 @@ namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\tcm\PrescriptionAiLogic;
use app\common\service\prescriptionai\PrescriptionAiVisibility;
use DomainException;
final class PrescriptionAiController extends BaseAdminController
{
public function capabilities()
{
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::capabilities($this->adminId));
}
public function getVisibilityConfig()
{
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::getConfig($this->adminId));
}
public function setVisibilityConfig()
{
return $this->handle(true, ['role_ids', 'admin_ids'],
fn (array $p): array => PrescriptionAiVisibility::setConfig($p, $this->adminId));
}
public function visibilityOptions()
{
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::options($this->adminId));
}
public function statuses()
{
return $this->handle(false, ['ids'], function (array $p): array {
@@ -235,7 +235,7 @@ class PrescriptionOrderController extends BaseAdminController
}
/**
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
* 任意履约状态下修改主辅方服用参数(浓缩水丸袋数、次数、开立天数)及订单服用天数,并记录日志
*/
public function patchPrescriptionUsage()
{
@@ -83,10 +83,12 @@ class AuthMiddleware
// 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'];
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
$action = substr($accessUri, strlen('tcm.followupaudio/'));
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)) {
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)
&& in_array('tcm.diagnosis/followupaudio', $uris, true)
&& ($action !== 'delete' || in_array('tcm.followupaudio/delete', $uris, true))) {
return $next($request); // The service also checks the current patient scope and daily permissions.
}
return JsonService::fail('权限不足,无法访问或操作');
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\service\tcm\RevisitVisitSlotService;
/** 指派日期队列:先圈定患者,再累计其持有期间的有效复诊订单;不改变全局诊次。 */
final class RevisitCohortCalculator
{
public const TYPE_NEW = 'new';
public const TYPE_INHERIT = 'inherit';
/**
* 日志与订单分别按 (diagnosis_id, create_time, id) 升序。
* 新指派保留全局诊次;继承每次从二诊重新起算,同一秒订单归最新指派。
* 同一医助、诊单、诊次只计一次,避免反复继承把单诊接诊率推高至 100% 以上。
*/
public static function compute(
array $logRows,
iterable $orderRows,
array $offsetMap,
array $refundRejectSet,
array $rollbackKeySet,
int $startTs,
int $endTs,
int $observedAt,
string $type
): array {
$type = $type === self::TYPE_INHERIT ? self::TYPE_INHERIT : self::TYPE_NEW;
$timeline = [];
$pairs = [];
$lastTarget = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$from = (int) ($r['from_assistant_id'] ?? 0);
$time = (int) ($r['create_time'] ?? 0);
if ($did <= 0 || $time > $observedAt) {
continue;
}
// 从未分配重新指派时,写入侧可能把 from 回填为原医助,形成 A→A。
// 以实际时间线判断持有人是否变化,保留释放后的重新承接。
$previous = $lastTarget[$did] ?? null;
if (($previous !== null && $previous === $aid) || ($previous === null && $from === $aid)) {
continue;
}
$lastTarget[$did] = $aid;
$isRollback = isset($rollbackKeySet[RevisitRateCalculator::rollbackKey(
$did, $time, (int) ($r['operator_admin_id'] ?? 0)
)]);
$eventType = (int) ($r['is_inherit'] ?? 0) === 1 || $isRollback
? self::TYPE_INHERIT : self::TYPE_NEW;
$selected = $aid > 0 && $time >= $startTs && $time <= $endTs
&& $eventType === $type && !isset($refundRejectSet[$did]);
$timeline[$did][] = [
'assistant_id' => $aid,
'time' => $time,
'selected' => $selected,
'type' => $eventType,
];
if (!$selected) {
continue;
}
if (!isset($pairs[$aid][$did])) {
$pairs[$aid][$did] = [
'diagnosis_id' => $did,
'assistant_id' => $aid,
'assign_type' => $type,
'assign_count' => 0,
'first_assign_time' => $time,
'last_assign_time' => $time,
'is_rollback' => 0,
];
}
$pairs[$aid][$did]['assign_count']++;
$pairs[$aid][$did]['last_assign_time'] = $time;
$pairs[$aid][$did]['is_rollback'] |= $isRollback ? 1 : 0;
}
$orders = [];
$curDid = 0;
$globalSeq = 0;
$segmentSeq = 0;
$ptr = 0;
$offset = 0;
$event = null;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$ct = (int) ($r['create_time'] ?? 0);
if ($did <= 0 || $ct > $observedAt) {
continue;
}
if ($curDid !== $did) {
$curDid = $did;
$globalSeq = 0;
$segmentSeq = 0;
$ptr = 0;
$event = null;
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$globalSeq++;
$tl = $timeline[$did] ?? [];
while ($ptr < \count($tl) && $tl[$ptr]['time'] <= $ct) {
$event = $tl[$ptr++];
$segmentSeq = 0;
}
if ($event === null) {
continue;
}
$segmentSeq++;
if (!$event['selected']) {
continue;
}
$slot = $type === self::TYPE_INHERIT ? $segmentSeq + 1 : $globalSeq + $offset;
if ($slot < 2 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) {
continue;
}
$aid = $event['assistant_id'];
// 重复承接同一患者时,明细保留首次达到该诊次的订单,与人数指标逐项对账。
if (isset($orders[$aid][$slot][$did])) {
continue;
}
$r['assistant_id'] = $aid;
$r['visit_slot'] = $slot;
$r['global_visit_slot'] = $globalSeq + $offset;
$r['cohort_assign_time'] = $event['time'];
$orders[$aid][$slot][$did] = $r;
}
return ['pairsByAssistant' => $pairs, 'ordersByAssistant' => $orders, 'cohort_type' => $type];
}
/** 部门与总计按诊单去重;每个诊次保留同一患者最早一笔订单。 */
public static function scopedOrders(array $calc, array $assistantIds, int $slot = 0): array
{
$out = [];
foreach ($assistantIds as $aid) {
foreach ($calc['ordersByAssistant'][$aid] ?? [] as $s => $byDiag) {
if ($slot > 0 && $s !== $slot) {
continue;
}
foreach ($byDiag as $did => $r) {
$previous = $out[$s][$did] ?? null;
if ($previous === null || [(int) $r['create_time'], (int) $r['id']]
< [(int) $previous['create_time'], (int) $previous['id']]) {
$out[$s][$did] = $r;
}
}
}
}
return $out;
}
public static function metricPack(array $calc, array $assistantIds, array $slots): array
{
$diagSet = [];
foreach ($assistantIds as $aid) {
foreach ($calc['pairsByAssistant'][$aid] ?? [] as $did => $_) {
$diagSet[$did] = true;
}
}
$count = \count($diagSet);
$inherit = $calc['cohort_type'] === self::TYPE_INHERIT;
$pack = [
'assigned_count' => $inherit ? 0 : $count,
'inherit_count' => $inherit ? $count : 0,
'assigned_total_count' => $count,
];
$orders = self::scopedOrders($calc, $assistantIds);
$all = array_sum(array_map('count', $orders));
foreach ($slots as $slot) {
$num = \count($orders[$slot] ?? []);
$pack['visit' . $slot . '_count'] = $num;
$pack['visit' . $slot . '_den'] = $count;
$pack['visit' . $slot . '_rate'] = $count > 0 ? round($num / $count * 100, 2) : null;
}
$pack['visit_all_count'] = $all;
$pack['visit_all_den'] = $count;
$pack['visit_all_rate'] = $count > 0 ? round($all / $count * 100, 2) : null;
return $pack;
}
/** 严格校验日期,避免非法日期被 strtotime 自动滚到次月。 */
public static function dateRange(array $params, int $now): array
{
$start = trim((string) ($params['start_date'] ?? ''));
$end = trim((string) ($params['end_date'] ?? ''));
if ($start === '' && $end === '') {
$month = trim((string) ($params['month'] ?? ''));
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
$start = $month . '-01';
$end = date('Y-m-t', (int) strtotime($start));
} else {
$start = date('Y-m-01', $now);
$end = date('Y-m-d', $now);
}
}
foreach ([$start, $end] as $date) {
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new \InvalidArgumentException('请选择有效的开始日期和结束日期');
}
}
if ($start > $end) {
throw new \InvalidArgumentException('开始日期不能晚于结束日期');
}
return [$start, $end, (int) strtotime($start . ' 00:00:00'), (int) strtotime($end . ' 23:59:59')];
}
}
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\service\tcm\RevisitVisitSlotService;
use think\facade\Db;
/** 新报表独立读取方法,保持原复诊率 Logic 源码及调用链不变。 */
trait RevisitCohortDataSource
{
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;
}
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;
}
}
}
private static function buildAssistantDeptIndex(array $adminIds): array
{
if ($adminIds === []) {
return [[], []];
}
// admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性
$relRows = Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->order(['admin_id' => 'asc', 'dept_id' => 'asc'])
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$deptNames = Db::name('dept')
->whereNull('delete_time')
->column('name', 'id');
$canonical = [];
foreach ($relRows as $r) {
$aid = (int) ($r['admin_id'] ?? 0);
$deptId = (int) ($r['dept_id'] ?? 0);
if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) {
continue;
}
if (!isset($deptNames[$deptId])) {
continue;
}
$canonical[$aid] = $deptId;
}
$names = [];
foreach ($deptNames as $id => $name) {
$names[(int) $id] = (string) $name;
}
return [$canonical, $names];
}
private static function resolveDeptFilterSet(mixed $raw): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return [];
}
$deptFilterIds = self::parseDeptIds($raw);
if ($deptFilterIds === []) {
return $erSet;
}
$allowedRoots = [];
foreach ($deptFilterIds as $id) {
if (isset($erSet[$id])) {
$allowedRoots[] = $id;
}
}
if ($allowedRoots === []) {
return [];
}
$expanded = self::expandDeptSubtreeSet($allowedRoots);
$out = [];
foreach ($expanded as $id => $_) {
if (isset($erSet[$id])) {
$out[$id] = true;
}
}
return $out;
}
private static function parseDeptIds(mixed $raw): array
{
if ($raw === null || $raw === '' || $raw === []) {
return [];
}
$list = \is_array($raw) ? $raw : explode(',', (string) $raw);
return array_values(array_unique(array_filter(
array_map('intval', $list),
static fn (int $v): bool => $v > 0
)));
}
private static function expandDeptSubtreeSet(array $deptIds): array
{
$rows = Db::name('dept')
->whereNull('delete_time')
->field(['id', 'pid'])
->select()
->toArray();
$childrenByPid = [];
foreach ($rows as $r) {
$childrenByPid[(int) $r['pid']][] = (int) $r['id'];
}
$set = [];
$queue = $deptIds;
while ($queue !== []) {
$id = (int) array_shift($queue);
if ($id <= 0 || isset($set[$id])) {
continue;
}
$set[$id] = true;
foreach ($childrenByPid[$id] ?? [] as $childId) {
$queue[] = $childId;
}
}
return $set;
}
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$ids = Db::name('tcm_prescription_order')
->whereIn('diagnosis_id', $chunk)
->whereNull('delete_time')
->whereIn('fulfillment_status', [9, 10])
->group('diagnosis_id')
->column('diagnosis_id');
foreach ($ids as $id) {
$out[(int) $id] = true;
}
}
return $out;
}
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
}
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
}
private static function fetchDiagnosisInfo(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $diagIds)
->field(['id', 'patient_name', 'phone'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
'phone' => trim((string) ($r['phone'] ?? '')),
];
}
return $out;
}
private static function cohortDeptOptions(): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return ['rows' => []];
}
$rows = Db::name('dept')
->whereNull('delete_time')
->whereIn('id', array_keys($erSet))
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
->select()
->toArray();
return [
'rows' => array_map(static fn (array $r): array => [
'id' => (int) $r['id'],
'pid' => (int) $r['pid'],
'name' => (string) $r['name'],
], $rows),
];
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use think\facade\Db;
/** 指派 / 继承独立报表。旧按月计算器继续服务自动分配与个人业绩。 */
class RevisitCohortLogic
{
use RevisitCohortDataSource;
public static function overview(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array
{
$ctx = self::cohortContext($params, $type);
$calc = $ctx['calc'];
$aids = array_keys($calc['pairsByAssistant']);
$names = $aids === [] ? [] : Db::name('admin')->whereIn('id', $aids)->column('name', 'id');
$maxSlot = 4;
foreach ($calc['ordersByAssistant'] as $bySlot) {
foreach ($bySlot as $slot => $_) {
$maxSlot = max($maxSlot, $slot);
}
}
$slots = range(2, $maxSlot);
$byDept = [];
foreach ($aids as $aid) {
$deptId = $ctx['assistantDept'][$aid];
$byDept[$deptId][] = $aid;
}
$rows = [];
foreach ($byDept as $deptId => $deptAids) {
$children = [];
$deptName = (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId));
foreach ($deptAids as $aid) {
$children[] = [
'row_key' => 'a' . $aid,
'is_dept' => 0,
'dept_id' => $deptId,
'dept_name' => $deptName,
'assistant_id' => $aid,
'assistant_name' => (string) ($names[$aid] ?? ('#' . $aid)),
] + RevisitCohortCalculator::metricPack($calc, [$aid], $slots);
}
usort($children, static fn (array $a, array $b): int =>
($b['assigned_total_count'] <=> $a['assigned_total_count'])
?: strcmp($a['assistant_name'], $b['assistant_name']));
$rows[] = [
'row_key' => 'd' . $deptId,
'is_dept' => 1,
'dept_id' => $deptId,
'dept_name' => $deptName,
'assistant_count' => \count($children),
'children' => $children,
] + RevisitCohortCalculator::metricPack($calc, $deptAids, $slots);
}
usort($rows, static fn (array $a, array $b): int =>
($b['assigned_total_count'] <=> $a['assigned_total_count'])
?: strcmp($a['dept_name'], $b['dept_name']));
return self::metadata($ctx) + [
'slots' => $slots,
'total' => RevisitCohortCalculator::metricPack($calc, $aids, $slots),
'rows' => $rows,
];
}
public static function assignLines(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array
{
$ctx = self::cohortContext($params, $type);
$aids = self::scopedAssistantIds($ctx, $params);
$byDiag = [];
foreach ($aids as $aid) {
foreach ($ctx['calc']['pairsByAssistant'][$aid] ?? [] as $did => $pair) {
if (!isset($byDiag[$did])) {
$byDiag[$did] = $pair + ['assistants' => []];
$byDiag[$did]['assign_count'] = 0;
}
$byDiag[$did]['assistants'][$aid] = true;
$byDiag[$did]['assign_count'] += $pair['assign_count'];
$byDiag[$did]['first_assign_time'] = min($byDiag[$did]['first_assign_time'], $pair['first_assign_time']);
$byDiag[$did]['last_assign_time'] = max($byDiag[$did]['last_assign_time'], $pair['last_assign_time']);
$byDiag[$did]['is_rollback'] |= $pair['is_rollback'];
}
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
$names = $aids === [] ? [] : Db::name('admin')->whereIn('id', $aids)->column('name', 'id');
$rows = [];
foreach ($byDiag as $did => $pair) {
$assistantNames = [];
foreach ($pair['assistants'] as $aid => $_) {
$assistantNames[] = (string) ($names[$aid] ?? ('#' . $aid));
}
$rows[] = [
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_names' => implode('、', $assistantNames),
'assign_count' => $pair['assign_count'],
'first_assign_time_text' => date('Y-m-d H:i:s', $pair['first_assign_time']),
'last_assign_time' => $pair['last_assign_time'],
'last_assign_time_text' => date('Y-m-d H:i:s', $pair['last_assign_time']),
'assign_type' => $ctx['calc']['cohort_type'],
'is_rollback' => $pair['is_rollback'],
];
}
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
return self::metadata($ctx) + ['count' => \count($rows), 'rows' => $rows];
}
public static function visitOrderLines(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array
{
$slot = (int) ($params['slot'] ?? 0);
if ($slot < 0 || $slot === 1 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) {
throw new \InvalidArgumentException('诊次须为 2~50,或 0(复诊合计)');
}
$ctx = self::cohortContext($params, $type);
$orders = RevisitCohortCalculator::scopedOrders($ctx['calc'], self::scopedAssistantIds($ctx, $params), $slot);
$flat = [];
$adminIds = [];
$diagIds = [];
foreach ($orders as $byDiag) {
foreach ($byDiag as $did => $r) {
$flat[] = $r;
$diagIds[$did] = true;
$adminIds[(int) $r['assistant_id']] = true;
if ((int) ($r['creator_id'] ?? 0) > 0) {
$adminIds[(int) $r['creator_id']] = true;
}
}
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($diagIds));
$names = $adminIds === [] ? [] : Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id');
$rows = [];
foreach ($flat as $r) {
$did = (int) $r['diagnosis_id'];
$aid = (int) $r['assistant_id'];
$cid = (int) ($r['creator_id'] ?? 0);
$rows[] = [
'order_id' => (int) $r['id'],
'order_no' => (string) ($r['order_no'] ?? ''),
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'amount' => round((float) ($r['amount'] ?? 0), 2),
'create_time' => (int) $r['create_time'],
'create_time_text' => date('Y-m-d H:i:s', (int) $r['create_time']),
'assistant_id' => $aid,
'assistant_name' => (string) ($names[$aid] ?? ('#' . $aid)),
'creator_name' => $cid > 0 ? (string) ($names[$cid] ?? ('#' . $cid)) : '—',
'visit_slot' => (int) $r['visit_slot'],
'global_visit_slot' => (int) $r['global_visit_slot'],
'cohort_assign_time_text' => date('Y-m-d H:i:s', (int) $r['cohort_assign_time']),
];
}
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
return self::metadata($ctx) + ['slot' => $slot, 'count' => \count($rows), 'rows' => $rows];
}
/** 保留部门下拉结构,补充二中心内医助;不影响复诊到期页对父类 deptOptions 的复用。 */
public static function deptOptions(): array
{
$options = self::cohortDeptOptions();
$deptSet = self::resolveDeptFilterSet(null);
if ($deptSet === []) {
return $options + ['assistants' => []];
}
$ids = Db::name('admin_dept')->whereIn('dept_id', array_keys($deptSet))->group('admin_id')->column('admin_id');
[$assistantDept] = self::buildAssistantDeptIndex($ids);
$admins = $ids === [] ? [] : Db::name('admin')->whereIn('id', $ids)->whereNull('delete_time')
->field(['id', 'name'])->order('name', 'asc')->select()->toArray();
$assistants = [];
foreach ($admins as $admin) {
$deptId = (int) ($assistantDept[(int) $admin['id']] ?? 0);
if (isset($deptSet[$deptId])) {
$assistants[] = ['id' => (int) $admin['id'], 'name' => (string) $admin['name'], 'dept_id' => $deptId];
}
}
return $options + ['assistants' => $assistants];
}
private static function cohortContext(array $params, string $type): array
{
$now = time();
[$start, $end, $startTs, $endTs] = RevisitCohortCalculator::dateRange($params, $now);
// 先按指派周期圈定诊单,再读取其完整时间线;避免小范围查询拉取全库历史日志。
$candidateQuery = Db::name('tcm_diagnosis_assign_log')->alias('lg')
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', '>=', $startTs)->where('lg.create_time', '<=', min($endTs, $now))
->where('lg.to_assistant_id', '>', 0)->where('lg.diagnosis_id', '>', 0);
$filterAid = (int) ($params['assistant_id'] ?? 0);
if ($filterAid > 0) {
$candidateQuery->where('lg.to_assistant_id', $filterAid);
}
$candidateIds = array_map('intval', $candidateQuery->group('lg.diagnosis_id')->column('lg.diagnosis_id'));
$logs = [];
foreach (array_chunk($candidateIds, 2000) as $chunk) {
$chunkLogs = Db::name('tcm_diagnosis_assign_log')->alias('lg')
->whereIn('lg.diagnosis_id', $chunk)->where('lg.create_time', '<=', $now)
->field(['lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id',
'lg.operator_admin_id', 'lg.create_time', 'lg.is_inherit'])
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
->select()->toArray();
array_push($logs, ...$chunkLogs);
}
$calc = RevisitCohortCalculator::compute(
$logs,
self::iterateOrderSeqRows($candidateIds, ['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']),
self::fetchRevisitSlotStartOffsetMap($candidateIds),
self::fetchRefundOrRejectDiagnosisSet($candidateIds),
self::fetchRollbackKeySet($startTs, min($endTs, $now)),
$startTs, $endTs, $now, $type
);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex(array_keys($calc['pairsByAssistant']));
$deptSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
foreach (array_keys($calc['pairsByAssistant']) as $aid) {
if (!isset($deptSet[$assistantDept[$aid] ?? 0]) || ($filterAid > 0 && $filterAid !== $aid)) {
unset($calc['pairsByAssistant'][$aid], $calc['ordersByAssistant'][$aid]);
}
}
return ['calc' => $calc, 'assistantDept' => $assistantDept, 'deptNames' => $deptNames,
'start_date' => $start, 'end_date' => $end, 'observed_until' => date('Y-m-d H:i:s', $now)];
}
private static function scopedAssistantIds(array $ctx, array $params): array
{
$aids = array_keys($ctx['calc']['pairsByAssistant']);
$scopeAid = (int) ($params['scope_assistant_id'] ?? 0);
if ($scopeAid > 0) {
return \in_array($scopeAid, $aids, true) ? [$scopeAid] : [];
}
if (isset($params['dept_id']) && $params['dept_id'] !== '') {
$deptId = (int) $params['dept_id'];
$aids = array_values(array_filter($aids, static fn (int $aid): bool => ($ctx['assistantDept'][$aid] ?? 0) === $deptId));
}
return $aids;
}
private static function metadata(array $ctx): array
{
return ['start_date' => $ctx['start_date'], 'end_date' => $ctx['end_date'],
'observed_until' => $ctx['observed_until'], 'cohort_type' => $ctx['calc']['cohort_type']];
}
}
@@ -12,6 +12,7 @@ use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConf
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use DomainException;
use app\common\service\followupaudio\FollowupAudioReextract as Reextract;
final class FollowupAudioLogic
{
@@ -70,7 +71,8 @@ final class FollowupAudioLogic
public static function lists(int $diagnosisId, int $actor, array $info): array
{
Access::diagnosis($diagnosisId, $actor, $info);
return ['items' => Store::enabled() ? Store::lists($diagnosisId) : []];
return ['items' => Store::enabled() ? array_map(static fn (array $task): array =>
self::withDeleteCapability($task, $actor), Store::lists($diagnosisId)) : []];
}
public static function detail(int $id, int $actor, array $info): array
@@ -96,6 +98,30 @@ final class FollowupAudioLogic
return Apply::apply($id, $version, $items, $actor, $info);
}
public static function delete(int $id, int $version, int $actor, array $info): array
{
self::requireEnabled();
return Store::delete($id, $version, $actor, $info);
}
private static function withDeleteCapability(array $summary, int $actor): array
{
$task = Store::task((int) $summary['id']);
$info = Access::actor($actor);
$reason = !Store::enabled() ? '回访录音功能尚未启用' : Access::deleteReason($task, $actor);
if ($reason === '') { $reason = Store::deleteBlockReason($task); }
$reextractReason = (int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor
? '仅上传者或超级管理员可重新提炼' : Reextract::blockReason($task);
return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason,
'can_reextract' => $reextractReason === '', 'reextract_block_reason' => $reextractReason];
}
public static function reextract(int $id, int $version, $roles, int $actor, array $info): array
{
self::requireEnabled(true);
return self::protectDetail(Reextract::queue($id, $version, $roles, $actor, $info), $actor, $info);
}
public static function retry(int $id, int $actor, array $info): array
{
self::requireEnabled(true);
@@ -160,7 +186,7 @@ final class FollowupAudioLogic
}
unset($item);
}
return $detail;
return self::withDeleteCapability($detail, $actor);
}
}
@@ -14,6 +14,7 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
use app\common\service\prescriptionai\PrescriptionAiStatistics;
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
use DomainException;
use think\facade\Db;
@@ -22,15 +23,16 @@ final class PrescriptionAiLogic
public static function statuses(array $ids, int $actor, array $info): array
{
self::requirePermission('statuses', $actor, $info);
$agreementVisible = Visibility::agreementVisible($actor);
if (!Store::enabled()) {
return ['enabled' => false, 'items' => []];
return ['enabled' => false, 'agreement_visible' => $agreementVisible, 'items' => []];
}
$ids = array_values(array_unique(array_map('intval', $ids)));
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
throw new DomainException('最多查询100个有效处方');
}
if ($ids === []) {
return ['enabled' => true, 'items' => []];
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => []];
}
$subjects = Db::name('prescription_ai_subject')->whereIn('prescription_id', $ids)->column('latest_batch_id', 'prescription_id');
$batches = $subjects ? Db::name('prescription_ai_batch')->whereIn('id', array_values($subjects))->select()->toArray() : [];
@@ -50,18 +52,20 @@ final class PrescriptionAiLogic
// Do not reveal that a report with a broader source scope exists.
continue;
}
$items[] = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
$item = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
'prescription_id' => $id, 'batch_id' => null,
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
];
$items[] = Visibility::projectBatch($item, $agreementVisible);
}
return ['enabled' => true, 'items' => $items];
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => $items];
}
public static function reports(array $params, int $actor, array $info): array
{
self::requirePermission('reports', $actor, $info);
$agreementVisible = Visibility::agreementVisible($actor);
$rxId = (int) ($params['prescription_id'] ?? 0);
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
if (($rxId > 0) === ($diagnosisId > 0)) {
@@ -100,22 +104,24 @@ final class PrescriptionAiLogic
} while (count($chunk) === 200);
$rows = array_slice($visible, ($page - 1) * $size, $size);
$modelMap = self::models(array_column($rows, 'id'), false);
return ['lists' => array_map(static fn ($b): array => self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $rows),
return ['agreement_visible' => $agreementVisible,
'lists' => array_map(static fn ($b): array => Visibility::projectBatch(self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $agreementVisible), $rows),
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
}
public static function detail(int $batchId, int $actor, array $info): array
{
self::requirePermission('detail', $actor, $info);
$agreementVisible = Visibility::agreementVisible($actor);
$batch = self::loadBatch($batchId, $actor, $info);
$models = self::models([$batchId], true);
$models = self::models([$batchId], true, $agreementVisible);
$detail = self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
// loadBatch has checked both current prescription and historical source permissions.
// This cipher is saved at enqueue time, so it is available before models start and
// remains the correct original when the user selects an older report revision.
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
: DoctorSnapshot::project((new Cipher())->decrypt($batch['prescription_cipher'], 'prescription'));
return $detail;
return Visibility::projectBatch($detail, $agreementVisible);
}
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
@@ -205,6 +211,9 @@ final class PrescriptionAiLogic
public static function statistics(array $params, int $actor, array $info): array
{
if (!Visibility::agreementVisible($actor)) {
throw new DomainException('无权查看处方AI一致度统计');
}
self::requirePermission('statistics', $actor, $info);
self::requirePermission('detail', $actor, $info);
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
@@ -271,7 +280,7 @@ final class PrescriptionAiLogic
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
}
private static function models(array $batchIds, bool $full): array
private static function models(array $batchIds, bool $full, bool $agreementVisible = true): array
{
if ($batchIds === []) {
return [];
@@ -301,7 +310,7 @@ final class PrescriptionAiLogic
foreach ($results as $result) {
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
if ($full) {
$body = self::localizeSourceMetadata($body);
$body = Visibility::reportBody(self::localizeSourceMetadata($body), $agreementVisible);
}
unset($result['body_cipher']);
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
@@ -5763,7 +5763,7 @@ class PrescriptionOrderLogic
}
/**
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
* 业务订单详情场景:任意履约状态下更新主辅方服用参数,变更必须写入操作日志
*
* @param array<string, mixed> $params
*/
@@ -5774,7 +5774,8 @@ class PrescriptionOrderLogic
try {
return (bool) LockedPharmacySnapshotMutation::execute(
$prescriptionOrderId,
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo),
false // 服用参数允许在提交药房后修正,继续保留订单/提交记录行锁与事务。
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
@@ -5799,21 +5800,13 @@ class PrescriptionOrderLogic
return false;
}
if (!self::assertRemoteSnapshotMutable($order)) {
return false;
}
if ((int) $order->fulfillment_status === 4) {
self::setError('已取消的订单不可修改');
return false;
}
$rxId = (int) ($order->prescription_id ?? 0);
if ($rxId <= 0) {
self::setError('该订单未关联处方');
return false;
}
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->lock(true)->find();
if (!$rx) {
self::setError('处方不存在');
@@ -5828,25 +5821,37 @@ class PrescriptionOrderLogic
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
$usageDays = (int) ($params['usage_days'] ?? 0);
$medDays = (int) ($params['medication_days'] ?? 0);
if ($timesPerDay < 1 || $timesPerDay > 6) {
if (!self::isPrescriptionUsageInteger($params['times_per_day'] ?? null, 6)) {
self::setError('主方每天次数须在 1~6 之间');
return false;
}
if ($usageDays < 1 || $usageDays > 999) {
if (!self::isPrescriptionUsageInteger($params['usage_days'] ?? null, 999)) {
self::setError('主方开立天数须在 1~999 之间');
return false;
}
if ($medDays < 1 || $medDays > 999) {
if (!self::isPrescriptionUsageInteger($params['medication_days'] ?? null, 999)) {
self::setError('订单服用天数须在 1~999 之间');
return false;
}
$isWaterPill = (string) $rx->prescription_type === '浓缩水丸';
$dosageBagCount = null;
if ($isWaterPill && array_key_exists('dosage_bag_count', $params)) {
if (!self::isPrescriptionUsageInteger($params['dosage_bag_count'], 5)) {
self::setError('主方袋数须为 1~5 的整数');
return false;
}
$dosageBagCount = (int) $params['dosage_bag_count'];
}
$hasAux = self::prescriptionHasAuxFormula($rx);
$auxTimesPerDay = null;
$auxUsageDays = null;
$auxDosageBagCount = null;
if ($hasAux) {
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
self::setError('含辅方处方须填写辅方服用参数');
@@ -5855,20 +5860,29 @@ class PrescriptionOrderLogic
}
$auxTimesPerDay = (int) $params['aux_times_per_day'];
$auxUsageDays = (int) $params['aux_usage_days'];
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
if (!self::isPrescriptionUsageInteger($params['aux_times_per_day'], 6)) {
self::setError('辅方每天次数须在 1~6 之间');
return false;
}
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
if (!self::isPrescriptionUsageInteger($params['aux_usage_days'], 999)) {
self::setError('辅方开立天数须在 1~999 之间');
return false;
}
if ($isWaterPill && array_key_exists('aux_dosage_bag_count', $params)) {
if (!self::isPrescriptionUsageInteger($params['aux_dosage_bag_count'], 5)) {
self::setError('辅方袋数须为 1~5 的整数');
return false;
}
$auxDosageBagCount = (int) $params['aux_dosage_bag_count'];
}
}
$oldTimes = (int) ($rx->times_per_day ?? 0);
$oldUsageDays = (int) ($rx->usage_days ?? 0);
$oldDosageBagCount = max(1, (int) ($rx->dosage_bag_count ?? 1));
$oldMedDays = (int) ($order->medication_days ?? 0);
$oldAuxUsage = $rx->aux_usage;
if (is_string($oldAuxUsage)) {
@@ -5880,16 +5894,48 @@ class PrescriptionOrderLogic
}
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
$oldAuxDosageBagCount = max(1, (int) ($oldAuxUsage['dosage_bag_count'] ?? 1));
$changes = [
'主方每天次数' => [$oldTimes, $timesPerDay, '次'],
'主方开立天数' => [$oldUsageDays, $usageDays, '天'],
];
if ($dosageBagCount !== null) {
$changes['主方袋数'] = [$oldDosageBagCount, $dosageBagCount, '袋'];
}
if ($hasAux) {
$changes['辅方每天次数'] = [$oldAuxTimes, $auxTimesPerDay, '次'];
$changes['辅方开立天数'] = [$oldAuxUsageDays, $auxUsageDays, '天'];
if ($auxDosageBagCount !== null) {
$changes['辅方袋数'] = [$oldAuxDosageBagCount, $auxDosageBagCount, '袋'];
}
}
$changes['订单服用天数'] = [$oldMedDays, $medDays, '天'];
$parts = [];
foreach ($changes as $label => [$oldValue, $newValue, $unit]) {
if ($oldValue !== $newValue) {
$parts[] = sprintf('%s %d%s → %d%s', $label, $oldValue, $unit, $newValue, $unit);
}
}
if ($parts === []) {
return true;
}
try {
$rxUpdates = [
'times_per_day' => $timesPerDay,
'usage_days' => $usageDays,
];
if ($dosageBagCount !== null) {
$rxUpdates['dosage_bag_count'] = $dosageBagCount;
}
if ($hasAux) {
$auxUsage = $oldAuxUsage;
$auxUsage['times_per_day'] = $auxTimesPerDay;
$auxUsage['usage_days'] = $auxUsageDays;
if ($auxDosageBagCount !== null) {
$auxUsage['dosage_bag_count'] = $auxDosageBagCount;
}
$rxUpdates['aux_usage'] = $auxUsage;
}
$rx->save($rxUpdates);
@@ -5897,31 +5943,8 @@ class PrescriptionOrderLogic
$order->medication_days = $medDays;
$order->save();
$parts = [
sprintf(
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
$oldTimes > 0 ? $oldTimes : 0,
$oldUsageDays > 0 ? $oldUsageDays : 0,
$timesPerDay,
$usageDays
),
];
if ($hasAux) {
$parts[] = sprintf(
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
$auxTimesPerDay,
$auxUsageDays
);
}
$parts[] = sprintf(
'订单设置 %d天 → %d天',
$oldMedDays > 0 ? $oldMedDays : 0,
$medDays
);
$summary = '服用参数:' . implode(';', $parts);
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary, true);
return true;
} catch (\Exception $e) {
@@ -5931,6 +5954,13 @@ class PrescriptionOrderLogic
}
}
private static function isPrescriptionUsageInteger($value, int $max): bool
{
return (is_int($value) || is_string($value))
&& preg_match('/^[1-9][0-9]*$/D', (string) $value) === 1
&& (int) $value <= $max;
}
/**
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
*/
@@ -108,9 +108,21 @@ class DiagnosisValidate extends BaseValidate
public function sceneEdit()
{
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source']);
}
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source'])
// 脱敏或被业务锁定的基本信息可省略,省略表示保留原值;显式提交仍需非空且格式有效。
->remove(['patient_name' => 'require', 'phone' => 'require', 'gender' => 'require', 'age' => 'require'])
->append(['patient_name' => 'requireWhenPresent', 'phone' => 'requireWhenPresent', 'gender' => 'requireWhenPresent', 'age' => 'requireWhenPresent']);
}
/** 编辑基本信息允许缺省,但不能用空值覆盖;require 前缀使 ThinkPHP 也校验 null/空串。 */
protected function requireWhenPresent($value, $rule, array $data = [], string $field = '')
{
if (!array_key_exists($field, $data) || $this->is($value, 'require')) {
return true;
}
return $this->message[$field . '.require'];
}
public function sceneId()
{
return $this->only(['id']);
@@ -100,7 +100,7 @@ class PrescriptionOrderValidate extends BaseValidate
'uploadToPharmacy' => ['id'],
'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'],
'updateAmount' => ['id', 'amount'],
'setShipMode' => ['id', 'ship_mode'],
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
@@ -133,14 +133,28 @@ class PrescriptionOrderValidate extends BaseValidate
->append('express_company', 'max:20');
}
public function patchPrescriptionUsage(): PrescriptionOrderValidate
public function scenePatchPrescriptionUsage(): PrescriptionOrderValidate
{
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
$this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'])
->append('id', 'require|integer|gt:0')
->append('times_per_day', 'require|integer|between:1,6')
->append('usage_days', 'require|integer|between:1,999')
->append('medication_days', 'require|integer|between:1,999')
->append('aux_times_per_day', 'integer|between:1,6')
->append('aux_usage_days', 'integer|between:1,999');
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
$this->append($field, [static function ($value, array $data) use ($field, $label) {
if (!array_key_exists($field, $data)) {
return true;
}
return (is_int($value) || is_string($value)) && preg_match('/^[1-5]$/D', (string) $value) === 1
? true
: $label . '袋数须为 1~5 的整数';
}]);
}
return $this;
}
}
@@ -950,7 +950,9 @@ class DifyChatService
bool $final = false
): void {
$buffer .= $chunk;
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
// Keep CRLF atomic: otherwise regex backtracking can count a single CRLF
// as two line breaks and decode a multi-line event before it is complete.
while (preg_match('/(?>\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
$delimiter = $match[0][0];
$offset = $match[0][1];
$frame = substr($buffer, 0, $offset);
@@ -50,6 +50,7 @@ final class FollowupAudioAccess
$row = Db::name('tcm_diagnosis')->where('id', $diagnosisId)->whereNull('delete_time')->lock(true)->find();
$info = self::actor($actor);
if (!$row || !self::allowed($actor, $info, 'tcm.diagnosis/edit')
|| !self::allowed($actor, $info, 'tcm.diagnosis/followupAudio')
|| ($daily && !self::allowed($actor, $info, 'tcm.diagnosis/dailyRecord'))) {
throw new DomainException('诊单不存在或无权操作');
}
@@ -73,6 +74,19 @@ final class FollowupAudioAccess
return $task;
}
/** One fresh authorization rule for both delete capability and the locked delete transaction. */
public static function deleteReason(array $task, int $actor): string
{
$info = self::actor($actor);
if (!self::allowed($actor, $info, 'tcm.followupAudio/delete')) {
return '缺少删除回访录音权限';
}
if ((int) $info['root'] !== 1 && (int) ($task['actor_id'] ?? 0) !== $actor) {
return '仅上传者或超级管理员可删除录音';
}
return '';
}
public static function canDaily(int $actor, array $info): bool
{
$info = self::actor($actor);
@@ -122,6 +122,9 @@ final class FollowupAudioApply
} else {
$data += ['diagnosis_id' => (int) $task['diagnosis_id'], 'patient_id' => (int) $task['patient_id'], 'create_time' => $now];
if ($kind === 'blood') { $data['source'] = 0; }
// Audio omissions mean unknown, not the legacy exercise defaults (0 minutes / low).
// Insert-only: omitted fields on an update must retain their existing values.
if ($kind === 'exercise') { $data += ['duration' => null, 'intensity' => null]; }
$targetId = (int) Db::name($table)->insertGetId($data);
$action = 'insert';
}

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