Merge branch 'master-10-146'

This commit is contained in:
Your Name
2026-10-17 16:52:17 +08:00
41 changed files with 2851 additions and 96 deletions
@@ -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>
}
+2
View File
@@ -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, [])
})
}
+2
View File
@@ -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',
+6 -3
View File
@@ -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)
+230 -4
View File
@@ -23,7 +23,7 @@ from doctor_workstation.ui.pages import prescriptions as page_module
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]: 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/`,不属于业务交付。
![本地模拟配置页](./prescription-ai-visibility-admin-local.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

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

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -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;
+130 -3
View File
@@ -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}`");
}