更新
@@ -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>
|
||||||
|
}
|
||||||
@@ -490,8 +490,10 @@ export function prescriptionOrderPatchPrescriptionUsage(params: {
|
|||||||
times_per_day: number
|
times_per_day: number
|
||||||
usage_days: number
|
usage_days: number
|
||||||
medication_days: number
|
medication_days: number
|
||||||
|
dosage_bag_count?: number
|
||||||
aux_times_per_day?: number
|
aux_times_per_day?: number
|
||||||
aux_usage_days?: number
|
aux_usage_days?: number
|
||||||
|
aux_dosage_bag_count?: number
|
||||||
}) {
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,17 @@
|
|||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
· {{ 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>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -325,6 +336,17 @@
|
|||||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
· {{ 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>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -943,6 +965,15 @@
|
|||||||
label-width="108px"
|
label-width="108px"
|
||||||
>
|
>
|
||||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
<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-form-item label="每天次数" prop="times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.times_per_day"
|
v-model="patchUsageForm.times_per_day"
|
||||||
@@ -968,6 +999,15 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="detailHasAuxHerbs">
|
<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>
|
<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-form-item label="每天次数" prop="aux_times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.aux_times_per_day"
|
v-model="patchUsageForm.aux_times_per_day"
|
||||||
@@ -1432,10 +1472,12 @@ const patchUsageVisible = ref(false)
|
|||||||
const patchUsageSaving = ref(false)
|
const patchUsageSaving = ref(false)
|
||||||
const patchUsageFormRef = ref<FormInstance>()
|
const patchUsageFormRef = ref<FormInstance>()
|
||||||
const patchUsageForm = reactive({
|
const patchUsageForm = reactive({
|
||||||
|
dosage_bag_count: 1 as number | undefined,
|
||||||
times_per_day: 3 as number | undefined,
|
times_per_day: 3 as number | undefined,
|
||||||
usage_days: 7 as number | undefined,
|
usage_days: 7 as number | undefined,
|
||||||
aux_times_per_day: 3 as number | undefined,
|
aux_times_per_day: 3 as number | undefined,
|
||||||
aux_usage_days: 7 as number | undefined,
|
aux_usage_days: 7 as number | undefined,
|
||||||
|
aux_dosage_bag_count: 1 as number | undefined,
|
||||||
medication_days: undefined as number | undefined
|
medication_days: undefined as number | undefined
|
||||||
})
|
})
|
||||||
const patchUsageRules = computed<FormRules>(() => {
|
const patchUsageRules = computed<FormRules>(() => {
|
||||||
@@ -1444,9 +1486,15 @@ const patchUsageRules = computed<FormRules>(() => {
|
|||||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||||
medication_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) {
|
if (detailHasAuxHerbs.value) {
|
||||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||||
rules.aux_usage_days = [{ 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
|
return rules
|
||||||
})
|
})
|
||||||
@@ -1459,6 +1507,7 @@ function openPatchUsageDialog() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const aux = detailAuxUsage.value
|
const aux = detailAuxUsage.value
|
||||||
|
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||||
patchUsageForm.times_per_day =
|
patchUsageForm.times_per_day =
|
||||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||||
patchUsageForm.usage_days =
|
patchUsageForm.usage_days =
|
||||||
@@ -1468,16 +1517,20 @@ function openPatchUsageDialog() {
|
|||||||
patchUsageForm.aux_usage_days =
|
patchUsageForm.aux_usage_days =
|
||||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||||
const md = Number(ord.medication_days)
|
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
|
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||||
patchUsageVisible.value = true
|
patchUsageVisible.value = true
|
||||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPatchUsageForm() {
|
function resetPatchUsageForm() {
|
||||||
|
patchUsageForm.dosage_bag_count = 1
|
||||||
patchUsageForm.times_per_day = 3
|
patchUsageForm.times_per_day = 3
|
||||||
patchUsageForm.usage_days = 7
|
patchUsageForm.usage_days = 7
|
||||||
patchUsageForm.aux_times_per_day = 3
|
patchUsageForm.aux_times_per_day = 3
|
||||||
patchUsageForm.aux_usage_days = 7
|
patchUsageForm.aux_usage_days = 7
|
||||||
|
patchUsageForm.aux_dosage_bag_count = 1
|
||||||
patchUsageForm.medication_days = undefined
|
patchUsageForm.medication_days = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1498,17 +1551,25 @@ async function submitPatchUsage() {
|
|||||||
times_per_day: number
|
times_per_day: number
|
||||||
usage_days: number
|
usage_days: number
|
||||||
medication_days: number
|
medication_days: number
|
||||||
|
dosage_bag_count?: number
|
||||||
aux_times_per_day?: number
|
aux_times_per_day?: number
|
||||||
aux_usage_days?: number
|
aux_usage_days?: number
|
||||||
|
aux_dosage_bag_count?: number
|
||||||
} = {
|
} = {
|
||||||
id: ordId,
|
id: ordId,
|
||||||
times_per_day: Number(patchUsageForm.times_per_day),
|
times_per_day: Number(patchUsageForm.times_per_day),
|
||||||
usage_days: Number(patchUsageForm.usage_days),
|
usage_days: Number(patchUsageForm.usage_days),
|
||||||
medication_days: Number(patchUsageForm.medication_days)
|
medication_days: Number(patchUsageForm.medication_days)
|
||||||
}
|
}
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||||
|
}
|
||||||
if (detailHasAuxHerbs.value) {
|
if (detailHasAuxHerbs.value) {
|
||||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
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)
|
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||||
feedback.msgSuccess('保存成功')
|
feedback.msgSuccess('保存成功')
|
||||||
@@ -1763,7 +1824,7 @@ async function refresh() {
|
|||||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||||
void fetchLogisticsTrace()
|
void fetchLogisticsTrace()
|
||||||
}
|
}
|
||||||
if (props.loadRelatedData) void fetchLogs(id)
|
if (props.loadRelatedData) await fetchLogs(id)
|
||||||
const diagId = d.diagnosis_id
|
const diagId = d.diagnosis_id
|
||||||
if (props.loadRelatedData && diagId) {
|
if (props.loadRelatedData && diagId) {
|
||||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||||
|
|||||||
@@ -883,6 +883,17 @@
|
|||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
· {{ 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>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -896,6 +907,17 @@
|
|||||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
· {{ 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>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -2261,6 +2283,15 @@
|
|||||||
label-width="96px"
|
label-width="96px"
|
||||||
>
|
>
|
||||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
<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-form-item label="每天次数" prop="times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.times_per_day"
|
v-model="patchUsageForm.times_per_day"
|
||||||
@@ -2286,6 +2317,15 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="detailHasAuxHerbs">
|
<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>
|
<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-form-item label="每天次数" prop="aux_times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.aux_times_per_day"
|
v-model="patchUsageForm.aux_times_per_day"
|
||||||
@@ -3954,10 +3994,12 @@ const patchUsageVisible = ref(false)
|
|||||||
const patchUsageSaving = ref(false)
|
const patchUsageSaving = ref(false)
|
||||||
const patchUsageFormRef = ref<FormInstance>()
|
const patchUsageFormRef = ref<FormInstance>()
|
||||||
const patchUsageForm = reactive({
|
const patchUsageForm = reactive({
|
||||||
|
dosage_bag_count: 1 as number | undefined,
|
||||||
times_per_day: 3 as number | undefined,
|
times_per_day: 3 as number | undefined,
|
||||||
usage_days: 7 as number | undefined,
|
usage_days: 7 as number | undefined,
|
||||||
aux_times_per_day: 3 as number | undefined,
|
aux_times_per_day: 3 as number | undefined,
|
||||||
aux_usage_days: 7 as number | undefined,
|
aux_usage_days: 7 as number | undefined,
|
||||||
|
aux_dosage_bag_count: 1 as number | undefined,
|
||||||
medication_days: undefined as number | undefined
|
medication_days: undefined as number | undefined
|
||||||
})
|
})
|
||||||
const patchUsageRules = computed<FormRules>(() => {
|
const patchUsageRules = computed<FormRules>(() => {
|
||||||
@@ -3966,9 +4008,15 @@ const patchUsageRules = computed<FormRules>(() => {
|
|||||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||||
medication_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) {
|
if (detailHasAuxHerbs.value) {
|
||||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||||
rules.aux_usage_days = [{ 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
|
return rules
|
||||||
})
|
})
|
||||||
@@ -3981,6 +4029,7 @@ function openPatchUsageDialog() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const aux = detailAuxUsage.value
|
const aux = detailAuxUsage.value
|
||||||
|
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||||
patchUsageForm.times_per_day =
|
patchUsageForm.times_per_day =
|
||||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||||
patchUsageForm.usage_days =
|
patchUsageForm.usage_days =
|
||||||
@@ -3990,16 +4039,20 @@ function openPatchUsageDialog() {
|
|||||||
patchUsageForm.aux_usage_days =
|
patchUsageForm.aux_usage_days =
|
||||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||||
const md = Number(ord.medication_days)
|
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
|
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||||
patchUsageVisible.value = true
|
patchUsageVisible.value = true
|
||||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPatchUsageForm() {
|
function resetPatchUsageForm() {
|
||||||
|
patchUsageForm.dosage_bag_count = 1
|
||||||
patchUsageForm.times_per_day = 3
|
patchUsageForm.times_per_day = 3
|
||||||
patchUsageForm.usage_days = 7
|
patchUsageForm.usage_days = 7
|
||||||
patchUsageForm.aux_times_per_day = 3
|
patchUsageForm.aux_times_per_day = 3
|
||||||
patchUsageForm.aux_usage_days = 7
|
patchUsageForm.aux_usage_days = 7
|
||||||
|
patchUsageForm.aux_dosage_bag_count = 1
|
||||||
patchUsageForm.medication_days = undefined
|
patchUsageForm.medication_days = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4020,17 +4073,25 @@ async function submitPatchUsage() {
|
|||||||
times_per_day: number
|
times_per_day: number
|
||||||
usage_days: number
|
usage_days: number
|
||||||
medication_days: number
|
medication_days: number
|
||||||
|
dosage_bag_count?: number
|
||||||
aux_times_per_day?: number
|
aux_times_per_day?: number
|
||||||
aux_usage_days?: number
|
aux_usage_days?: number
|
||||||
|
aux_dosage_bag_count?: number
|
||||||
} = {
|
} = {
|
||||||
id: ordId,
|
id: ordId,
|
||||||
times_per_day: Number(patchUsageForm.times_per_day),
|
times_per_day: Number(patchUsageForm.times_per_day),
|
||||||
usage_days: Number(patchUsageForm.usage_days),
|
usage_days: Number(patchUsageForm.usage_days),
|
||||||
medication_days: Number(patchUsageForm.medication_days)
|
medication_days: Number(patchUsageForm.medication_days)
|
||||||
}
|
}
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||||
|
}
|
||||||
if (detailHasAuxHerbs.value) {
|
if (detailHasAuxHerbs.value) {
|
||||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
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)
|
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||||
feedback.msgSuccess('保存成功')
|
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,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, [])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
base: '/admin/',
|
base: '/admin/',
|
||||||
|
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
|
||||||
|
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
|
||||||
server: {
|
server: {
|
||||||
port: 5555,
|
port: 5555,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def example_batch() -> dict[str, Any]:
|
|||||||
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
"prompt_version": "manual-prescription-required-candidate-v4",
|
"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",
|
"prescription_revision": 1, "status": "success", "validity": "current",
|
||||||
"comparison_type": "non_independent", "models": models, "coverage_status": "partial",
|
"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",
|
"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:
|
def __init__(self) -> None:
|
||||||
self.batch = example_batch()
|
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]:
|
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]]:
|
def history(self) -> list[dict[str, Any]]:
|
||||||
"""The current batch plus the earlier ones it supersedes, newest first."""
|
"""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"},
|
"algorithm_version": "prescription-soft-dice-v1.0.0"},
|
||||||
"openai": {"comparison": {"status": "not_comparable"}}}},
|
"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]:
|
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||||
return deepcopy(self.batch)
|
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:
|
def get_prescription(self, prescription_id: int) -> Prescription:
|
||||||
"""Return one issued 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]:
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
"""Read cached dual-model states for up to 100 prescriptions."""
|
"""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)
|
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
|
||||||
return dict(_require_mapping(payload, endpoint))
|
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]:
|
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):
|
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
|
||||||
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
from html import escape
|
from html import escape
|
||||||
from math import isfinite
|
from math import isfinite
|
||||||
from time import monotonic
|
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)
|
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:
|
def _html(value: Any, field: str = "") -> str:
|
||||||
"""Escape all stored/model content, including source URLs, before rendering."""
|
"""Escape all stored/model content, including source URLs, before rendering."""
|
||||||
if isinstance(value, Mapping):
|
if isinstance(value, Mapping):
|
||||||
@@ -536,6 +559,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
self._progress_frozen_seconds = 0
|
self._progress_frozen_seconds = 0
|
||||||
self._progress_stale = False
|
self._progress_stale = False
|
||||||
self._read_access_denied = False
|
self._read_access_denied = False
|
||||||
|
self._agreement_visible = False
|
||||||
|
self._history_agreement_visible = False
|
||||||
self.setWindowTitle("诊断与药方 · AI 分析报告")
|
self.setWindowTitle("诊断与药方 · AI 分析报告")
|
||||||
self.resize(1440, 940)
|
self.resize(1440, 940)
|
||||||
self.setMinimumSize(940, 640)
|
self.setMinimumSize(940, 640)
|
||||||
@@ -575,7 +600,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
identity_block.addWidget(self.breadcrumb)
|
identity_block.addWidget(self.breadcrumb)
|
||||||
title_row = QHBoxLayout()
|
title_row = QHBoxLayout()
|
||||||
title_row.setSpacing(10)
|
title_row.setSpacing(10)
|
||||||
heading = QLabel("诊断与药方对照", top)
|
heading = self.heading = QLabel("诊断与药方对照", top)
|
||||||
heading.setObjectName("AiHeroTitle")
|
heading.setObjectName("AiHeroTitle")
|
||||||
title_row.addWidget(heading)
|
title_row.addWidget(heading)
|
||||||
self.chip_row = QHBoxLayout()
|
self.chip_row = QHBoxLayout()
|
||||||
@@ -903,7 +928,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
footer_text = QVBoxLayout()
|
footer_text = QVBoxLayout()
|
||||||
footer_text.setSpacing(4)
|
footer_text.setSpacing(4)
|
||||||
footer_text.addWidget(self.message)
|
footer_text.addWidget(self.message)
|
||||||
note = QLabel(DISCLAIMER)
|
note = self.disclaimer = QLabel(DISCLAIMER)
|
||||||
note.setObjectName("AiFootnote")
|
note.setObjectName("AiFootnote")
|
||||||
note.setWordWrap(True)
|
note.setWordWrap(True)
|
||||||
footer_text.addWidget(note)
|
footer_text.addWidget(note)
|
||||||
@@ -921,6 +946,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
self._progress_timer.timeout.connect(self._render_progress)
|
self._progress_timer.timeout.connect(self._render_progress)
|
||||||
self._sync_actions()
|
self._sync_actions()
|
||||||
self._navigation_changed(self.tabs.currentIndex())
|
self._navigation_changed(self.tabs.currentIndex())
|
||||||
|
self._apply_agreement_visibility(self._agreement_visible)
|
||||||
|
|
||||||
REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
|
REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
|
||||||
("differences", "只看不同段落"))
|
("differences", "只看不同段落"))
|
||||||
@@ -1063,6 +1089,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
return host
|
return host
|
||||||
|
|
||||||
def _goto(self, key: str) -> None:
|
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)
|
page = self.tab_pages.get(key)
|
||||||
if page is not None:
|
if page is not None:
|
||||||
self.tabs.setCurrentWidget(page)
|
self.tabs.setCurrentWidget(page)
|
||||||
@@ -1070,7 +1098,10 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
def _navigation_changed(self, index: int) -> None:
|
def _navigation_changed(self, index: int) -> None:
|
||||||
widget = self.tabs.widget(index)
|
widget = self.tabs.widget(index)
|
||||||
current = next((key for key, page in self.tab_pages.items() if page is widget), "")
|
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._current_page = current
|
||||||
self._apply_density()
|
self._apply_density()
|
||||||
|
|
||||||
@@ -1101,6 +1132,36 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
if field in MODELS:
|
if field in MODELS:
|
||||||
self.model_views[field]["report"].setFocus()
|
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
|
@property
|
||||||
def save_review_button(self) -> QPushButton:
|
def save_review_button(self) -> QPushButton:
|
||||||
"""The review card's own save action for the model currently being annotated."""
|
"""The review card's own save action for the model currently being annotated."""
|
||||||
@@ -1206,9 +1267,12 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
return
|
return
|
||||||
self._read_access_denied = False
|
self._read_access_denied = False
|
||||||
data = mapping(result)
|
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
|
self._feature_enabled = data.get("enabled", True) is not False
|
||||||
rows = data.get("lists") or []
|
rows = [agreement_safe_batch(row, self._history_agreement_visible and mapping(row).get("agreement_visible") is True)
|
||||||
self.history_page.set_history([mapping(row) for row in rows])
|
for row in data.get("lists") or []]
|
||||||
|
self.history_page.set_history(rows if self._agreement_visible else [])
|
||||||
self.history.blockSignals(True)
|
self.history.blockSignals(True)
|
||||||
self.history.clear()
|
self.history.clear()
|
||||||
self._history_scope = {}
|
self._history_scope = {}
|
||||||
@@ -1321,6 +1385,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
|
|
||||||
def _read_failed(self, error: Exception, prefix: str) -> None:
|
def _read_failed(self, error: Exception, prefix: str) -> None:
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
|
self._history_agreement_visible = False
|
||||||
|
self._apply_agreement_visibility(False)
|
||||||
text = str(error).lower()
|
text = str(error).lower()
|
||||||
restricted = isinstance(error, (ValueError, AuthenticationExpiredError)) or getattr(error, "status_code", None) in {401, 403, 404} or any(
|
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", "权限", "无权", "登录", "归属", "不存在")
|
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):
|
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)
|
self._detail_error(ValueError("报告归属与当前处方或诊单不一致"), generation)
|
||||||
return
|
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
|
first_load = not self._batch
|
||||||
self._batch = batch
|
self._batch = batch
|
||||||
self._read_access_denied = False
|
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.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}")
|
||||||
self._set_chips([
|
self._set_chips([
|
||||||
(label(batch.get("validity")), "info" if current_batch(batch) else "warn"),
|
(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.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)
|
self._render_facts(batch)
|
||||||
@@ -1411,6 +1481,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
"risk_assessment": report.get("risk_assessment"),
|
"risk_assessment": report.get("risk_assessment"),
|
||||||
}
|
}
|
||||||
_set_html(views["candidate"], _candidate_html(candidate_content))
|
_set_html(views["candidate"], _candidate_html(candidate_content))
|
||||||
|
comparison_html = ""
|
||||||
|
if self._agreement_visible:
|
||||||
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
|
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 += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
||||||
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
||||||
@@ -1428,7 +1500,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
views["comment"].setPlainText(str(review.get("comment") or ""))
|
views["comment"].setPlainText(str(review.get("comment") or ""))
|
||||||
index = views["review_state"].findData(review.get("status"))
|
index = views["review_state"].findData(review.get("status"))
|
||||||
views["review_state"].setCurrentIndex(max(0, index))
|
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.sources_page.set_batch(batch)
|
||||||
self.pipeline_page.set_batch(batch)
|
self.pipeline_page.set_batch(batch)
|
||||||
self._sync_failures(batch)
|
self._sync_failures(batch)
|
||||||
@@ -1488,6 +1560,12 @@ class IssuedPrescriptionAiDialog(QDialog):
|
|||||||
"restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
|
"restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
|
||||||
"consensus": panel.consensus_count() if hasattr(panel, "consensus_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:
|
def _render_facts(self, batch: Any) -> None:
|
||||||
snapshot = mapping(mapping(batch).get("doctor_snapshot"))
|
snapshot = mapping(mapping(batch).get("doctor_snapshot"))
|
||||||
@@ -1707,6 +1785,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.repository, self.permissions = repository, permissions
|
self.repository, self.permissions = repository, permissions
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
|
self._agreement_visible = False
|
||||||
self.setWindowTitle("医生处方 AI 一致度统计")
|
self.setWindowTitle("医生处方 AI 一致度统计")
|
||||||
self.setStyleSheet(dialog_style() + rail_qss())
|
self.setStyleSheet(dialog_style() + rail_qss())
|
||||||
self.resize(1180, 860)
|
self.resize(1180, 860)
|
||||||
@@ -1742,6 +1821,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
|||||||
self.report.document().setDefaultStyleSheet(_report_css())
|
self.report.document().setDefaultStyleSheet(_report_css())
|
||||||
self.report.setMinimumHeight(130)
|
self.report.setMinimumHeight(130)
|
||||||
root.addWidget(self.report, 1)
|
root.addWidget(self.report, 1)
|
||||||
|
self.panel.hide()
|
||||||
|
|
||||||
def showEvent(self, event: Any) -> None:
|
def showEvent(self, event: Any) -> None:
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
@@ -1750,9 +1830,19 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
|||||||
|
|
||||||
def hideEvent(self, event: Any) -> None:
|
def hideEvent(self, event: Any) -> None:
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
|
self._clear_statistics()
|
||||||
super().hideEvent(event)
|
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:
|
def refresh(self) -> None:
|
||||||
|
self._generation += 1
|
||||||
|
generation = self._generation
|
||||||
|
self._clear_statistics()
|
||||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||||
self.summary.setText("没有查看医生统计的权限。")
|
self.summary.setText("没有查看医生统计的权限。")
|
||||||
return
|
return
|
||||||
@@ -1763,20 +1853,33 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
|||||||
if doctor and (not doctor.isdecimal() or int(doctor) <= 0):
|
if doctor and (not doctor.isdecimal() or int(doctor) <= 0):
|
||||||
self.summary.setText("请填写有效的医生编号。")
|
self.summary.setText("请填写有效的医生编号。")
|
||||||
return
|
return
|
||||||
self._generation += 1
|
self.summary.setText("正在核验一致度查看权限…")
|
||||||
generation = self._generation
|
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
||||||
self.summary.setText("正在读取统计…")
|
if not callable(method):
|
||||||
self.report.clear()
|
self.summary.setText("当前账号无权查看一致度统计。")
|
||||||
|
return
|
||||||
date_from, date_to = self.date_from.date().toString("yyyy-MM-dd"), self.date_to.date().toString("yyyy-MM-dd")
|
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:
|
def _error(self, error: Exception, generation: int) -> None:
|
||||||
if generation == self._generation:
|
if generation == self._generation:
|
||||||
self.summary.setText("统计暂不可用:" + _error_text(error))
|
self.summary.setText("统计暂不可用:" + _error_text(error))
|
||||||
self.report.clear()
|
self._clear_statistics()
|
||||||
|
|
||||||
def _ready(self, result: Any, generation: int) -> None:
|
def _ready(self, result: Any, generation: int) -> None:
|
||||||
if generation != self._generation:
|
if generation != self._generation or not self._agreement_visible:
|
||||||
return
|
return
|
||||||
data = mapping(result)
|
data = mapping(result)
|
||||||
if data.get("enabled") is False:
|
if data.get("enabled") is False:
|
||||||
@@ -1786,6 +1889,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
|||||||
return
|
return
|
||||||
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
|
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
|
||||||
self.panel.set_statistics(data)
|
self.panel.set_statistics(data)
|
||||||
|
self.panel.show()
|
||||||
sections = []
|
sections = []
|
||||||
for row in data.get("doctors") or []:
|
for row in data.get("doctors") or []:
|
||||||
doctor = mapping(row)
|
doctor = mapping(row)
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ class AgreementBar(QFrame):
|
|||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
layout.setContentsMargins(22, 16, 22, 16)
|
layout.setContentsMargins(22, 16, 22, 16)
|
||||||
layout.setSpacing(24)
|
layout.setSpacing(24)
|
||||||
|
self._agreement_visible = True
|
||||||
|
self._metric_chrome: list[QWidget] = []
|
||||||
self.blocks: dict[str, dict[str, Any]] = {}
|
self.blocks: dict[str, dict[str, Any]] = {}
|
||||||
for index, key in enumerate(("qwen", "openai")):
|
for index, key in enumerate(("qwen", "openai")):
|
||||||
if index:
|
if index:
|
||||||
@@ -197,11 +199,14 @@ class AgreementBar(QFrame):
|
|||||||
divider.setObjectName("AiFactDivider")
|
divider.setObjectName("AiFactDivider")
|
||||||
divider.setFixedWidth(1)
|
divider.setFixedWidth(1)
|
||||||
layout.addWidget(divider)
|
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 = QFrame(self)
|
||||||
divider.setObjectName("AiFactDivider")
|
divider.setObjectName("AiFactDivider")
|
||||||
divider.setFixedWidth(1)
|
divider.setFixedWidth(1)
|
||||||
layout.addWidget(divider)
|
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.
|
# 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)
|
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
@@ -294,7 +299,7 @@ class AgreementBar(QFrame):
|
|||||||
carrier.setFixedHeight(4)
|
carrier.setFixedHeight(4)
|
||||||
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||||
carrier.setVisible(False)
|
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,
|
"status": status, "failure": failure, "failure_text": failure_text,
|
||||||
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
|
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
|
||||||
return holder
|
return holder
|
||||||
@@ -313,6 +318,30 @@ class AgreementBar(QFrame):
|
|||||||
|
|
||||||
self.blocks[key]["retry_slot"].addWidget(button)
|
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:
|
def set_failure(self, key: str, message: str, retryable: bool) -> None:
|
||||||
block = self.blocks[key]
|
block = self.blocks[key]
|
||||||
block["failure_text"].setText(message)
|
block["failure_text"].setText(message)
|
||||||
@@ -351,7 +380,7 @@ class AgreementBar(QFrame):
|
|||||||
elapsed: Mapping[str, str] | None = None) -> None:
|
elapsed: Mapping[str, str] | None = None) -> None:
|
||||||
data = _mapping(batch)
|
data = _mapping(batch)
|
||||||
models = _mapping(data.get("models"))
|
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"):
|
for key in ("qwen", "openai"):
|
||||||
block = self.blocks[key]
|
block = self.blocks[key]
|
||||||
value = scores[key]
|
value = scores[key]
|
||||||
@@ -364,6 +393,9 @@ class AgreementBar(QFrame):
|
|||||||
spent = _mapping(elapsed).get(key, "")
|
spent = _mapping(elapsed).get(key, "")
|
||||||
block["elapsed"].setText(spent)
|
block["elapsed"].setText(spent)
|
||||||
block["elapsed"].setVisible(bool(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:
|
if scores["qwen"] is None or scores["openai"] is None:
|
||||||
self.delta.setText("—")
|
self.delta.setText("—")
|
||||||
self.delta_note.setText("两个模型都可比后才给差值")
|
self.delta_note.setText("两个模型都可比后才给差值")
|
||||||
|
|||||||
@@ -537,6 +537,9 @@ class DoseDiffPanel(QFrame):
|
|||||||
item = self.body_layout.takeAt(0)
|
item = self.body_layout.takeAt(0)
|
||||||
widget = item.widget()
|
widget = item.widget()
|
||||||
if widget is not None:
|
if widget is not None:
|
||||||
|
widget.hide()
|
||||||
|
for label in widget.findChildren(QLabel):
|
||||||
|
label.clear()
|
||||||
widget.setParent(None)
|
widget.setParent(None)
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
extent = _axis_extent(deltas)
|
extent = _axis_extent(deltas)
|
||||||
@@ -620,6 +623,9 @@ class ConclusionsPanel(QFrame):
|
|||||||
entry = self.body_layout.takeAt(0)
|
entry = self.body_layout.takeAt(0)
|
||||||
widget = entry.widget()
|
widget = entry.widget()
|
||||||
if widget is not None:
|
if widget is not None:
|
||||||
|
widget.hide()
|
||||||
|
for label in widget.findChildren(QLabel):
|
||||||
|
label.clear()
|
||||||
widget.setParent(None)
|
widget.setParent(None)
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
for index, text in enumerate(items, start=1):
|
for index, text in enumerate(items, start=1):
|
||||||
@@ -844,6 +850,9 @@ class RiskPanel(QFrame):
|
|||||||
entry = self.body_layout.takeAt(0)
|
entry = self.body_layout.takeAt(0)
|
||||||
widget = entry.widget()
|
widget = entry.widget()
|
||||||
if widget is not None:
|
if widget is not None:
|
||||||
|
widget.hide()
|
||||||
|
for label in widget.findChildren(QLabel):
|
||||||
|
label.clear()
|
||||||
widget.setParent(None)
|
widget.setParent(None)
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
flagged = []
|
flagged = []
|
||||||
@@ -1015,6 +1024,9 @@ class ChecklistPanel(QFrame):
|
|||||||
entry = self.body_layout.takeAt(0)
|
entry = self.body_layout.takeAt(0)
|
||||||
widget = entry.widget()
|
widget = entry.widget()
|
||||||
if widget is not None:
|
if widget is not None:
|
||||||
|
widget.hide()
|
||||||
|
for label in widget.findChildren(QLabel):
|
||||||
|
label.clear()
|
||||||
widget.setParent(None)
|
widget.setParent(None)
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
grouped: dict[str, tuple[str, str, int, str]] = {}
|
grouped: dict[str, tuple[str, str, int, str]] = {}
|
||||||
@@ -1132,6 +1144,7 @@ class PrescriptionReviewWorkspace(QWidget):
|
|||||||
self._batch: dict[str, Any] = {}
|
self._batch: dict[str, Any] = {}
|
||||||
self.rows: list[ReviewDoseRow] = []
|
self.rows: list[ReviewDoseRow] = []
|
||||||
self._review_widget: QWidget | None = None
|
self._review_widget: QWidget | None = None
|
||||||
|
self._agreement_visible = True
|
||||||
self._summary = "选择报告后查看诊断与药方"
|
self._summary = "选择报告后查看诊断与药方"
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(f"""
|
||||||
QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
|
QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
|
||||||
@@ -1150,7 +1163,7 @@ class PrescriptionReviewWorkspace(QWidget):
|
|||||||
page = QWidget(self.page_scroll)
|
page = QWidget(self.page_scroll)
|
||||||
self.page_scroll.setWidget(page)
|
self.page_scroll.setWidget(page)
|
||||||
outer.addWidget(self.page_scroll)
|
outer.addWidget(self.page_scroll)
|
||||||
root = QVBoxLayout(page)
|
root = self._workspace_layout = QVBoxLayout(page)
|
||||||
root.setContentsMargins(0, 0, 6, 0)
|
root.setContentsMargins(0, 0, 6, 0)
|
||||||
root.setSpacing(14)
|
root.setSpacing(14)
|
||||||
|
|
||||||
@@ -1201,6 +1214,10 @@ class PrescriptionReviewWorkspace(QWidget):
|
|||||||
layout.takeAt(0)
|
layout.takeAt(0)
|
||||||
for index in range(layout.columnCount()):
|
for index in range(layout.columnCount()):
|
||||||
layout.setColumnStretch(index, 0)
|
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)
|
cards = (self.conclusions, self.attribution, self.checklist)
|
||||||
if columns == 1:
|
if columns == 1:
|
||||||
for row, card in enumerate(cards):
|
for row, card in enumerate(cards):
|
||||||
@@ -1289,7 +1306,31 @@ class PrescriptionReviewWorkspace(QWidget):
|
|||||||
self._batch = deepcopy(data)
|
self._batch = deepcopy(data)
|
||||||
self._render()
|
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:
|
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)
|
self.rows, _states = review_rows(self._batch)
|
||||||
deltas = dose_deltas(self.rows)
|
deltas = dose_deltas(self.rows)
|
||||||
self.doses.set_rows(deltas)
|
self.doses.set_rows(deltas)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from PySide6.QtWidgets import (
|
|||||||
from .. import icons, motion
|
from .. import icons, motion
|
||||||
from ..dialogs.issued_prescription_ai import (
|
from ..dialogs.issued_prescription_ai import (
|
||||||
PrescriptionAiStatisticsDialog,
|
PrescriptionAiStatisticsDialog,
|
||||||
|
agreement_safe_batch,
|
||||||
agreement_text,
|
agreement_text,
|
||||||
batch_running,
|
batch_running,
|
||||||
can_open_issued_ai,
|
can_open_issued_ai,
|
||||||
@@ -1007,6 +1008,10 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._mutation_pending = False
|
self._mutation_pending = False
|
||||||
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
||||||
self._ai_enabled = False
|
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_request_token = 0
|
||||||
self._ai_pending = False
|
self._ai_pending = False
|
||||||
self._ai_timer = QTimer(self)
|
self._ai_timer = QTimer(self)
|
||||||
@@ -1048,7 +1053,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.orders_button.clicked.connect(lambda: self._open_orders())
|
self.orders_button.clicked.connect(lambda: self._open_orders())
|
||||||
header.add_action(self.orders_button)
|
header.add_action(self.orders_button)
|
||||||
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
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)
|
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
|
||||||
header.add_action(self.ai_statistics_button)
|
header.add_action(self.ai_statistics_button)
|
||||||
self.add_button = QPushButton("新增处方", header)
|
self.add_button = QPushButton("新增处方", header)
|
||||||
@@ -1437,6 +1442,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._refresh_pending = False
|
self._refresh_pending = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
|
self._load_ai_capabilities()
|
||||||
page_size = self._page_size
|
page_size = self._page_size
|
||||||
start_time, end_time = self._date_filters()
|
start_time, end_time = self._date_filters()
|
||||||
filters: dict[str, Any] = {
|
filters: dict[str, Any] = {
|
||||||
@@ -1656,6 +1662,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
return
|
return
|
||||||
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
||||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
||||||
|
self._set_ai_columns(False)
|
||||||
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
||||||
return
|
return
|
||||||
if not callable(method):
|
if not callable(method):
|
||||||
@@ -1687,8 +1694,8 @@ class PrescriptionsPage(QWidget):
|
|||||||
def _set_ai_columns(self, enabled: bool) -> None:
|
def _set_ai_columns(self, enabled: bool) -> None:
|
||||||
changed = enabled != self._ai_enabled
|
changed = enabled != self._ai_enabled
|
||||||
self._ai_enabled = enabled
|
self._ai_enabled = enabled
|
||||||
for column in (11, 12):
|
self.table.setColumnHidden(11, not enabled)
|
||||||
self.table.setColumnHidden(column, not enabled)
|
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
|
||||||
if enabled:
|
if enabled:
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
||||||
@@ -1699,10 +1706,54 @@ class PrescriptionsPage(QWidget):
|
|||||||
if changed:
|
if changed:
|
||||||
self._decorate_rows()
|
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:
|
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():
|
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
||||||
return
|
return
|
||||||
data = _row_mapping(result)
|
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)
|
self._set_ai_columns(data.get("enabled") is True)
|
||||||
if not self._ai_enabled:
|
if not self._ai_enabled:
|
||||||
self._ai_timer.stop()
|
self._ai_timer.stop()
|
||||||
@@ -1713,15 +1764,17 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.ai_status_notice.setToolTip("")
|
self.ai_status_notice.setToolTip("")
|
||||||
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
||||||
return
|
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_status_notice.setToolTip("")
|
||||||
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
|
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
|
||||||
|
+ ("可查看逐味对照。" if self._agreement_visible else ""))
|
||||||
for value in ids:
|
for value in ids:
|
||||||
self._ai_statuses[value] = {}
|
self._ai_statuses[value] = {}
|
||||||
for batch in data.get("items") or []:
|
for batch in data.get("items") or []:
|
||||||
value = _int(get_value(batch, "prescription_id"), 0)
|
value = _int(get_value(batch, "prescription_id"), 0)
|
||||||
if value in ids:
|
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()
|
self._render_ai_statuses()
|
||||||
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
||||||
self._ai_timer.start()
|
self._ai_timer.start()
|
||||||
@@ -1741,13 +1794,13 @@ class PrescriptionsPage(QWidget):
|
|||||||
if batch is None and not blank:
|
if batch is None and not blank:
|
||||||
continue
|
continue
|
||||||
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
|
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)):
|
for column, text in ((11, state), (12, agreement)):
|
||||||
item = self.table.item(index, column)
|
item = self.table.item(index, column)
|
||||||
if item.text() != text:
|
if item.text() != text:
|
||||||
item.setText(text)
|
item.setText(text)
|
||||||
changed = True
|
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)
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||||
if changed:
|
if changed:
|
||||||
self.table.resizeRowsToContents()
|
self.table.resizeRowsToContents()
|
||||||
@@ -1757,6 +1810,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
||||||
if token == self._ai_request_token and generation == self._generation:
|
if token == self._ai_request_token and generation == self._generation:
|
||||||
self._ai_timer.stop()
|
self._ai_timer.stop()
|
||||||
|
self._set_agreement_visible(False)
|
||||||
self._set_ai_columns(False)
|
self._set_ai_columns(False)
|
||||||
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
||||||
self.ai_status_notice.setToolTip(friendly_error(error))
|
self.ai_status_notice.setToolTip(friendly_error(error))
|
||||||
@@ -1781,13 +1835,13 @@ class PrescriptionsPage(QWidget):
|
|||||||
if _is_blank_prescription(row):
|
if _is_blank_prescription(row):
|
||||||
return
|
return
|
||||||
menu = QMenu(self.table)
|
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))
|
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
||||||
self._ai_context_menu = menu
|
self._ai_context_menu = menu
|
||||||
menu.popup(self.table.viewport().mapToGlobal(position))
|
menu.popup(self.table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
def _open_ai_statistics(self) -> None:
|
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
|
return
|
||||||
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
||||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||||
@@ -2207,12 +2261,14 @@ class PrescriptionsPage(QWidget):
|
|||||||
if self.table.rowCount() == 0 and not self._loading:
|
if self.table.rowCount() == 0 and not self._loading:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
elif not self._loading:
|
elif not self._loading:
|
||||||
self._load_ai_statuses()
|
self._load_ai_capabilities()
|
||||||
|
|
||||||
def hideEvent(self, event: Any) -> None:
|
def hideEvent(self, event: Any) -> None:
|
||||||
self._ai_timer.stop()
|
self._ai_timer.stop()
|
||||||
self._ai_scroll_timer.stop()
|
self._ai_scroll_timer.stop()
|
||||||
self._ai_request_token += 1
|
self._ai_request_token += 1
|
||||||
|
self._ai_capability_token += 1
|
||||||
|
self._ai_capability_pending = False
|
||||||
self._ai_pending = False
|
self._ai_pending = False
|
||||||
super().hideEvent(event)
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]:
|
||||||
return {
|
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,
|
"patient_id": 301, "diagnosis_id": 501, "status": status,
|
||||||
"validity": "current", "comparison_type": "independent_baseline",
|
"validity": "current", "comparison_type": "independent_baseline",
|
||||||
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
|
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
|
||||||
@@ -48,10 +48,14 @@ class Repository(DemoDoctorRepository):
|
|||||||
self.calls: list[tuple[str, Any]] = []
|
self.calls: list[tuple[str, Any]] = []
|
||||||
self.batches = [batch()]
|
self.batches = [batch()]
|
||||||
self.enabled = True
|
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]:
|
def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]:
|
||||||
self.calls.append(("reports", params))
|
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]:
|
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
||||||
self.calls.append(("detail", batch_id))
|
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]:
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
self.calls.append(("statuses", list(ids)))
|
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")
|
@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:
|
def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None:
|
||||||
class StatisticsRepository:
|
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]:
|
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}}]}
|
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(), ["*"])
|
dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"])
|
||||||
@@ -635,6 +642,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
|||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
client = Client()
|
client = Client()
|
||||||
repository = RemoteDoctorRepository(client)
|
repository = RemoteDoctorRepository(client)
|
||||||
|
repository.get_prescription_ai_capabilities()
|
||||||
assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []}
|
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.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20)
|
||||||
repository.get_prescription_ai_report(40)
|
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.review_prescription_ai(40, "qwen", "reviewed", "核对完成")
|
||||||
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
|
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
|
||||||
assert client.calls == [
|
assert client.calls == [
|
||||||
|
("GET", "tcm.prescriptionAi/capabilities", {}),
|
||||||
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
|
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
|
||||||
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
|
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
|
||||||
("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}),
|
("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")
|
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:
|
def test_stale_list_score_is_not_attached_to_current_prescription() -> None:
|
||||||
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
|
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
|
||||||
assert ai.agreement_text(value) == "千问 —\nOpenAI —"
|
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]:
|
def reports(**params: Any) -> dict[str, Any]:
|
||||||
repository.calls.append(("reports", params))
|
repository.calls.append(("reports", params))
|
||||||
page = params["page_no"]
|
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)
|
monkeypatch.setattr(repository, "list_prescription_ai_reports", reports)
|
||||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
dialog.show()
|
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:
|
def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None:
|
||||||
class StatisticsRepository:
|
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]:
|
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))]
|
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}}]}
|
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:
|
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]:
|
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||||
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
|
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
|
||||||
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
|
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
|
||||||
|
|||||||
@@ -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,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/`,不属于业务交付。
|
||||||
|
|
||||||
|

|
||||||
|
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)
|
||||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -6,10 +6,32 @@ namespace app\adminapi\controller\tcm;
|
|||||||
|
|
||||||
use app\adminapi\controller\BaseAdminController;
|
use app\adminapi\controller\BaseAdminController;
|
||||||
use app\adminapi\logic\tcm\PrescriptionAiLogic;
|
use app\adminapi\logic\tcm\PrescriptionAiLogic;
|
||||||
|
use app\common\service\prescriptionai\PrescriptionAiVisibility;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
|
|
||||||
final class PrescriptionAiController extends BaseAdminController
|
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()
|
public function statuses()
|
||||||
{
|
{
|
||||||
return $this->handle(false, ['ids'], function (array $p): array {
|
return $this->handle(false, ['ids'], function (array $p): array {
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
|
* 任意履约状态下修改主辅方服用参数(浓缩水丸袋数、次数、开立天数)及订单服用天数,并记录日志
|
||||||
*/
|
*/
|
||||||
public function patchPrescriptionUsage()
|
public function patchPrescriptionUsage()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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\PrescriptionAiProgress as Progress;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiStatistics;
|
use app\common\service\prescriptionai\PrescriptionAiStatistics;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
|
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
|
||||||
|
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
@@ -22,15 +23,16 @@ final class PrescriptionAiLogic
|
|||||||
public static function statuses(array $ids, int $actor, array $info): array
|
public static function statuses(array $ids, int $actor, array $info): array
|
||||||
{
|
{
|
||||||
self::requirePermission('statuses', $actor, $info);
|
self::requirePermission('statuses', $actor, $info);
|
||||||
|
$agreementVisible = Visibility::agreementVisible($actor);
|
||||||
if (!Store::enabled()) {
|
if (!Store::enabled()) {
|
||||||
return ['enabled' => false, 'items' => []];
|
return ['enabled' => false, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||||
}
|
}
|
||||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||||
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
|
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
|
||||||
throw new DomainException('最多查询100个有效处方');
|
throw new DomainException('最多查询100个有效处方');
|
||||||
}
|
}
|
||||||
if ($ids === []) {
|
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');
|
$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() : [];
|
$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.
|
// Do not reveal that a report with a broader source scope exists.
|
||||||
continue;
|
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,
|
'prescription_id' => $id, 'batch_id' => null,
|
||||||
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
|
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
|
||||||
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
|
'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
|
public static function reports(array $params, int $actor, array $info): array
|
||||||
{
|
{
|
||||||
self::requirePermission('reports', $actor, $info);
|
self::requirePermission('reports', $actor, $info);
|
||||||
|
$agreementVisible = Visibility::agreementVisible($actor);
|
||||||
$rxId = (int) ($params['prescription_id'] ?? 0);
|
$rxId = (int) ($params['prescription_id'] ?? 0);
|
||||||
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||||
if (($rxId > 0) === ($diagnosisId > 0)) {
|
if (($rxId > 0) === ($diagnosisId > 0)) {
|
||||||
@@ -100,22 +104,24 @@ final class PrescriptionAiLogic
|
|||||||
} while (count($chunk) === 200);
|
} while (count($chunk) === 200);
|
||||||
$rows = array_slice($visible, ($page - 1) * $size, $size);
|
$rows = array_slice($visible, ($page - 1) * $size, $size);
|
||||||
$modelMap = self::models(array_column($rows, 'id'), false);
|
$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];
|
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function detail(int $batchId, int $actor, array $info): array
|
public static function detail(int $batchId, int $actor, array $info): array
|
||||||
{
|
{
|
||||||
self::requirePermission('detail', $actor, $info);
|
self::requirePermission('detail', $actor, $info);
|
||||||
|
$agreementVisible = Visibility::agreementVisible($actor);
|
||||||
$batch = self::loadBatch($batchId, $actor, $info);
|
$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));
|
$detail = self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
|
||||||
// loadBatch has checked both current prescription and historical source permissions.
|
// 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
|
// 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.
|
// remains the correct original when the user selects an older report revision.
|
||||||
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
|
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
|
||||||
: DoctorSnapshot::project((new Cipher())->decrypt($batch['prescription_cipher'], 'prescription'));
|
: 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
|
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
|
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('statistics', $actor, $info);
|
||||||
self::requirePermission('detail', $actor, $info);
|
self::requirePermission('detail', $actor, $info);
|
||||||
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
|
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
|
||||||
@@ -271,7 +280,7 @@ final class PrescriptionAiLogic
|
|||||||
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
|
'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 === []) {
|
if ($batchIds === []) {
|
||||||
return [];
|
return [];
|
||||||
@@ -301,7 +310,7 @@ final class PrescriptionAiLogic
|
|||||||
foreach ($results as $result) {
|
foreach ($results as $result) {
|
||||||
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
||||||
if ($full) {
|
if ($full) {
|
||||||
$body = self::localizeSourceMetadata($body);
|
$body = Visibility::reportBody(self::localizeSourceMetadata($body), $agreementVisible);
|
||||||
}
|
}
|
||||||
unset($result['body_cipher']);
|
unset($result['body_cipher']);
|
||||||
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
||||||
|
|||||||
@@ -5763,7 +5763,7 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
|
* 业务订单详情场景:任意履约状态下更新主辅方服用参数,变更必须写入操作日志
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $params
|
* @param array<string, mixed> $params
|
||||||
*/
|
*/
|
||||||
@@ -5774,7 +5774,8 @@ class PrescriptionOrderLogic
|
|||||||
try {
|
try {
|
||||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||||
$prescriptionOrderId,
|
$prescriptionOrderId,
|
||||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
|
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo),
|
||||||
|
false // 服用参数允许在提交药房后修正,继续保留订单/提交记录行锁与事务。
|
||||||
);
|
);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
if (self::getError() === '') {
|
if (self::getError() === '') {
|
||||||
@@ -5799,21 +5800,13 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ((int) $order->fulfillment_status === 4) {
|
|
||||||
self::setError('已取消的订单不可修改');
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$rxId = (int) ($order->prescription_id ?? 0);
|
$rxId = (int) ($order->prescription_id ?? 0);
|
||||||
if ($rxId <= 0) {
|
if ($rxId <= 0) {
|
||||||
self::setError('该订单未关联处方');
|
self::setError('该订单未关联处方');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->lock(true)->find();
|
||||||
if (!$rx) {
|
if (!$rx) {
|
||||||
self::setError('处方不存在');
|
self::setError('处方不存在');
|
||||||
|
|
||||||
@@ -5828,25 +5821,37 @@ class PrescriptionOrderLogic
|
|||||||
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
||||||
$usageDays = (int) ($params['usage_days'] ?? 0);
|
$usageDays = (int) ($params['usage_days'] ?? 0);
|
||||||
$medDays = (int) ($params['medication_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 之间');
|
self::setError('主方每天次数须在 1~6 之间');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($usageDays < 1 || $usageDays > 999) {
|
if (!self::isPrescriptionUsageInteger($params['usage_days'] ?? null, 999)) {
|
||||||
self::setError('主方开立天数须在 1~999 之间');
|
self::setError('主方开立天数须在 1~999 之间');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($medDays < 1 || $medDays > 999) {
|
if (!self::isPrescriptionUsageInteger($params['medication_days'] ?? null, 999)) {
|
||||||
self::setError('订单服用天数须在 1~999 之间');
|
self::setError('订单服用天数须在 1~999 之间');
|
||||||
|
|
||||||
return false;
|
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);
|
$hasAux = self::prescriptionHasAuxFormula($rx);
|
||||||
$auxTimesPerDay = null;
|
$auxTimesPerDay = null;
|
||||||
$auxUsageDays = null;
|
$auxUsageDays = null;
|
||||||
|
$auxDosageBagCount = null;
|
||||||
if ($hasAux) {
|
if ($hasAux) {
|
||||||
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
||||||
self::setError('含辅方处方须填写辅方服用参数');
|
self::setError('含辅方处方须填写辅方服用参数');
|
||||||
@@ -5855,20 +5860,29 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
||||||
$auxUsageDays = (int) $params['aux_usage_days'];
|
$auxUsageDays = (int) $params['aux_usage_days'];
|
||||||
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
|
if (!self::isPrescriptionUsageInteger($params['aux_times_per_day'], 6)) {
|
||||||
self::setError('辅方每天次数须在 1~6 之间');
|
self::setError('辅方每天次数须在 1~6 之间');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
|
if (!self::isPrescriptionUsageInteger($params['aux_usage_days'], 999)) {
|
||||||
self::setError('辅方开立天数须在 1~999 之间');
|
self::setError('辅方开立天数须在 1~999 之间');
|
||||||
|
|
||||||
return false;
|
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);
|
$oldTimes = (int) ($rx->times_per_day ?? 0);
|
||||||
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
||||||
|
$oldDosageBagCount = max(1, (int) ($rx->dosage_bag_count ?? 1));
|
||||||
$oldMedDays = (int) ($order->medication_days ?? 0);
|
$oldMedDays = (int) ($order->medication_days ?? 0);
|
||||||
$oldAuxUsage = $rx->aux_usage;
|
$oldAuxUsage = $rx->aux_usage;
|
||||||
if (is_string($oldAuxUsage)) {
|
if (is_string($oldAuxUsage)) {
|
||||||
@@ -5880,16 +5894,48 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
||||||
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 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 {
|
try {
|
||||||
$rxUpdates = [
|
$rxUpdates = [
|
||||||
'times_per_day' => $timesPerDay,
|
'times_per_day' => $timesPerDay,
|
||||||
'usage_days' => $usageDays,
|
'usage_days' => $usageDays,
|
||||||
];
|
];
|
||||||
|
if ($dosageBagCount !== null) {
|
||||||
|
$rxUpdates['dosage_bag_count'] = $dosageBagCount;
|
||||||
|
}
|
||||||
if ($hasAux) {
|
if ($hasAux) {
|
||||||
$auxUsage = $oldAuxUsage;
|
$auxUsage = $oldAuxUsage;
|
||||||
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
||||||
$auxUsage['usage_days'] = $auxUsageDays;
|
$auxUsage['usage_days'] = $auxUsageDays;
|
||||||
|
if ($auxDosageBagCount !== null) {
|
||||||
|
$auxUsage['dosage_bag_count'] = $auxDosageBagCount;
|
||||||
|
}
|
||||||
$rxUpdates['aux_usage'] = $auxUsage;
|
$rxUpdates['aux_usage'] = $auxUsage;
|
||||||
}
|
}
|
||||||
$rx->save($rxUpdates);
|
$rx->save($rxUpdates);
|
||||||
@@ -5897,31 +5943,8 @@ class PrescriptionOrderLogic
|
|||||||
$order->medication_days = $medDays;
|
$order->medication_days = $medDays;
|
||||||
$order->save();
|
$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);
|
$summary = '服用参数:' . implode(';', $parts);
|
||||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
|
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary, true);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} 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 口径一致)
|
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'uploadToPharmacy' => ['id'],
|
'uploadToPharmacy' => ['id'],
|
||||||
'previewGancaoRecipel' => ['id'],
|
'previewGancaoRecipel' => ['id'],
|
||||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'],
|
||||||
'updateAmount' => ['id', 'amount'],
|
'updateAmount' => ['id', 'amount'],
|
||||||
'setShipMode' => ['id', 'ship_mode'],
|
'setShipMode' => ['id', 'ship_mode'],
|
||||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||||
@@ -133,14 +133,28 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
->append('express_company', 'max:20');
|
->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('id', 'require|integer|gt:0')
|
||||||
->append('times_per_day', 'require|integer|between:1,6')
|
->append('times_per_day', 'require|integer|between:1,6')
|
||||||
->append('usage_days', 'require|integer|between:1,999')
|
->append('usage_days', 'require|integer|between:1,999')
|
||||||
->append('medication_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_times_per_day', 'integer|between:1,6')
|
||||||
->append('aux_usage_days', 'integer|between:1,999');
|
->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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service\prescriptionai;
|
||||||
|
|
||||||
|
use app\common\service\ConfigService;
|
||||||
|
use DomainException;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/** Independent access to agreement metrics; never grants access to a patient or report. */
|
||||||
|
final class PrescriptionAiVisibility
|
||||||
|
{
|
||||||
|
public const CONFIG_TYPE = 'prescription_ai_visibility';
|
||||||
|
public const CONFIG_NAME = 'agreement_policy';
|
||||||
|
public const MAX_ROLES = 500;
|
||||||
|
public const MAX_ADMINS = 5000;
|
||||||
|
|
||||||
|
private const COMPARISON_FIELDS = ['comparison', 'score', 'herb_score', 'comparison_status',
|
||||||
|
'comparison_reason_code', 'comparison_type', 'baseline_eligible', 'baseline_exclusion_reasons',
|
||||||
|
'baseline_exclusions_json', 'algorithm_version', 'dictionary_version',
|
||||||
|
'raw', 'raw_body', 'raw_response', 'result', 'response', 'body', 'body_cipher', 'context_cipher', 'progress_cipher'];
|
||||||
|
|
||||||
|
// Scalar leaves and explicitly shaped lists prevent historical/debug body fields from escaping at any depth.
|
||||||
|
private const REPORT_BODY_SCHEMA = [
|
||||||
|
'report' => ['summary' => true, 'diagnosis' => true, 'treatment_advice' => true,
|
||||||
|
'risk_assessment' => ['*' => ['label' => true, 'level' => true, 'evidence_references' => ['*' => true]]],
|
||||||
|
'evidence_references' => ['*' => true], 'missing_information' => ['*' => true]],
|
||||||
|
'candidate' => ['status' => true, 'reason' => true, 'prescription_name' => true, 'prescription_type' => true,
|
||||||
|
'dose_basis' => true, 'usage_instruction' => true, 'times_per_day' => true, 'usage_days' => true,
|
||||||
|
'rationale' => true, 'risk_warnings' => ['*' => true], 'evidence_references' => ['*' => true],
|
||||||
|
'herbs' => ['*' => ['name' => true, 'dosage' => true, 'unit' => true, 'dose_basis' => true,
|
||||||
|
'processing' => true, 'formula_type' => true, 'instructions' => true, 'evidence_references' => ['*' => true]]]],
|
||||||
|
'coverage' => ['status' => true, 'complete' => true, 'source_complete' => true, 'source_ids' => ['*' => true],
|
||||||
|
'token_budget_method' => true, 'clinical_interpretation_verified' => true,
|
||||||
|
'missing' => ['*' => ['source_id' => true, 'code' => true, 'critical' => true]],
|
||||||
|
'files' => ['*' => ['file_id' => true, 'type' => true, 'status' => true, 'transmitted' => true,
|
||||||
|
'version_verified' => true, 'reason' => true]]],
|
||||||
|
'usage' => ['total_calls' => true, 'calls' => ['*' => ['stage' => true, 'input_hash' => true, 'latency_ms' => true,
|
||||||
|
'ok' => true, 'file_count' => true, 'input_token_upper_bound' => true, 'error_code' => true,
|
||||||
|
'usage' => ['prompt_tokens' => true, 'completion_tokens' => true, 'total_tokens' => true]]]],
|
||||||
|
'knowledge_source' => ['title' => true, 'url' => true, 'path' => true, 'commit' => true, 'sections' => ['*' => true]],
|
||||||
|
'ancient_book_sources' => ['*' => ['title' => true, 'chapter' => true, 'excerpt' => true,
|
||||||
|
'path' => true, 'line' => true, 'commit' => true, 'url' => true]],
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function capabilities(int $adminId): array
|
||||||
|
{
|
||||||
|
$actor = PrescriptionAiAccess::actor($adminId);
|
||||||
|
if ($actor === null) {
|
||||||
|
throw new DomainException('账号已停用或无权访问');
|
||||||
|
}
|
||||||
|
return ['agreement_visible' => self::visibleForActor($adminId),
|
||||||
|
'can_manage_visibility' => PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function agreementVisible(int $adminId): bool
|
||||||
|
{
|
||||||
|
return PrescriptionAiAccess::actor($adminId) !== null && self::visibleForActor($adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getConfig(int $adminId): array
|
||||||
|
{
|
||||||
|
self::requireManage($adminId);
|
||||||
|
return self::loadPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setConfig(array $params, int $adminId): array
|
||||||
|
{
|
||||||
|
self::requireManage($adminId);
|
||||||
|
$policy = self::normalizePolicy($params);
|
||||||
|
foreach (['role_ids' => 'system_role', 'admin_ids' => 'admin'] as $field => $table) {
|
||||||
|
if ($policy[$field] === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$query = Db::name($table)->whereIn('id', $policy[$field])->whereNull('delete_time');
|
||||||
|
if ($table === 'admin') {
|
||||||
|
$query->where('disable', 0);
|
||||||
|
}
|
||||||
|
if ((int) $query->count() !== count($policy[$field])) {
|
||||||
|
throw new DomainException($table === 'admin' ? '授权用户不存在或已停用,请移除失效用户' : '授权角色不存在,请移除失效角色');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Store one object so readers never observe half of a role/user policy update.
|
||||||
|
ConfigService::set(self::CONFIG_TYPE, self::CONFIG_NAME, $policy);
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function options(int $adminId): array
|
||||||
|
{
|
||||||
|
self::requireManage($adminId);
|
||||||
|
// The endpoint is intentionally bounded. Never silently truncate an authorization selector.
|
||||||
|
$roles = Db::name('system_role')->whereNull('delete_time')->field('id,name')->order('id')
|
||||||
|
->limit(self::MAX_ROLES + 1)->select()->toArray();
|
||||||
|
$admins = Db::name('admin')->whereNull('delete_time')->where('disable', 0)->field('id,name,account')->order('id')
|
||||||
|
->limit(self::MAX_ADMINS + 1)->select()->toArray();
|
||||||
|
if (count($roles) > self::MAX_ROLES || count($admins) > self::MAX_ADMINS) {
|
||||||
|
throw new DomainException('授权选项超过上限(500个角色、5000个用户),请联系管理员');
|
||||||
|
}
|
||||||
|
return ['roles' => array_map(static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $roles),
|
||||||
|
'admins' => array_map(static fn (array $a): array => ['id' => (int) $a['id'], 'name' => (string) $a['name'],
|
||||||
|
'account' => (string) $a['account']], $admins)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizePolicy(array $params): array
|
||||||
|
{
|
||||||
|
if (array_diff(array_keys($params), ['role_ids', 'admin_ids']) !== []
|
||||||
|
|| !array_key_exists('role_ids', $params) || !array_key_exists('admin_ids', $params)) {
|
||||||
|
throw new DomainException('授权配置须包含且仅包含角色和用户列表');
|
||||||
|
}
|
||||||
|
return ['role_ids' => self::ids($params['role_ids'], self::MAX_ROLES),
|
||||||
|
'admin_ids' => self::ids($params['admin_ids'], self::MAX_ADMINS)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the response from known saved-body fields, never merge an arbitrary decrypted object. */
|
||||||
|
public static function reportBody(array $body, bool $agreementVisible): array
|
||||||
|
{
|
||||||
|
$projected = self::projectValue($body, self::REPORT_BODY_SCHEMA);
|
||||||
|
if ($agreementVisible && array_key_exists('comparison', $body)) {
|
||||||
|
$projected['comparison'] = $body['comparison'];
|
||||||
|
}
|
||||||
|
return $projected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function projectBatch(array $batch, bool $agreementVisible): array
|
||||||
|
{
|
||||||
|
if (!$agreementVisible) {
|
||||||
|
$batch = self::withoutComparison($batch);
|
||||||
|
foreach ($batch['models'] ?? [] as $key => $model) {
|
||||||
|
// A failed task's reason is a generation error; a successful result's reason is a comparison.
|
||||||
|
if (($model['error_code'] ?? '') === '') {
|
||||||
|
unset($batch['models'][$key]['reason']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$batch['agreement_visible'] = $agreementVisible;
|
||||||
|
return $batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function withoutComparison(array $value): array
|
||||||
|
{
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (in_array($key, self::COMPARISON_FIELDS, true)) {
|
||||||
|
unset($value[$key]);
|
||||||
|
} elseif (is_array($item)) {
|
||||||
|
$value[$key] = self::withoutComparison($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function projectValue($value, $schema)
|
||||||
|
{
|
||||||
|
if ($schema === true) {
|
||||||
|
return is_scalar($value) || $value === null ? $value : null;
|
||||||
|
}
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('*', $schema)) {
|
||||||
|
return array_map(static fn ($item) => self::projectValue($item, $schema['*']), array_values($value));
|
||||||
|
}
|
||||||
|
$result = [];
|
||||||
|
foreach ($schema as $key => $shape) {
|
||||||
|
if (array_key_exists($key, $value)) {
|
||||||
|
$result[$key] = self::projectValue($value[$key], $shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requireManage(int $adminId): void
|
||||||
|
{
|
||||||
|
$actor = PrescriptionAiAccess::actor($adminId);
|
||||||
|
if ($actor === null || !PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')) {
|
||||||
|
throw new DomainException('无权管理处方AI一致度授权');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function visibleForActor(int $adminId): bool
|
||||||
|
{
|
||||||
|
$policy = self::loadPolicy();
|
||||||
|
if (in_array($adminId, $policy['admin_ids'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($policy['role_ids'] === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Read current membership and active roles, never the login token's role snapshot.
|
||||||
|
return Db::name('admin_role')->alias('ar')->join('system_role r', 'r.id=ar.role_id')
|
||||||
|
->where('ar.admin_id', $adminId)->whereIn('r.id', $policy['role_ids'])->whereNull('r.delete_time')->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadPolicy(): array
|
||||||
|
{
|
||||||
|
$empty = ['role_ids' => [], 'admin_ids' => []];
|
||||||
|
$stored = ConfigService::get(self::CONFIG_TYPE, self::CONFIG_NAME, $empty);
|
||||||
|
if (!is_array($stored)) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return self::normalizePolicy($stored);
|
||||||
|
} catch (DomainException $e) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ids($raw, int $max): array
|
||||||
|
{
|
||||||
|
if (!is_array($raw) || !array_is_list($raw) || count($raw) > $max) {
|
||||||
|
throw new DomainException('授权列表格式或数量无效');
|
||||||
|
}
|
||||||
|
$ids = [];
|
||||||
|
foreach ($raw as $id) {
|
||||||
|
if ((!is_int($id) && !is_string($id)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $id)) {
|
||||||
|
throw new DomainException('授权标识须为有效正整数');
|
||||||
|
}
|
||||||
|
$ids[] = (int) $id;
|
||||||
|
}
|
||||||
|
$ids = array_values(array_unique($ids));
|
||||||
|
sort($ids, SORT_NUMERIC);
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Agreement visibility is a separate allowlist. This migration grants no role access.
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
SET @rx_ai_visibility_setting := (
|
||||||
|
SELECT id FROM zyt_system_menu
|
||||||
|
WHERE type='M' AND (paths IN ('setting','/setting') OR name='系统设置')
|
||||||
|
ORDER BY CASE WHEN paths IN ('setting','/setting') THEN 0 ELSE 1 END,id
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu
|
||||||
|
(pid,type,name,icon,sort,perms,paths,component,selected,params,is_cache,is_show,is_disable,create_time,update_time)
|
||||||
|
SELECT COALESCE(@rx_ai_visibility_setting,0),'C','处方AI一致度授权','el-icon-Lock',91,
|
||||||
|
'tcm.prescriptionAi/manageVisibility','prescription_ai_visibility','setting/prescription_ai_visibility/index',
|
||||||
|
'','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM zyt_system_menu WHERE perms='tcm.prescriptionAi/manageVisibility'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed one complete empty policy. Runtime ConfigService writes update this same row.
|
||||||
|
INSERT INTO zyt_config (type,name,value,create_time,update_time)
|
||||||
|
SELECT 'prescription_ai_visibility','agreement_policy','{"role_ids":[],"admin_ids":[]}',UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM zyt_config WHERE type='prescription_ai_visibility' AND name='agreement_policy'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -11,6 +11,9 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
|||||||
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||||
use app\common\service\prescriptionai\PrescriptionAiRequest as SaveRequest;
|
use app\common\service\prescriptionai\PrescriptionAiRequest as SaveRequest;
|
||||||
|
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||||
|
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
|
||||||
|
use app\common\service\ConfigService;
|
||||||
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
|
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
|
||||||
use think\Container;
|
use think\Container;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
@@ -56,11 +59,15 @@ $root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' =>
|
|||||||
try {
|
try {
|
||||||
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(5),name VARCHAR(100),icon VARCHAR(50),sort INT,perms VARCHAR(100),paths VARCHAR(100),component VARCHAR(100),selected VARCHAR(100),params VARCHAR(100),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)');
|
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(5),name VARCHAR(100),icon VARCHAR(50),sort INT,perms VARCHAR(100),paths VARCHAR(100),component VARCHAR(100),selected VARCHAR(100),params VARCHAR(100),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT,menu_id INT,UNIQUE KEY(role_id,menu_id))');
|
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT,menu_id INT,UNIQUE KEY(role_id,menu_id))');
|
||||||
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL)');
|
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL,account VARCHAR(50) DEFAULT "fixture")');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_system_role (id INT PRIMARY KEY,name VARCHAR(50),data_scope INT DEFAULT 1,delete_time INT NULL)');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_config (id INT PRIMARY KEY AUTO_INCREMENT,type VARCHAR(30),name VARCHAR(60),value TEXT,create_time INT,update_time INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT,role_id INT)');
|
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT,role_id INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_admin_dept (admin_id INT,dept_id INT)');
|
$pdo->exec('CREATE TABLE zyt_admin_dept (admin_id INT,dept_id INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_admin_jobs (admin_id INT,jobs_id INT)');
|
$pdo->exec('CREATE TABLE zyt_admin_jobs (admin_id INT,jobs_id INT)');
|
||||||
$pdo->exec("INSERT INTO zyt_admin VALUES(1,'Test',1,0,NULL)");
|
$pdo->exec("INSERT INTO zyt_admin(id,name,root,disable,delete_time) VALUES(1,'Test',1,0,NULL),(2,'Role user',0,0,NULL),(3,'Direct user',0,0,NULL),(4,'Disabled user',0,1,NULL),(5,'Deleted user',0,0,1),(6,'Manager',0,0,NULL)");
|
||||||
|
$pdo->exec("INSERT INTO zyt_system_role VALUES(7,'Selected role',1,NULL),(8,'Other role',1,NULL),(9,'Deleted role',1,1),(10,'Policy manager',1,NULL)");
|
||||||
|
$pdo->exec('INSERT INTO zyt_admin_role VALUES(2,7),(2,8),(6,10)');
|
||||||
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 1,delete_time INT NULL)');
|
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 1,delete_time INT NULL)');
|
||||||
$pdo->exec('INSERT INTO zyt_tcm_diagnosis(id,patient_id) VALUES(1,100),(2,200)');
|
$pdo->exec('INSERT INTO zyt_tcm_diagnosis(id,patient_id) VALUES(1,100),(2,200)');
|
||||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription (id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT DEFAULT 1,patient_id INT DEFAULT 100,creator_id INT DEFAULT 1,is_system_auto INT DEFAULT 0,void_status INT DEFAULT 0,delete_time INT NULL,herbs TEXT,prescription_type VARCHAR(30) DEFAULT "饮片",update_time INT DEFAULT 0) ENGINE=InnoDB');
|
$pdo->exec('CREATE TABLE zyt_tcm_prescription (id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT DEFAULT 1,patient_id INT DEFAULT 100,creator_id INT DEFAULT 1,is_system_auto INT DEFAULT 0,void_status INT DEFAULT 0,delete_time INT NULL,herbs TEXT,prescription_type VARCHAR(30) DEFAULT "饮片",update_time INT DEFAULT 0) ENGINE=InnoDB');
|
||||||
@@ -86,6 +93,87 @@ try {
|
|||||||
if (trim($statement) !== '') { $pdo->exec($statement); }
|
if (trim($statement) !== '') { $pdo->exec($statement); }
|
||||||
}
|
}
|
||||||
$expect((int) Db::name('system_menu')->count() === 7, 'idempotent permission migration');
|
$expect((int) Db::name('system_menu')->count() === 7, 'idempotent permission migration');
|
||||||
|
$visibilityMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql');
|
||||||
|
for ($repeat = 0; $repeat < 2; $repeat++) {
|
||||||
|
foreach (explode(';', preg_replace('/^--.*$/m', '', $visibilityMigration)) as $statement) {
|
||||||
|
if (trim($statement) !== '') { $pdo->exec($statement); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$expect((int) Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->count() === 1
|
||||||
|
&& (int) Db::name('config')->count() === 1, 'visibility migration is idempotent and seeds one empty policy');
|
||||||
|
$expect((int) Db::name('system_role_menu')->count() === 0, 'migration grants no nonroot role management permission');
|
||||||
|
$expect(Visibility::capabilities(1) === ['agreement_visible' => false, 'can_manage_visibility' => true],
|
||||||
|
'root manages policy but does not bypass empty view allowlist');
|
||||||
|
$expect(Visibility::getConfig(1) === ['role_ids' => [], 'admin_ids' => []], 'default policy is closed');
|
||||||
|
$denied = static function (callable $call, string $message) use ($expect): void {
|
||||||
|
try { $call(); $expect(false, $message); }
|
||||||
|
catch (DomainException $e) { $expect(true, $message); }
|
||||||
|
};
|
||||||
|
$http = static function (string $action, int $adminId = 1, string $method = 'GET', array $params = []): array {
|
||||||
|
// Run the real final controller against a local request without bootstrapping an HTTP app.
|
||||||
|
$reflection = new ReflectionClass(\app\adminapi\controller\tcm\PrescriptionAiController::class);
|
||||||
|
$controller = $reflection->newInstanceWithoutConstructor();
|
||||||
|
$request = (new think\Request())->setMethod($method);
|
||||||
|
$request = $method === 'POST' ? $request->withPost($params) : $request->withGet($params);
|
||||||
|
foreach (['request' => $request, 'adminId' => $adminId,
|
||||||
|
'adminInfo' => ['root' => 1, 'role_id' => [7], 'permissions' => ['*']]] as $property => $value) {
|
||||||
|
$reflection->getProperty($property)->setValue($controller, $value);
|
||||||
|
}
|
||||||
|
return $controller->$action()->getData();
|
||||||
|
};
|
||||||
|
$expect($http('capabilities', 2)['data'] === ['agreement_visible' => false, 'can_manage_visibility' => false],
|
||||||
|
'capabilities is a login-only self-check, never trusts stale root/star token information');
|
||||||
|
$expect($http('capabilities', 4)['code'] === 0, 'disabled account cannot call capabilities with stale root token');
|
||||||
|
foreach (['getVisibilityConfig', 'visibilityOptions'] as $action) {
|
||||||
|
$expect($http($action, 2)['code'] === 0 && $http($action, 1, 'POST')['code'] === 0,
|
||||||
|
'config read/options enforce current manager and GET method');
|
||||||
|
}
|
||||||
|
$expect($http('setVisibilityConfig', 2, 'POST', ['role_ids' => [], 'admin_ids' => [2]])['code'] === 0
|
||||||
|
&& $http('setVisibilityConfig', 1, 'GET')['code'] === 0
|
||||||
|
&& $http('setVisibilityConfig', 1, 'POST', ['role_ids' => [], 'admin_ids' => [], 'root' => true])['code'] === 0,
|
||||||
|
'config write rejects manager escalation, wrong method and unknown parameters');
|
||||||
|
foreach ([static fn () => Visibility::getConfig(2), static fn () => Visibility::options(2),
|
||||||
|
static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [2]], 2)] as $operation) {
|
||||||
|
$denied($operation, 'ordinary account cannot manage allowlist despite unregistered config routes');
|
||||||
|
}
|
||||||
|
Db::name('system_role_menu')->insert(['role_id' => 10,
|
||||||
|
'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->value('id')]);
|
||||||
|
$expect(Visibility::capabilities(6) === ['agreement_visible' => false, 'can_manage_visibility' => true],
|
||||||
|
'dedicated permission enables management without granting agreement view');
|
||||||
|
$selected = Visibility::setConfig(['role_ids' => ['7', 7], 'admin_ids' => [3]], 6);
|
||||||
|
$expect($selected === ['role_ids' => [7], 'admin_ids' => [3]], 'authorized manager saves canonical policy');
|
||||||
|
$expect(Visibility::agreementVisible(2) && Visibility::agreementVisible(3) && !Visibility::agreementVisible(1),
|
||||||
|
'role union and direct user are alternatives, root has no implicit view');
|
||||||
|
$denied(static fn () => Api::statuses([], 2, Access::actor(2)), 'allowlist does not replace existing action RBAC');
|
||||||
|
Db::name('system_role_menu')->insert(['role_id' => 8,
|
||||||
|
'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/statuses')->value('id')]);
|
||||||
|
$expect(Api::statuses([], 2, Access::actor(2))['agreement_visible'] === true,
|
||||||
|
'selected role plus independent action permission can view metric capability');
|
||||||
|
Db::name('admin_role')->where('admin_id', 2)->where('role_id', 7)->delete();
|
||||||
|
$expect(!Visibility::agreementVisible(2), 'role membership revocation applies immediately');
|
||||||
|
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 7]);
|
||||||
|
Db::name('system_role')->where('id', 7)->update(['delete_time' => 1]);
|
||||||
|
$expect(!Visibility::agreementVisible(2), 'deleted selected role never authorizes stale membership');
|
||||||
|
Db::name('system_role')->where('id', 7)->update(['delete_time' => null]);
|
||||||
|
$denied(static fn () => Visibility::setConfig(['role_ids' => [9], 'admin_ids' => []], 1), 'deleted role cannot be newly selected');
|
||||||
|
$denied(static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [4]], 1), 'disabled user cannot be newly selected');
|
||||||
|
$options = Visibility::options(6);
|
||||||
|
$expect(array_column($options['roles'], 'id') === [7, 8, 10]
|
||||||
|
&& array_column($options['admins'], 'id') === [1, 2, 3, 6]
|
||||||
|
&& array_keys($options['admins'][0]) === ['id', 'name', 'account'], 'options contain active records and minimal account fields only');
|
||||||
|
Db::name('system_role')->insertAll(array_map(static fn (int $id): array =>
|
||||||
|
['id' => $id, 'name' => 'bounded selector fixture'], range(100, 599)));
|
||||||
|
$denied(static fn () => Visibility::options(1), 'oversized selector fails explicitly instead of silently truncating');
|
||||||
|
Db::name('system_role')->where('id', '>=', 100)->delete();
|
||||||
|
Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 1]);
|
||||||
|
$denied(static fn () => Visibility::getConfig(6), 'disabled management permission is revoked immediately');
|
||||||
|
Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 0]);
|
||||||
|
Db::name('config')->where('type', Visibility::CONFIG_TYPE)->update(['value' => '{"role_ids":"*","admin_ids":[1]}']);
|
||||||
|
$expect(!Visibility::agreementVisible(1) && !Visibility::agreementVisible(3), 'malformed policy fails closed as a whole');
|
||||||
|
ConfigService::set(Visibility::CONFIG_TYPE, Visibility::CONFIG_NAME, ['role_ids' => [], 'admin_ids' => [1, 4, 5]]);
|
||||||
|
$expect(Visibility::agreementVisible(1) && !Visibility::agreementVisible(4) && !Visibility::agreementVisible(5),
|
||||||
|
'stale direct grants cannot enable disabled or deleted accounts');
|
||||||
|
Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1);
|
||||||
if (!$legacyProgressSchema) {
|
if (!$legacyProgressSchema) {
|
||||||
$progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql');
|
$progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql');
|
||||||
for ($i = 0; $i < 2; $i++) {
|
for ($i = 0; $i < 2; $i++) {
|
||||||
@@ -225,6 +313,45 @@ try {
|
|||||||
'report body cannot override trusted task progress');
|
'report body cannot override trusted task progress');
|
||||||
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
|
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
|
||||||
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['review']['status'] === 'not_adopted', 'review independent of prescription');
|
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['review']['status'] === 'not_adopted', 'review independent of prescription');
|
||||||
|
$resultBody['comparison']['reason'] = 'hidden-metric-sentinel';
|
||||||
|
$resultBody['comparison']['normalization'] = ['matched_contribution_sum' => 'hidden-metric-sentinel'];
|
||||||
|
foreach (['score', 'herb_score', 'raw', 'result', 'response', 'body'] as $rawKey) {
|
||||||
|
$resultBody[$rawKey] = ['unknown' => 'hidden-metric-sentinel'];
|
||||||
|
}
|
||||||
|
$resultBody['report']['unknown_debug'] = ['normalization' => 'hidden-metric-sentinel'];
|
||||||
|
$resultBody['candidate']['unknown_debug'] = 'hidden-metric-sentinel';
|
||||||
|
Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([
|
||||||
|
'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'),
|
||||||
|
]);
|
||||||
|
Visibility::setConfig(['role_ids' => [], 'admin_ids' => []], 1);
|
||||||
|
$hiddenStatus = Api::statuses([$rx['id']], 1, $root);
|
||||||
|
$hiddenReports = Api::reports(['prescription_id' => $rx['id']], 1, $root);
|
||||||
|
$hiddenDetail = Api::detail($batchId, 1, $root);
|
||||||
|
$expect($hiddenStatus['agreement_visible'] === false && $hiddenStatus['items'][0]['agreement_visible'] === false
|
||||||
|
&& $hiddenReports['agreement_visible'] === false && $hiddenReports['lists'][0]['agreement_visible'] === false
|
||||||
|
&& $hiddenDetail['agreement_visible'] === false, 'all list/history/detail levels carry current agreement capability');
|
||||||
|
foreach ([$hiddenStatus['items'][0], $hiddenReports['lists'][0], $hiddenDetail] as $hiddenBatch) {
|
||||||
|
$expect(!isset($hiddenBatch['comparison_type']) && !isset($hiddenBatch['baseline_eligible'])
|
||||||
|
&& !isset($hiddenBatch['baseline_exclusion_reasons'])
|
||||||
|
&& !isset($hiddenBatch['models']['qwen']['score']) && !isset($hiddenBatch['models']['qwen']['herb_score'])
|
||||||
|
&& !isset($hiddenBatch['models']['qwen']['comparison']) && !isset($hiddenBatch['models']['qwen']['comparison_status'])
|
||||||
|
&& !isset($hiddenBatch['models']['qwen']['comparison_reason_code']) && !isset($hiddenBatch['models']['qwen']['reason'])
|
||||||
|
&& !str_contains(json_encode($hiddenBatch), 'hidden-metric-sentinel'), 'shared API projection removes metrics and raw-body paths');
|
||||||
|
}
|
||||||
|
$expect($hiddenDetail['models']['qwen']['report']['summary'] === 'synthetic report'
|
||||||
|
&& $hiddenDetail['models']['qwen']['candidate']['status'] === 'available_for_review'
|
||||||
|
&& $hiddenDetail['doctor_snapshot'] === $snapshot && $hiddenDetail['models']['qwen']['review']['status'] === 'not_adopted'
|
||||||
|
&& $hiddenDetail['models']['openai']['reason'] !== '', 'hidden metric preserves report, candidate, original, review and failure explanation');
|
||||||
|
$denied(static fn () => Api::statistics([], 1, $root), 'root without selected grant cannot read agreement statistics');
|
||||||
|
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
|
||||||
|
$expect(Api::retry($batchId, 'openai', 1, $root)['status'] === 'retry_wait',
|
||||||
|
'agreement restriction does not remove independent report review/retry permission');
|
||||||
|
Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1);
|
||||||
|
$restored = Api::detail($batchId, 1, $root);
|
||||||
|
$expect($restored['agreement_visible'] && $restored['models']['qwen']['score'] === 80.0
|
||||||
|
&& $restored['models']['qwen']['comparison']['score'] === 80
|
||||||
|
&& !isset($restored['models']['qwen']['raw']) && !isset($restored['models']['qwen']['result']),
|
||||||
|
'selected root gets genuine persisted metric, never arbitrary decrypted-body aliases');
|
||||||
Api::retry($batchId, 'openai', 1, $root);
|
Api::retry($batchId, 'openai', 1, $root);
|
||||||
$o2 = Store::claimTask('openai');
|
$o2 = Store::claimTask('openai');
|
||||||
$expect((int) $o2['total_attempts'] === 2 && (int) $o2['attempts'] === 1, 'manual retry preserves lifetime attempts');
|
$expect((int) $o2['total_attempts'] === 2 && (int) $o2['attempts'] === 1, 'manual retry preserves lifetime attempts');
|
||||||
@@ -363,7 +490,7 @@ try {
|
|||||||
$before = (int) Db::name('prescription_ai_batch')->count();
|
$before = (int) Db::name('prescription_ai_batch')->count();
|
||||||
$config->set(['enabled' => false], 'prescription_analysis');
|
$config->set(['enabled' => false], 'prescription_analysis');
|
||||||
$expect($save($fixture()) === null && (int) Db::name('prescription_ai_batch')->count() === $before, 'feature disabled makes no outbox writes');
|
$expect($save($fixture()) === null && (int) Db::name('prescription_ai_batch')->count() === $before, 'feature disabled makes no outbox writes');
|
||||||
$expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'items' => []], 'disabled list has graceful compatibility');
|
$expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'agreement_visible' => true, 'items' => []], 'disabled list has graceful compatibility');
|
||||||
echo "Prescription AI queue: {$checks} checks passed\n";
|
echo "Prescription AI queue: {$checks} checks passed\n";
|
||||||
} finally {
|
} finally {
|
||||||
$pdo->exec("DROP DATABASE `{$database}`");
|
$pdo->exec("DROP DATABASE `{$database}`");
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Pure policy normalization and presentation checks: no App initialize, .env, database or network.
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||||
|
|
||||||
|
$checks = 0;
|
||||||
|
$expect = static function (bool $condition, string $message) use (&$checks): void {
|
||||||
|
if (!$condition) { throw new RuntimeException($message); }
|
||||||
|
$checks++;
|
||||||
|
};
|
||||||
|
$expect(Visibility::normalizePolicy(['role_ids' => ['7', 2, 7], 'admin_ids' => ['9', 3]])
|
||||||
|
=== ['role_ids' => [2, 7], 'admin_ids' => [3, 9]], 'IDs normalize, deduplicate and sort');
|
||||||
|
$expect(Visibility::normalizePolicy(['role_ids' => [], 'admin_ids' => []])
|
||||||
|
=== ['role_ids' => [], 'admin_ids' => []], 'empty policy stays empty');
|
||||||
|
foreach ([[], ['role_ids' => []], ['role_ids' => [], 'admin_ids' => [], 'root' => true],
|
||||||
|
['role_ids' => '*', 'admin_ids' => []], ['role_ids' => [true], 'admin_ids' => []],
|
||||||
|
['role_ids' => [1.0], 'admin_ids' => []], ['role_ids' => [-1], 'admin_ids' => []],
|
||||||
|
['role_ids' => ['01'], 'admin_ids' => []], ['role_ids' => ['1,2'], 'admin_ids' => []],
|
||||||
|
['role_ids' => ['id' => 1], 'admin_ids' => []], ['role_ids' => [PHP_INT_MAX], 'admin_ids' => []],
|
||||||
|
['role_ids' => array_fill(0, Visibility::MAX_ROLES + 1, 1), 'admin_ids' => []]] as $invalid) {
|
||||||
|
try { Visibility::normalizePolicy($invalid); $expect(false, 'invalid policy must fail'); }
|
||||||
|
catch (DomainException $e) { $checks++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentinel = 'private-agreement-sentinel';
|
||||||
|
$body = ['report' => ['summary' => 'clinical report', 'comparison' => ['reason' => $sentinel], 'raw' => [$sentinel],
|
||||||
|
'unknown_debug' => ['normalization' => $sentinel]],
|
||||||
|
'candidate' => ['status' => 'available_for_review', 'reason' => 'clinical reasoning', 'herbs' => [['name' => '测试药材']]],
|
||||||
|
'comparison' => ['score' => 88, 'normalization' => ['matched_contribution_sum' => 2]],
|
||||||
|
'coverage' => ['status' => 'complete'], 'usage' => ['total_calls' => 2],
|
||||||
|
'knowledge_source' => ['title' => 'clinical reference'], 'ancient_book_sources' => [['title' => 'book']],
|
||||||
|
'score' => $sentinel, 'result' => [$sentinel], 'raw' => [$sentinel], 'body' => [$sentinel],
|
||||||
|
'progress' => ['stage' => 'poisoned']];
|
||||||
|
$public = Visibility::reportBody($body, false);
|
||||||
|
$expect(!str_contains(json_encode($public), $sentinel) && !isset($public['comparison']), 'comparison and unknown raw body paths cannot escape');
|
||||||
|
$expect($public['report']['summary'] === 'clinical report' && $public['candidate']['reason'] === 'clinical reasoning'
|
||||||
|
&& $public['coverage']['status'] === 'complete', 'report, candidate reasoning and coverage survive');
|
||||||
|
$private = Visibility::reportBody($body, true);
|
||||||
|
$expect($private['comparison']['score'] === 88 && !isset($private['result']) && !isset($private['progress']),
|
||||||
|
'authorized comparison survives but arbitrary body cannot override response fields');
|
||||||
|
$expect(!isset($private['report']['unknown_debug']) && !isset($private['report']['raw']),
|
||||||
|
'known clinical report fields are projected at every depth for all viewers');
|
||||||
|
$batch = ['comparison_type' => $sentinel, 'baseline_eligible' => true, 'baseline_exclusion_reasons' => [$sentinel],
|
||||||
|
'models' => ['qwen' => ['status' => 'success', 'score' => 88, 'herb_score' => 90,
|
||||||
|
'reason' => $sentinel, 'comparison_status' => 'comparable', 'comparison_reason_code' => $sentinel,
|
||||||
|
'algorithm_version' => $sentinel, 'dictionary_version' => $sentinel, 'error_code' => '',
|
||||||
|
'comparison' => ['rows' => [$sentinel]], 'progress' => ['stage' => 'completed']] + $public,
|
||||||
|
'openai' => ['status' => 'failed', 'error_code' => 'UPSTREAM_TIMEOUT', 'reason' => '模型请求超时']]];
|
||||||
|
$hidden = Visibility::projectBatch($batch, false);
|
||||||
|
$expect($hidden['agreement_visible'] === false && !str_contains(json_encode($hidden), $sentinel), 'whole batch removes all metric aliases and derived fields');
|
||||||
|
$expect($hidden['models']['openai']['reason'] === '模型请求超时'
|
||||||
|
&& $hidden['models']['qwen']['progress']['stage'] === 'completed', 'failure explanation and trusted progress remain');
|
||||||
|
$expect(Visibility::projectBatch($batch, true)['models']['qwen']['score'] === 88, 'authorized stored metric is unchanged');
|
||||||
|
$expect(Visibility::projectBatch(['models' => []], false) === ['models' => [], 'agreement_visible' => false], 'empty batch advertises capability');
|
||||||
|
|
||||||
|
echo "Prescription AI visibility: {$checks} offline checks passed\n";
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main/auxiliary usage edits in every order state, using real validation, ORM and transactions.
|
||||||
|
* Set ZYT_AUX_BAG_TEST_MYSQL_PORT to an explicitly selected isolated root/empty-password instance.
|
||||||
|
* Never initializes the application or loads its business database configuration.
|
||||||
|
*/
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||||
|
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
|
||||||
|
use app\common\model\tcm\Prescription;
|
||||||
|
use think\Container;
|
||||||
|
use think\DbManager;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
$port = (int) getenv('ZYT_AUX_BAG_TEST_MYSQL_PORT');
|
||||||
|
if ($port <= 0 || $port === 3306) {
|
||||||
|
fwrite(STDERR, "Set ZYT_AUX_BAG_TEST_MYSQL_PORT to an isolated local MySQL instance, outside port 3306.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$database = 'aux_bag_test_' . bin2hex(random_bytes(6));
|
||||||
|
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_TIMEOUT => 2,
|
||||||
|
]);
|
||||||
|
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
||||||
|
$pdo->exec("USE `{$database}`");
|
||||||
|
$testApp = new think\App(); // Do not initialize application configuration or services.
|
||||||
|
$manager = new DbManager();
|
||||||
|
$manager->setConfig([
|
||||||
|
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
|
||||||
|
'connections' => ['mysql' => [
|
||||||
|
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
||||||
|
'database' => $database, 'username' => 'root', 'password' => '',
|
||||||
|
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
$config = new think\Config();
|
||||||
|
$config->set(['data_scope' => ['enabled' => false]], 'project');
|
||||||
|
Container::getInstance()->instance('think\DbManager', $manager);
|
||||||
|
Container::getInstance()->instance('config', $config);
|
||||||
|
$testLang = new think\Lang($testApp);
|
||||||
|
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
|
||||||
|
|
||||||
|
$checks = 0;
|
||||||
|
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
||||||
|
if (!$ok) {
|
||||||
|
throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
++$checks;
|
||||||
|
};
|
||||||
|
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
|
||||||
|
$params = static fn (int $id): array => [
|
||||||
|
'id' => $id, 'times_per_day' => 3, 'usage_days' => 9, 'medication_days' => 8,
|
||||||
|
'aux_times_per_day' => 2, 'aux_usage_days' => 6,
|
||||||
|
];
|
||||||
|
$unchangedParams = static fn (int $id): array => [
|
||||||
|
'id' => $id, 'times_per_day' => 3, 'usage_days' => 7, 'medication_days' => 7,
|
||||||
|
'aux_times_per_day' => 3, 'aux_usage_days' => 7,
|
||||||
|
];
|
||||||
|
$validate = static fn (array $data): bool => (new PrescriptionOrderValidate())
|
||||||
|
->scene('patchPrescriptionUsage')->check($data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->exec('CREATE TABLE zyt_tcm_prescription (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT, creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0,
|
||||||
|
is_shared INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "", gender INT DEFAULT 0,
|
||||||
|
prescription_type VARCHAR(30) DEFAULT "浓缩水丸", herbs JSON, aux_usage JSON,
|
||||||
|
dosage_amount DECIMAL(10,2) DEFAULT 5, dosage_bag_count INT DEFAULT 2,
|
||||||
|
times_per_day INT DEFAULT 3, usage_days INT DEFAULT 7,
|
||||||
|
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
|
||||||
|
) ENGINE=InnoDB');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT, prescription_id INT DEFAULT 0, creator_id INT DEFAULT 1,
|
||||||
|
fulfillment_status INT DEFAULT 2, medication_days INT DEFAULT 7,
|
||||||
|
gancao_reciperl_order_no VARCHAR(100) DEFAULT "", ej_pharmacy_order_no VARCHAR(100) DEFAULT "",
|
||||||
|
gancao_submit_time INT DEFAULT 0, ej_pharmacy_submit_time INT DEFAULT 0,
|
||||||
|
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
|
||||||
|
) ENGINE=InnoDB');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT,
|
||||||
|
admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT
|
||||||
|
) ENGINE=InnoDB');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_pharmacy_submission_claim (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, source_revision INT, status VARCHAR(50)
|
||||||
|
) ENGINE=InnoDB');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
|
||||||
|
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)');
|
||||||
|
|
||||||
|
$auxSeed = [
|
||||||
|
'dosage_amount' => 5, 'dosage_bag_count' => 1, 'times_per_day' => 3, 'usage_days' => 7,
|
||||||
|
'prescription_name' => '测试辅方', 'need_decoction' => 0, 'bags_per_dose' => 1,
|
||||||
|
'extra_metadata' => ['source' => 'fixture'],
|
||||||
|
];
|
||||||
|
$fixture = static function (array $rx = [], array $order = []) use ($auxSeed): int {
|
||||||
|
$rxId = (int) Db::name('tcm_prescription')->insertGetId(array_replace([
|
||||||
|
'herbs' => json_encode([['formula_type' => '主方'], ['formula_type' => '辅方']], JSON_UNESCAPED_UNICODE),
|
||||||
|
'aux_usage' => json_encode($auxSeed, JSON_UNESCAPED_UNICODE),
|
||||||
|
], $rx));
|
||||||
|
|
||||||
|
return (int) Db::name('tcm_prescription_order')->insertGetId(array_replace(['prescription_id' => $rxId], $order));
|
||||||
|
};
|
||||||
|
$orderRow = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
|
||||||
|
$rxRow = static fn (int $id): array => Db::name('tcm_prescription')
|
||||||
|
->where('id', $orderRow($id)['prescription_id'])->find();
|
||||||
|
$rxModel = static fn (int $id): Prescription => Prescription::find((int) $orderRow($id)['prescription_id']);
|
||||||
|
$logs = static fn (int $id): array => Db::name('tcm_prescription_order_log')
|
||||||
|
->where('prescription_order_id', $id)->order('id')->select()->toArray();
|
||||||
|
$snapshot = static fn (int $id): array => [$orderRow($id), $rxRow($id), $logs($id)];
|
||||||
|
|
||||||
|
// Exercise the same scene name used by the controller, including backward-compatible omission.
|
||||||
|
$expect($validate($params(1)), 'Legacy requests without a bag count must remain valid');
|
||||||
|
$invalidCounts = [0, 6, -1, 1.5, 2.0, '1.5', '2.0', 'abc', '2bags', ' 2 ', true, false, null, '', [], [2]];
|
||||||
|
foreach (['dosage_bag_count', 'aux_dosage_bag_count'] as $field) {
|
||||||
|
foreach ([1, 5, '2'] as $count) {
|
||||||
|
$expect($validate($params(1) + [$field => $count]), 'Valid integer bag count must pass the actual scene');
|
||||||
|
}
|
||||||
|
foreach ($invalidCounts as $count) {
|
||||||
|
$expect(!$validate($params(1) + [$field => $count]), 'Supplied invalid bag count must fail the actual scene');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ([['id' => 0], ['times_per_day' => 'invalid'], ['usage_days' => 0], ['medication_days' => 1000],
|
||||||
|
['aux_times_per_day' => 7], ['aux_usage_days' => 0]] as $invalidField) {
|
||||||
|
$expect(!$validate(array_replace($params(1), $invalidField)), 'Existing usage rules must execute through the corrected scene');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist through the public transaction wrapper, then read the real JSON column and model back.
|
||||||
|
foreach ([1, 5, '3'] as $count) {
|
||||||
|
$id = $fixture();
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => $count], 1, $admin),
|
||||||
|
'Valid auxiliary bag count must save');
|
||||||
|
$expectedAux = array_replace($auxSeed, ['times_per_day' => 2, 'usage_days' => 6, 'dosage_bag_count' => (int) $count]);
|
||||||
|
$savedAux = json_decode($rxRow($id)['aux_usage'], true);
|
||||||
|
$modelAux = $rxModel($id)->aux_usage;
|
||||||
|
$expect($savedAux == $expectedAux && $modelAux == $expectedAux
|
||||||
|
&& $savedAux['dosage_bag_count'] === (int) $count && $modelAux['dosage_bag_count'] === (int) $count,
|
||||||
|
'Saved bag count must round-trip as an integer while preserving all other auxiliary JSON keys');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->dosage_amount === 5.0,
|
||||||
|
'Auxiliary edits must preserve main dosage and bag count');
|
||||||
|
$expect((int) $rxRow($id)['times_per_day'] === 3 && (int) $rxRow($id)['usage_days'] === 9
|
||||||
|
&& (int) $orderRow($id)['medication_days'] === 8, 'Existing main and order usage updates must remain intact');
|
||||||
|
$log = $logs($id);
|
||||||
|
$expect(count($log) === 1 && $log[0]['action'] === 'patch_rx_usage'
|
||||||
|
&& ((int) $count === 1 ? !str_contains($log[0]['summary'], '辅方袋数')
|
||||||
|
: str_contains($log[0]['summary'], '辅方袋数 1袋 → ' . (int) $count . '袋'))
|
||||||
|
&& (int) $log[0]['admin_id'] === 1 && $log[0]['admin_name'] === $admin['name']
|
||||||
|
&& (int) $log[0]['create_time'] > 0, 'Usage log must include changed auxiliary bag count, operator and time');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([1, 5, '3'] as $count) {
|
||||||
|
$id = $fixture();
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => $count], 1, $admin),
|
||||||
|
'Valid main bag count must save');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === (int) $count
|
||||||
|
&& $rxModel($id)->aux_usage['dosage_bag_count'] === 1 && $rxModel($id)->dosage_amount === 5.0,
|
||||||
|
'Main bag edits must round-trip independently without altering auxiliary bags or dosage');
|
||||||
|
$expect(str_contains($logs($id)[0]['summary'], '主方袋数 2袋 → ' . (int) $count . '袋')
|
||||||
|
&& !str_contains($logs($id)[0]['summary'], '辅方袋数'), 'Main-only bag changes must be logged under the main formula');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $fixture();
|
||||||
|
$allChanges = array_replace($params($id), ['times_per_day' => 4, 'dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5]);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($allChanges, 1, $admin), 'Both formula bag counts must save together');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === 4 && $rxModel($id)->aux_usage['dosage_bag_count'] === 5,
|
||||||
|
'Simultaneous main and auxiliary bag edits must remain independent');
|
||||||
|
$summary = $logs($id)[0]['summary'];
|
||||||
|
foreach (['主方每天次数 3次 → 4次', '主方开立天数 7天 → 9天', '主方袋数 2袋 → 4袋',
|
||||||
|
'辅方每天次数 3次 → 2次', '辅方开立天数 7天 → 6天', '辅方袋数 1袋 → 5袋', '订单服用天数 7天 → 8天'] as $change) {
|
||||||
|
$expect(str_contains($summary, $change), 'Every changed usage field must include its old and new value in the log');
|
||||||
|
}
|
||||||
|
$expect(count(explode(';', $summary)) === 7, 'The combined usage log must contain every change exactly once');
|
||||||
|
|
||||||
|
$id = $fixture();
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($unchangedParams($id) + ['dosage_bag_count' => 2, 'aux_dosage_bag_count' => 1], 1, $admin)
|
||||||
|
&& $snapshot($id) === $before, 'An unchanged request must not rewrite rows or create an empty operation log');
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($unchangedParams($id) + ['dosage_bag_count' => 5], 1, $admin)
|
||||||
|
&& $logs($id)[0]['summary'] === '服用参数:主方袋数 2袋 → 5袋', 'A bag-only edit must log only that actual change');
|
||||||
|
|
||||||
|
$id = $fixture(['aux_usage' => json_encode(array_replace($auxSeed, ['dosage_bag_count' => 4]), JSON_UNESCAPED_UNICODE)]);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id), 1, $admin), 'Legacy usage request must still save');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->aux_usage['dosage_bag_count'] === 4
|
||||||
|
&& !str_contains($logs($id)[0]['summary'], '袋数'), 'Omission must preserve both previous bag counts without reporting a bag edit');
|
||||||
|
$missingCountAux = $auxSeed;
|
||||||
|
unset($missingCountAux['dosage_bag_count']);
|
||||||
|
$id = $fixture(['aux_usage' => json_encode($missingCountAux, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id), 1, $admin)
|
||||||
|
&& !array_key_exists('dosage_bag_count', $rxModel($id)->aux_usage), 'Omission must not insert a default bag count into legacy JSON');
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => 2], 1, $admin)
|
||||||
|
&& str_contains($logs($id)[1]['summary'], '辅方袋数 1袋 → 2袋'), 'First explicit bag edit must log the displayed legacy default');
|
||||||
|
|
||||||
|
foreach (['饮片', '散剂'] as $type) {
|
||||||
|
$id = $fixture(['prescription_type' => $type]);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 5, 'aux_dosage_bag_count' => 5], 1, $admin),
|
||||||
|
'Other prescription types must retain their existing usage-edit behavior');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->aux_usage['dosage_bag_count'] === 1
|
||||||
|
&& !str_contains($logs($id)[0]['summary'], '袋数'), 'Other prescription types must ignore both bag edits');
|
||||||
|
}
|
||||||
|
foreach ([json_encode($auxSeed, JSON_UNESCAPED_UNICODE), null] as $oldAux) {
|
||||||
|
$id = $fixture(['herbs' => json_encode([['formula_type' => '主方']], JSON_UNESCAPED_UNICODE), 'aux_usage' => $oldAux]);
|
||||||
|
$beforeAux = $rxRow($id)['aux_usage'];
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 5, 'aux_dosage_bag_count' => 5], 1, $admin),
|
||||||
|
'Main-only prescription must still support its original usage changes');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === 5 && $rxRow($id)['aux_usage'] === $beforeAux
|
||||||
|
&& !str_contains($logs($id)[0]['summary'], '辅方袋数'), 'A main-only prescription may edit main bags without changing stale auxiliary JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic callers must receive the same strict rejection even without a controller/validator.
|
||||||
|
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
|
||||||
|
foreach ($invalidCounts as $count) {
|
||||||
|
$id = $fixture();
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + [$field => $count], 1, $admin) === false
|
||||||
|
&& PrescriptionOrderLogic::getError() === $label . '袋数须为 1~5 的整数' && $snapshot($id) === $before,
|
||||||
|
'Invalid direct logic bag input must leave prescription, order and logs unchanged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['times_per_day' => 6, 'usage_days' => 999, 'medication_days' => 999, 'aux_times_per_day' => 6, 'aux_usage_days' => 999] as $field => $max) {
|
||||||
|
foreach ([0, $max + 1, 1.5, '2invalid', true] as $invalid) {
|
||||||
|
$id = $fixture();
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage(array_replace($params($id), [$field => $invalid]), 1, $admin) === false
|
||||||
|
&& $snapshot($id) === $before, 'Invalid direct logic usage input must not be truncated or persisted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reject = static function (int $id, int $adminId, array $actor, string $error) use ($params, $snapshot, $expect): void {
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => 5], $adminId, $actor) === false
|
||||||
|
&& str_contains(PrescriptionOrderLogic::getError(), $error) && $snapshot($id) === $before,
|
||||||
|
'Rejected usage mutation must preserve all persisted state: ' . $error);
|
||||||
|
};
|
||||||
|
$outsider = ['root' => 0, 'admin_id' => 2, 'role_id' => [], 'permissions' => [], 'name' => '无权限测试账号'];
|
||||||
|
$reject($fixture(), 2, $outsider, '无权限操作');
|
||||||
|
$reject($fixture([], ['creator_id' => 2]), 2, $outsider, '无权限修改此处方');
|
||||||
|
$reject($fixture([], ['fulfillment_status' => 4, 'ej_pharmacy_order_no' => 'EJ-TEST']), 2, $outsider, '无权限操作');
|
||||||
|
$reject($fixture(['delete_time' => time()]), 1, $admin, '处方不存在');
|
||||||
|
|
||||||
|
$allow = static function (int $id) use ($params, $rxModel, $orderRow, $logs, $expect, $admin): void {
|
||||||
|
$beforeOrder = $orderRow($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin),
|
||||||
|
'Usage edits must be allowed regardless of fulfillment or pharmacy submission status');
|
||||||
|
$expect($rxModel($id)->dosage_bag_count === 4 && $rxModel($id)->aux_usage['dosage_bag_count'] === 5
|
||||||
|
&& (int) $orderRow($id)['medication_days'] === 8 && count($logs($id)) === 1,
|
||||||
|
'All-status edit must persist independent main/auxiliary bags, days and its operation log');
|
||||||
|
$afterOrder = $orderRow($id);
|
||||||
|
unset($beforeOrder['medication_days'], $beforeOrder['update_time'], $afterOrder['medication_days'], $afterOrder['update_time']);
|
||||||
|
$expect($afterOrder === $beforeOrder, 'Usage edits must preserve fulfillment status and remote pharmacy identity');
|
||||||
|
};
|
||||||
|
foreach ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $status) {
|
||||||
|
$allow($fixture([], ['fulfillment_status' => $status]));
|
||||||
|
}
|
||||||
|
foreach ([['gancao_reciperl_order_no' => 'GC-TEST'], ['ej_pharmacy_order_no' => 'EJ-TEST'],
|
||||||
|
['gancao_submit_time' => time()], ['ej_pharmacy_submit_time' => time()]] as $remoteField) {
|
||||||
|
$id = $fixture([], $remoteField);
|
||||||
|
$allow($id);
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionPatient($id, '测试姓名', '13000000000', 1, $admin) === false
|
||||||
|
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||||
|
'Opening usage edits must not open patient snapshot edits after submission');
|
||||||
|
$expect(PrescriptionOrderLogic::edit(['id' => $id], 1, $admin) === false
|
||||||
|
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||||
|
'Opening usage edits must not open general order edits after submission');
|
||||||
|
$expect(PrescriptionOrderLogic::setShipMode($id, 'direct', 1, $admin) === false
|
||||||
|
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||||
|
'Opening usage edits must not open pharmacy selection after submission');
|
||||||
|
}
|
||||||
|
foreach (['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'] as $status) {
|
||||||
|
$id = $fixture();
|
||||||
|
Db::name('pharmacy_submission_claim')->insert(['prescription_order_id' => $id, 'source_revision' => 1, 'status' => $status]);
|
||||||
|
$allow($id);
|
||||||
|
$expect(Db::name('pharmacy_submission_claim')->where('prescription_order_id', $id)->value('status') === $status,
|
||||||
|
'Usage edits must leave the existing pharmacy submission claim untouched');
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionPatient($id, '测试姓名', '13000000000', 1, $admin) === false
|
||||||
|
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||||
|
'A pharmacy submission claim must still lock patient edits');
|
||||||
|
}
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params(999999) + ['aux_dosage_bag_count' => 5], 1, $admin) === false
|
||||||
|
&& PrescriptionOrderLogic::getError() === '订单不存在', 'Missing order must retain the existing rejection');
|
||||||
|
|
||||||
|
// Force failure after the prescription save to prove the shared order transaction rolls it back.
|
||||||
|
$id = $fixture();
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$pdo->exec("CREATE TRIGGER reject_aux_bag_order BEFORE UPDATE ON zyt_tcm_prescription_order
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced auxiliary bag order failure'");
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin) === false
|
||||||
|
&& $snapshot($id) === $before, 'Order save failure must roll back the earlier prescription JSON update');
|
||||||
|
$pdo->exec('DROP TRIGGER reject_aux_bag_order');
|
||||||
|
|
||||||
|
$id = $fixture([], ['fulfillment_status' => 4, 'ej_pharmacy_order_no' => 'EJ-TEST']);
|
||||||
|
$before = $snapshot($id);
|
||||||
|
$pdo->exec("CREATE TRIGGER reject_usage_log BEFORE INSERT ON zyt_tcm_prescription_order_log
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced usage log failure'");
|
||||||
|
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin) === false
|
||||||
|
&& PrescriptionOrderLogic::getError() === '操作日志写入失败,变更未保存' && $snapshot($id) === $before,
|
||||||
|
'Mandatory log failure must roll back main bags, auxiliary JSON, days and all timestamps, even on cancelled/submitted orders');
|
||||||
|
$pdo->exec('DROP TRIGGER reject_usage_log');
|
||||||
|
|
||||||
|
echo "PrescriptionOrderAuxBagCountTest: {$checks} assertions passed\n";
|
||||||
|
} finally {
|
||||||
|
$manager->connect()->close();
|
||||||
|
$pdo->exec("DROP DATABASE `{$database}`");
|
||||||
|
}
|
||||||