更新
@@ -0,0 +1,49 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface PrescriptionAiVisibilityConfig {
|
||||
role_ids: number[]
|
||||
admin_ids: number[]
|
||||
}
|
||||
|
||||
export interface PrescriptionAiVisibilityOptions {
|
||||
roles: { id: number; name: string }[]
|
||||
admins: { id: number; name: string; account: string }[]
|
||||
}
|
||||
|
||||
export interface PrescriptionAiCapabilities {
|
||||
agreement_visible: boolean
|
||||
can_manage_visibility: boolean
|
||||
}
|
||||
|
||||
// A failed or malformed read must never become an empty policy that can be saved.
|
||||
function readVisibilityConfig(value: unknown): PrescriptionAiVisibilityConfig {
|
||||
const config = value as Partial<PrescriptionAiVisibilityConfig> | null
|
||||
const validIds = (ids: unknown): ids is number[] =>
|
||||
Array.isArray(ids) && ids.every((id) => Number.isSafeInteger(id) && id > 0)
|
||||
if (!config || !validIds(config.role_ids) || !validIds(config.admin_ids)) {
|
||||
throw new Error('可见范围配置响应格式不正确')
|
||||
}
|
||||
return { role_ids: [...config.role_ids], admin_ids: [...config.admin_ids] }
|
||||
}
|
||||
|
||||
export async function getPrescriptionAiVisibilityConfig() {
|
||||
const data = await request.get({ url: '/tcm.prescriptionAi/getVisibilityConfig' })
|
||||
return readVisibilityConfig(data)
|
||||
}
|
||||
|
||||
export async function setPrescriptionAiVisibilityConfig(params: PrescriptionAiVisibilityConfig) {
|
||||
const data = await request.post({ url: '/tcm.prescriptionAi/setVisibilityConfig', params })
|
||||
return readVisibilityConfig(data)
|
||||
}
|
||||
|
||||
export function getPrescriptionAiVisibilityOptions() {
|
||||
return request.get({
|
||||
url: '/tcm.prescriptionAi/visibilityOptions'
|
||||
}) as Promise<PrescriptionAiVisibilityOptions>
|
||||
}
|
||||
|
||||
export function getPrescriptionAiCapabilities() {
|
||||
return request.get({
|
||||
url: '/tcm.prescriptionAi/capabilities'
|
||||
}) as Promise<PrescriptionAiCapabilities>
|
||||
}
|
||||
@@ -490,8 +490,10 @@ export function prescriptionOrderPatchPrescriptionUsage(params: {
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
dosage_bag_count?: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
aux_dosage_bag_count?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||
}
|
||||
|
||||
@@ -312,6 +312,17 @@
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -325,6 +336,17 @@
|
||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -943,6 +965,15 @@
|
||||
label-width="108px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
@@ -968,6 +999,15 @@
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="aux_dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
@@ -1432,10 +1472,12 @@ const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
dosage_bag_count: 1 as number | undefined,
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
aux_dosage_bag_count: 1 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
@@ -1444,9 +1486,15 @@ const patchUsageRules = computed<FormRules>(() => {
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
})
|
||||
@@ -1459,6 +1507,7 @@ function openPatchUsageDialog() {
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
@@ -1468,16 +1517,20 @@ function openPatchUsageDialog() {
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.aux_dosage_bag_count =
|
||||
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.dosage_bag_count = 1
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.aux_dosage_bag_count = 1
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
@@ -1498,17 +1551,25 @@ async function submitPatchUsage() {
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
dosage_bag_count?: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
aux_dosage_bag_count?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
|
||||
}
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
@@ -1763,7 +1824,7 @@ async function refresh() {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
if (props.loadRelatedData) void fetchLogs(id)
|
||||
if (props.loadRelatedData) await fetchLogs(id)
|
||||
const diagId = d.diagnosis_id
|
||||
if (props.loadRelatedData && diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||
|
||||
@@ -883,6 +883,17 @@
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -896,6 +907,17 @@
|
||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -2261,6 +2283,15 @@
|
||||
label-width="96px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
@@ -2286,6 +2317,15 @@
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="aux_dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
@@ -3954,10 +3994,12 @@ const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
dosage_bag_count: 1 as number | undefined,
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
aux_dosage_bag_count: 1 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
@@ -3966,9 +4008,15 @@ const patchUsageRules = computed<FormRules>(() => {
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
})
|
||||
@@ -3981,6 +4029,7 @@ function openPatchUsageDialog() {
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
@@ -3990,16 +4039,20 @@ function openPatchUsageDialog() {
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.aux_dosage_bag_count =
|
||||
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.dosage_bag_count = 1
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.aux_dosage_bag_count = 1
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
@@ -4020,17 +4073,25 @@ async function submitPatchUsage() {
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
dosage_bag_count?: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
aux_dosage_bag_count?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
|
||||
}
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="prescription-ai-visibility">
|
||||
<el-card v-loading="loading" shadow="never" class="!border-none">
|
||||
<div class="text-xl font-medium mb-[20px]">处方 AI 一致度可见范围</div>
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-5">
|
||||
<template #title>指定用户组或用户可查看一致度</template>
|
||||
<div class="leading-6">
|
||||
用户命中任一指定系统角色,或被单独指定,即可查看桌面端“与 AI
|
||||
一致度”、逐味对照和一致度统计。AI
|
||||
报告继续按原有权限开放,已有报告不受影响,患者与处方的数据范围保持原有规则。
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-if="loadError"
|
||||
:title="loadError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
/>
|
||||
<div v-if="loading" role="status" class="mb-5">正在读取当前可见范围…</div>
|
||||
|
||||
<el-form
|
||||
v-if="hasLoaded"
|
||||
class="ls-form"
|
||||
:model="formData"
|
||||
:disabled="!ready || loading || saving"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item label="指定用户组" prop="role_ids">
|
||||
<div class="w-full max-w-[640px]">
|
||||
<el-select
|
||||
v-model="formData.role_ids"
|
||||
class="w-full"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择系统角色,可搜索角色名称"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.id"
|
||||
:label="role.name"
|
||||
:value="role.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tips">账号拥有任一指定角色即可命中。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="指定用户" prop="admin_ids">
|
||||
<div class="w-full max-w-[640px]">
|
||||
<el-select
|
||||
v-model="formData.admin_ids"
|
||||
class="w-full"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择用户,可搜索姓名或账号"
|
||||
>
|
||||
<el-option
|
||||
v-for="admin in adminOptions"
|
||||
:key="admin.id"
|
||||
:label="admin.label"
|
||||
:value="admin.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tips">
|
||||
单独指定的用户无需同时命中用户组。当前不在可选列表中的已配置 ID
|
||||
会保留,移除后保存才会取消该项授权。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效规则">
|
||||
<div>
|
||||
<div class="leading-6">指定用户组与指定用户按“或”生效。</div>
|
||||
<div class="form-tips">
|
||||
两项均为空时,所有账号(包括超级管理员)均隐藏一致度、对照与统计。
|
||||
拥有本页管理权限不自动获得一致度查看权,超级管理员也需命中上述名单。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="ready && !formData.role_ids.length && !formData.admin_ids.length"
|
||||
title="当前选择为空:保存后所有账号将隐藏一致度、对照与统计,AI 报告仍保留。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="saveError"
|
||||
:title="saveError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
/>
|
||||
<el-button :disabled="loading || saving" @click="loadConfig">
|
||||
{{ loadError ? '重试加载' : '重新加载' }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
|
||||
<footer-btns v-if="canManage">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!ready || loading || saving"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ saving ? '正在保存' : '保存' }}
|
||||
</el-button>
|
||||
</footer-btns>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="prescriptionAiVisibility">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
PrescriptionAiVisibilityConfig,
|
||||
PrescriptionAiVisibilityOptions
|
||||
} from '@/api/setting/prescription_ai_visibility'
|
||||
import {
|
||||
getPrescriptionAiCapabilities,
|
||||
getPrescriptionAiVisibilityConfig,
|
||||
getPrescriptionAiVisibilityOptions,
|
||||
setPrescriptionAiVisibilityConfig
|
||||
} from '@/api/setting/prescription_ai_visibility'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const ready = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const canManage = ref(false)
|
||||
const loadError = ref('')
|
||||
const saveError = ref('')
|
||||
const formData = reactive<PrescriptionAiVisibilityConfig>({ role_ids: [], admin_ids: [] })
|
||||
const optionsData = reactive<PrescriptionAiVisibilityOptions>({ roles: [], admins: [] })
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const options = new Map(optionsData.roles.map((role) => [role.id, role]))
|
||||
for (const id of formData.role_ids) {
|
||||
if (!options.has(id)) {
|
||||
options.set(id, { id, name: `已配置角色 #${id}(当前不在可选列表)` })
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
})
|
||||
|
||||
const adminOptions = computed(() => {
|
||||
const options = new Map(
|
||||
optionsData.admins.map((admin) => [
|
||||
admin.id,
|
||||
{ id: admin.id, label: `${admin.name || `用户 #${admin.id}`}(${admin.account})` }
|
||||
])
|
||||
)
|
||||
for (const id of formData.admin_ids) {
|
||||
if (!options.has(id)) {
|
||||
options.set(id, { id, label: `已配置用户 #${id}(当前不在可选列表)` })
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
})
|
||||
|
||||
const assignConfig = (config: PrescriptionAiVisibilityConfig) => {
|
||||
formData.role_ids = [...config.role_ids]
|
||||
formData.admin_ids = [...config.admin_ids]
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (loading.value || saving.value) return
|
||||
loading.value = true
|
||||
ready.value = false
|
||||
canManage.value = false
|
||||
loadError.value = ''
|
||||
saveError.value = ''
|
||||
try {
|
||||
const capabilities = await getPrescriptionAiCapabilities()
|
||||
if (capabilities.can_manage_visibility !== true) {
|
||||
loadError.value = '当前账号无权管理处方 AI 一致度可见范围。'
|
||||
return
|
||||
}
|
||||
canManage.value = true
|
||||
const [config, options] = await Promise.all([
|
||||
getPrescriptionAiVisibilityConfig(),
|
||||
getPrescriptionAiVisibilityOptions()
|
||||
])
|
||||
if (!Array.isArray(options?.roles) || !Array.isArray(options?.admins)) {
|
||||
throw new Error('可选用户组或用户响应格式不正确')
|
||||
}
|
||||
// Apply both responses together; an incomplete load cannot overwrite the current policy.
|
||||
assignConfig(config)
|
||||
optionsData.roles = options.roles
|
||||
optionsData.admins = options.admins
|
||||
hasLoaded.value = true
|
||||
ready.value = true
|
||||
} catch {
|
||||
loadError.value = '可见范围加载失败,尚未读取完整配置。请重试加载后再保存。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!ready.value || !canManage.value || loading.value || saving.value) return
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
try {
|
||||
const config = await setPrescriptionAiVisibilityConfig({
|
||||
role_ids: [...formData.role_ids],
|
||||
admin_ids: [...formData.admin_ids]
|
||||
})
|
||||
assignConfig(config)
|
||||
feedback.msgSuccess('可见范围已保存')
|
||||
} catch {
|
||||
saveError.value = '保存结果未确认,当前选择已保留。请重试保存,或重新加载以确认服务端策略。'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void loadConfig()
|
||||
</script>
|
||||
@@ -0,0 +1,253 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const apiPath = path.join(root, 'src/api/setting/prescription_ai_visibility.ts')
|
||||
const pagePath = path.join(root, 'src/views/setting/prescription_ai_visibility/index.vue')
|
||||
const { descriptor, errors } = parse(fs.readFileSync(pagePath, 'utf8'), { filename: pagePath })
|
||||
assert.deepEqual(errors, [])
|
||||
const script = compileScript(descriptor, { id: 'prescription-ai-visibility' })
|
||||
|
||||
function moduleFrom(source, dependencies) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
esModuleInterop: true
|
||||
}
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const makeApi = (request) =>
|
||||
moduleFrom(fs.readFileSync(apiPath, 'utf8'), (name) => {
|
||||
assert.equal(name, '@/utils/request')
|
||||
return request
|
||||
})
|
||||
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
||||
const deferred = () => {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePage(overrides = {}) {
|
||||
const saved = []
|
||||
const messages = []
|
||||
const calls = []
|
||||
const api = {
|
||||
getPrescriptionAiCapabilities: async () => {
|
||||
calls.push('capabilities')
|
||||
return { agreement_visible: false, can_manage_visibility: true }
|
||||
},
|
||||
getPrescriptionAiVisibilityConfig: async () => {
|
||||
calls.push('config')
|
||||
return { role_ids: [2, 90], admin_ids: [3, 91] }
|
||||
},
|
||||
getPrescriptionAiVisibilityOptions: async () => {
|
||||
calls.push('options')
|
||||
return {
|
||||
roles: [{ id: 2, name: '医生' }],
|
||||
admins: [{ id: 3, name: '张医生', account: 'zhang.doctor' }]
|
||||
}
|
||||
},
|
||||
setPrescriptionAiVisibilityConfig: async (params) => {
|
||||
saved.push(params)
|
||||
return params
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
const component = moduleFrom(script.content, (name) => {
|
||||
if (name === 'vue') return vue
|
||||
if (name === '@/api/setting/prescription_ai_visibility') return api
|
||||
if (name === '@/utils/feedback') {
|
||||
return { msgSuccess: (message) => messages.push(message) }
|
||||
}
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
}).default
|
||||
const page = component.setup({}, { expose() {} })
|
||||
return { page, api, saved, messages, calls }
|
||||
}
|
||||
|
||||
test('API uses the dedicated routes and rejects malformed policy responses', async () => {
|
||||
const calls = []
|
||||
let response = { role_ids: [2], admin_ids: [3] }
|
||||
const api = makeApi({
|
||||
get: async (params) => {
|
||||
calls.push(['get', params])
|
||||
return response
|
||||
},
|
||||
post: async (params) => {
|
||||
calls.push(['post', params])
|
||||
return response
|
||||
}
|
||||
})
|
||||
const data = await api.getPrescriptionAiVisibilityConfig()
|
||||
data.role_ids.push(5)
|
||||
assert.deepEqual(response.role_ids, [2])
|
||||
await api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] })
|
||||
await api.getPrescriptionAiVisibilityOptions()
|
||||
await api.getPrescriptionAiCapabilities()
|
||||
assert.deepEqual(calls, [
|
||||
['get', { url: '/tcm.prescriptionAi/getVisibilityConfig' }],
|
||||
[
|
||||
'post',
|
||||
{
|
||||
url: '/tcm.prescriptionAi/setVisibilityConfig',
|
||||
params: { role_ids: [], admin_ids: [] }
|
||||
}
|
||||
],
|
||||
['get', { url: '/tcm.prescriptionAi/visibilityOptions' }],
|
||||
['get', { url: '/tcm.prescriptionAi/capabilities' }]
|
||||
])
|
||||
for (response of [
|
||||
null,
|
||||
{},
|
||||
{ role_ids: [], admin_ids: ['3'] },
|
||||
{ role_ids: [-1], admin_ids: [] }
|
||||
]) {
|
||||
await assert.rejects(api.getPrescriptionAiVisibilityConfig(), /响应格式不正确/)
|
||||
await assert.rejects(
|
||||
api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] }),
|
||||
/响应格式不正确/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('initial loading is pending and cannot submit before the real policy is read', async () => {
|
||||
const pending = deferred()
|
||||
const { page, saved } = makePage({ getPrescriptionAiVisibilityConfig: () => pending.promise })
|
||||
assert.equal(page.loading.value, true)
|
||||
assert.equal(page.ready.value, false)
|
||||
assert.equal(page.hasLoaded.value, false)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
pending.reject(new Error('offline'))
|
||||
await tick()
|
||||
assert.equal(page.loading.value, false)
|
||||
assert.equal(page.ready.value, false)
|
||||
assert.match(page.loadError.value, /加载失败/)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
})
|
||||
|
||||
test('a failed reload cannot apply a partial policy or overwrite saved selections', async () => {
|
||||
const { page, api, saved } = makePage()
|
||||
await tick()
|
||||
api.getPrescriptionAiVisibilityConfig = async () => ({ role_ids: [], admin_ids: [] })
|
||||
api.getPrescriptionAiVisibilityOptions = async () => {
|
||||
throw new Error('options failed')
|
||||
}
|
||||
await page.loadConfig()
|
||||
assert.deepEqual(page.formData.role_ids, [2, 90])
|
||||
assert.deepEqual(page.formData.admin_ids, [3, 91])
|
||||
assert.equal(page.hasLoaded.value, true)
|
||||
assert.equal(page.ready.value, false)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
api.getPrescriptionAiVisibilityOptions = async () => ({ roles: [], admins: [] })
|
||||
await page.loadConfig()
|
||||
assert.equal(page.ready.value, true)
|
||||
assert.equal(page.loadError.value, '')
|
||||
assert.deepEqual(page.formData.role_ids, [])
|
||||
})
|
||||
|
||||
test('missing selected IDs stay visible and user labels support both name and account search', async () => {
|
||||
const { page, saved } = makePage()
|
||||
await tick()
|
||||
assert.equal(page.canManage.value, true)
|
||||
assert.equal(page.ready.value, true)
|
||||
assert.deepEqual(
|
||||
page.roleOptions.value.map((row) => row.id),
|
||||
[2, 90]
|
||||
)
|
||||
assert.match(page.roleOptions.value[1].name, /#90.*当前不在可选列表/)
|
||||
assert.deepEqual(
|
||||
page.adminOptions.value.map((row) => row.id),
|
||||
[3, 91]
|
||||
)
|
||||
assert.match(page.adminOptions.value[0].label, /张医生.*zhang\.doctor/)
|
||||
assert.match(page.adminOptions.value[1].label, /#91.*当前不在可选列表/)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [{ role_ids: [2, 90], admin_ids: [3, 91] }])
|
||||
})
|
||||
|
||||
test('explicitly clearing both selectors saves the empty policy and applies normalized response', async () => {
|
||||
const { page, saved, messages, api } = makePage()
|
||||
await tick()
|
||||
page.formData.role_ids = []
|
||||
page.formData.admin_ids = []
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [{ role_ids: [], admin_ids: [] }])
|
||||
assert.equal(messages.length, 1)
|
||||
page.formData.role_ids = [90, 2, 90]
|
||||
api.setPrescriptionAiVisibilityConfig = async () => ({ role_ids: [2, 90], admin_ids: [] })
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(page.formData.role_ids, [2, 90])
|
||||
assert.equal(page.saving.value, false)
|
||||
})
|
||||
|
||||
test('pending save blocks duplicate writes and reload; failed write keeps the user selection', async () => {
|
||||
const pending = deferred()
|
||||
const saved = []
|
||||
const { page, calls } = makePage({
|
||||
setPrescriptionAiVisibilityConfig: (params) => {
|
||||
saved.push(params)
|
||||
return pending.promise
|
||||
}
|
||||
})
|
||||
await tick()
|
||||
page.formData.role_ids = [90]
|
||||
const first = page.handleSubmit()
|
||||
assert.equal(page.saving.value, true)
|
||||
await page.handleSubmit()
|
||||
await page.loadConfig()
|
||||
assert.equal(saved.length, 1)
|
||||
assert.equal(calls.filter((name) => name === 'capabilities').length, 1)
|
||||
pending.reject(new Error('offline'))
|
||||
await first
|
||||
assert.equal(page.saving.value, false)
|
||||
assert.equal(page.ready.value, true)
|
||||
assert.deepEqual(page.formData.role_ids, [90])
|
||||
assert.deepEqual(page.formData.admin_ids, [3, 91])
|
||||
assert.match(page.saveError.value, /当前选择已保留/)
|
||||
})
|
||||
|
||||
test('management capability is independent from viewing and denies policy reads when absent', async () => {
|
||||
const { page, calls, saved } = makePage({
|
||||
getPrescriptionAiCapabilities: async () => ({
|
||||
agreement_visible: true,
|
||||
can_manage_visibility: false
|
||||
})
|
||||
})
|
||||
await tick()
|
||||
assert.deepEqual(calls, [])
|
||||
assert.equal(page.canManage.value, false)
|
||||
assert.equal(page.hasLoaded.value, false)
|
||||
assert.equal(page.ready.value, false)
|
||||
assert.match(page.loadError.value, /无权管理/)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
})
|
||||
|
||||
test('actual settings template compiles with its script bindings', () => {
|
||||
const compiled = compileTemplate({
|
||||
source: descriptor.template.content,
|
||||
filename: pagePath,
|
||||
id: 'prescription-ai-visibility',
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
|
||||
const selected = [
|
||||
'patchUsageVisible', 'patchUsageSaving', 'patchUsageFormRef', 'patchUsageForm',
|
||||
'patchUsageRules', 'openPatchUsageDialog', 'resetPatchUsageForm', 'submitPatchUsage'
|
||||
]
|
||||
|
||||
for (const page of ['components/PrescriptionOrderDetailDrawer.vue', 'order_list_h5.vue']) {
|
||||
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
|
||||
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
assert.deepEqual(errors, [])
|
||||
const script = compileScript(descriptor, { id: page })
|
||||
const template = compileTemplate({
|
||||
source: descriptor.template.content, filename, id: page,
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(template.errors, [])
|
||||
|
||||
// Run the real handlers; replace the network and the rest of the order page.
|
||||
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
|
||||
const declarations = new Map()
|
||||
for (const node of ast.statements) {
|
||||
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const executed = page.startsWith('components/') ? [...selected, 'refresh'] : selected
|
||||
for (const name of executed) assert.ok(declarations.has(name), `${page}: missing ${name}`)
|
||||
const compiled = ts.transpileModule(executed.map(name => declarations.get(name)).join('\n'), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
|
||||
function instance({ hasAux = true, prescriptionType = '浓缩水丸', bagCount = 2, mainBagCount = 2, status = 2, fail = false } = {}) {
|
||||
const calls = []
|
||||
const events = []
|
||||
const aux = { times_per_day: 3, usage_days: 10, dosage_bag_count: bagCount }
|
||||
const detailPrescription = vue.ref({ prescription_type: prescriptionType, times_per_day: 3, usage_days: 10, dosage_bag_count: mainBagCount })
|
||||
const detailAuxUsage = vue.ref(hasAux ? aux : null)
|
||||
const detailData = vue.ref({
|
||||
id: 42, prescription_id: 17, medication_days: 10, fulfillment_status: status,
|
||||
gancao_reciperl_order_no: 'TEST-SUBMITTED', ej_pharmacy_order_no: 'TEST-EJ-SUBMITTED'
|
||||
})
|
||||
const refresh = async () => {
|
||||
if (calls[0]?.dosage_bag_count) detailPrescription.value.dosage_bag_count = calls[0].dosage_bag_count
|
||||
if (calls[0]?.aux_dosage_bag_count) detailAuxUsage.value.dosage_bag_count = calls[0].aux_dosage_bag_count
|
||||
events.push('refresh')
|
||||
return { ...detailData.value }
|
||||
}
|
||||
const globals = {
|
||||
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, nextTick: vue.nextTick,
|
||||
detailPrescription, detailAuxUsage, detailData, detailHasAuxHerbs: vue.ref(hasAux),
|
||||
feedback: { msgWarning() {}, msgSuccess() {} },
|
||||
prescriptionOrderPatchPrescriptionUsage: async payload => {
|
||||
calls.push(payload)
|
||||
if (fail) throw new Error('Save failed')
|
||||
},
|
||||
requestDetail: refresh, refreshCurrentPrescriptionOrderDetail: refresh,
|
||||
props: { loadRelatedData: true },
|
||||
emit: event => events.push(event),
|
||||
fetchLogs: async () => {
|
||||
events.push('logs:start')
|
||||
await Promise.resolve()
|
||||
events.push('logs:done')
|
||||
},
|
||||
getLists: () => events.push('lists')
|
||||
}
|
||||
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
|
||||
state.patchUsageFormRef.value = { validate: async () => {}, clearValidate() {} }
|
||||
return { state, calls, events, detailAuxUsage, detailPrescription }
|
||||
}
|
||||
|
||||
test(`${page}: edits auxiliary bags and refreshes the persisted value without changing usage`, async () => {
|
||||
const { state, calls, events, detailAuxUsage } = instance()
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 2)
|
||||
state.patchUsageForm.aux_dosage_bag_count = 4
|
||||
await state.submitPatchUsage()
|
||||
assert.deepEqual(calls, [{
|
||||
id: 42, times_per_day: 3, usage_days: 10, medication_days: 10,
|
||||
dosage_bag_count: 2,
|
||||
aux_times_per_day: 3, aux_usage_days: 10, aux_dosage_bag_count: 4
|
||||
}])
|
||||
assert.equal(state.patchUsageVisible.value, false)
|
||||
assert.equal(detailAuxUsage.value.dosage_bag_count, 4)
|
||||
assert.ok(events.includes('refresh'))
|
||||
assert.ok(events.indexOf('logs:done') > events.indexOf('refresh'))
|
||||
const completedEvent = page.startsWith('components/') ? 'detail-changed' : 'lists'
|
||||
assert.ok(events.indexOf(completedEvent) > events.indexOf('logs:done'))
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 4)
|
||||
})
|
||||
|
||||
test(`${page}: missing historical bag count defaults to one and resets on close`, () => {
|
||||
const { state } = instance({ bagCount: null, mainBagCount: null })
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
|
||||
state.patchUsageForm.dosage_bag_count = 4
|
||||
state.patchUsageForm.aux_dosage_bag_count = 5
|
||||
state.resetPatchUsageForm()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
|
||||
})
|
||||
|
||||
test(`${page}: only water pills with an auxiliary formula submit or require auxiliary bags`, async () => {
|
||||
for (const options of [{ hasAux: false }, { prescriptionType: '饮片' }, { prescriptionType: '颗粒' }]) {
|
||||
const { state, calls } = instance(options)
|
||||
state.openPatchUsageDialog()
|
||||
state.patchUsageForm.aux_dosage_bag_count = 5
|
||||
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count, undefined)
|
||||
await state.submitPatchUsage()
|
||||
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
|
||||
assert.equal(Object.hasOwn(calls[0], 'dosage_bag_count'), !options.prescriptionType)
|
||||
}
|
||||
const { state } = instance()
|
||||
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count[0].required, true)
|
||||
assert.equal(state.patchUsageRules.value.dosage_bag_count[0].required, true)
|
||||
})
|
||||
|
||||
test(`${page}: main and auxiliary bags can be edited independently in every submitted order status`, async () => {
|
||||
for (let status = 1; status <= 12; status++) {
|
||||
const { state, calls, detailPrescription, detailAuxUsage } = instance({ status })
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 2)
|
||||
state.patchUsageForm.dosage_bag_count = 5
|
||||
state.patchUsageForm.aux_dosage_bag_count = 1
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(calls.length, 1, `status ${status}`)
|
||||
assert.equal(calls[0].dosage_bag_count, 5)
|
||||
assert.equal(calls[0].aux_dosage_bag_count, 1)
|
||||
assert.equal(detailPrescription.value.dosage_bag_count, 5)
|
||||
assert.equal(detailAuxUsage.value.dosage_bag_count, 1)
|
||||
}
|
||||
const { state, calls } = instance({ hasAux: false })
|
||||
state.openPatchUsageDialog()
|
||||
state.patchUsageForm.dosage_bag_count = 4
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(calls[0].dosage_bag_count, 4)
|
||||
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
|
||||
})
|
||||
|
||||
test(`${page}: validation blocks requests and a rejected save retains the selected bags`, async () => {
|
||||
const { state, calls, events } = instance({ fail: true })
|
||||
state.openPatchUsageDialog()
|
||||
state.patchUsageForm.aux_dosage_bag_count = 3
|
||||
state.patchUsageForm.dosage_bag_count = 4
|
||||
state.patchUsageFormRef.value.validate = async () => { throw new Error('Required') }
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(calls.length, 0)
|
||||
state.patchUsageFormRef.value.validate = async () => {}
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 3)
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 4)
|
||||
assert.equal(state.patchUsageVisible.value, true)
|
||||
assert.equal(state.patchUsageSaving.value, false)
|
||||
assert.deepEqual(events, [])
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
base: '/admin/',
|
||||
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
|
||||
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
|
||||
server: {
|
||||
port: 5555,
|
||||
host: '0.0.0.0',
|
||||
|
||||
@@ -89,7 +89,7 @@ def example_batch() -> dict[str, Any]:
|
||||
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||
"prompt_version": "manual-prescription-required-candidate-v4",
|
||||
}
|
||||
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391,
|
||||
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391, "agreement_visible": True,
|
||||
"prescription_revision": 1, "status": "success", "validity": "current",
|
||||
"comparison_type": "non_independent", "models": models, "coverage_status": "partial",
|
||||
"created_at": "2026-09-10 15:29:00", "cutoff_at": "2026-09-10 15:29:00",
|
||||
@@ -143,8 +143,11 @@ class PreviewRepository:
|
||||
def __init__(self) -> None:
|
||||
self.batch = example_batch()
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True, "can_manage_visibility": False}
|
||||
|
||||
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||
return {"enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
|
||||
return {"agreement_visible": True, "enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
|
||||
|
||||
def history(self) -> list[dict[str, Any]]:
|
||||
"""The current batch plus the earlier ones it supersedes, newest first."""
|
||||
@@ -170,7 +173,7 @@ class PreviewRepository:
|
||||
"algorithm_version": "prescription-soft-dice-v1.0.0"},
|
||||
"openai": {"comparison": {"status": "not_comparable"}}}},
|
||||
]
|
||||
return [self.batch, *older]
|
||||
return [{**row, "agreement_visible": True} for row in [self.batch, *older]]
|
||||
|
||||
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||
return deepcopy(self.batch)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Capture agreement authorization states using synthetic data and no network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from render_issued_prescription_ai import PreviewRepository
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import issued_prescription_ai as ai
|
||||
from doctor_workstation.ui.pages import prescriptions
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class VisibilityPreviewRepository(DemoDoctorRepository, PreviewRepository):
|
||||
def __init__(self) -> None:
|
||||
DemoDoctorRepository.__init__(self)
|
||||
PreviewRepository.__init__(self)
|
||||
self.visible = True
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": self.visible, "can_manage_visibility": True}
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
return {"enabled": True, "agreement_visible": self.visible, "items": [
|
||||
{"prescription_id": value, "agreement_visible": self.visible, "status": "success",
|
||||
"models": {"qwen": {"status": "success", "score": 68.5},
|
||||
"openai": {"status": "success", "score": 55.2}}} for value in ids]}
|
||||
|
||||
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||
rows = [{**row, "agreement_visible": self.visible} for row in self.history()]
|
||||
return {"agreement_visible": self.visible, "enabled": True, "lists": deepcopy(rows), "count": len(rows)}
|
||||
|
||||
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||
return {**deepcopy(self.batch), "agreement_visible": self.visible}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
output = Path(__file__).resolve().parents[2] / "artifacts" / "prescription-ai-visibility"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def immediate(function: Any, **callbacks: Any) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if callbacks.get("on_error"):
|
||||
callbacks["on_error"](error)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
if callbacks.get("on_success"):
|
||||
callbacks["on_success"](result)
|
||||
finally:
|
||||
if callbacks.get("on_finished"):
|
||||
callbacks["on_finished"]()
|
||||
|
||||
ai.run_async = immediate
|
||||
prescriptions.run_async = immediate
|
||||
repository = VisibilityPreviewRepository()
|
||||
|
||||
def capture(widget: Any, filename: str) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
assert widget.grab().save(str(output / filename))
|
||||
|
||||
page = prescriptions.PrescriptionsPage(repository, ["*"], {"name": "演示医生", "root": 1})
|
||||
page.resize(1366, 800)
|
||||
page.show()
|
||||
capture(page, "list-authorized.png")
|
||||
repository.visible = False
|
||||
page.refresh()
|
||||
capture(page, "list-restricted.png")
|
||||
page.close()
|
||||
|
||||
repository.visible = True
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556,
|
||||
current_user={"name": "演示医生", "root": 1})
|
||||
dialog.resize(1280, 860)
|
||||
dialog.show()
|
||||
capture(dialog, "report-authorized.png")
|
||||
repository.visible = False
|
||||
repository.batch["status"] = "partial"
|
||||
repository.batch["models"]["openai"].update(
|
||||
status="failed", report=None, candidate=None, comparison=None,
|
||||
error_code="upstream_timeout", error_message="演示:模型连接超时,可重试。")
|
||||
dialog.refresh()
|
||||
dialog._goto("report")
|
||||
capture(dialog, "report-restricted.png")
|
||||
dialog._goto("overview")
|
||||
capture(dialog, "review-restricted.png")
|
||||
dialog._goto("per_herb")
|
||||
capture(dialog, "candidate-restricted.png")
|
||||
dialog.close()
|
||||
statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||
statistics.resize(1180, 860)
|
||||
statistics.show()
|
||||
capture(statistics, "statistics-restricted.png")
|
||||
statistics.close()
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -235,6 +235,9 @@ class DoctorRepository(Protocol):
|
||||
def get_prescription(self, prescription_id: int) -> Prescription:
|
||||
"""Return one issued prescription."""
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
"""Read the server's current agreement visibility decision."""
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
"""Read cached dual-model states for up to 100 prescriptions."""
|
||||
|
||||
@@ -1611,6 +1614,9 @@ class RemoteDoctorRepository:
|
||||
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
|
||||
return dict(_require_mapping(payload, endpoint))
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return self._prescription_ai_request("capabilities", {})
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
|
||||
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from html import escape
|
||||
from math import isfinite
|
||||
from time import monotonic
|
||||
@@ -402,6 +403,28 @@ def can_open_issued_ai(permissions: Any) -> bool:
|
||||
return has_permission(permissions, "tcm.prescriptionAi/reports", default=False) and has_permission(permissions, "tcm.prescriptionAi/detail", default=False)
|
||||
|
||||
|
||||
def agreement_safe_batch(value: Any, visible: bool) -> dict[str, Any]:
|
||||
"""Discard restricted structured comparisons before retaining any UI cache."""
|
||||
|
||||
data = deepcopy(mapping(value))
|
||||
if visible:
|
||||
return data
|
||||
fields = {"comparison", "score", "herb_score", "comparison_status", "comparison_reason_code", "comparison_type",
|
||||
"algorithm_version", "normalization_version", "denominator", "dictionary_version", "dictionary_versions",
|
||||
"baseline_eligible", "baseline_exclusion_reasons", "baseline_exclusions_json"}
|
||||
for item in (data, *mapping(data.get("models")).values()):
|
||||
if isinstance(item, dict):
|
||||
for field in fields:
|
||||
item.pop(field, None)
|
||||
for model in mapping(data.get("models")).values():
|
||||
if isinstance(model, dict) and not model.get("error_code"):
|
||||
# Successful model reasons describe the comparison, while failed
|
||||
# task reasons explain generation and remain useful with reports.
|
||||
model.pop("reason", None)
|
||||
data["agreement_visible"] = False
|
||||
return data
|
||||
|
||||
|
||||
def _html(value: Any, field: str = "") -> str:
|
||||
"""Escape all stored/model content, including source URLs, before rendering."""
|
||||
if isinstance(value, Mapping):
|
||||
@@ -536,6 +559,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
self._progress_frozen_seconds = 0
|
||||
self._progress_stale = False
|
||||
self._read_access_denied = False
|
||||
self._agreement_visible = False
|
||||
self._history_agreement_visible = False
|
||||
self.setWindowTitle("诊断与药方 · AI 分析报告")
|
||||
self.resize(1440, 940)
|
||||
self.setMinimumSize(940, 640)
|
||||
@@ -575,7 +600,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
identity_block.addWidget(self.breadcrumb)
|
||||
title_row = QHBoxLayout()
|
||||
title_row.setSpacing(10)
|
||||
heading = QLabel("诊断与药方对照", top)
|
||||
heading = self.heading = QLabel("诊断与药方对照", top)
|
||||
heading.setObjectName("AiHeroTitle")
|
||||
title_row.addWidget(heading)
|
||||
self.chip_row = QHBoxLayout()
|
||||
@@ -903,7 +928,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
footer_text = QVBoxLayout()
|
||||
footer_text.setSpacing(4)
|
||||
footer_text.addWidget(self.message)
|
||||
note = QLabel(DISCLAIMER)
|
||||
note = self.disclaimer = QLabel(DISCLAIMER)
|
||||
note.setObjectName("AiFootnote")
|
||||
note.setWordWrap(True)
|
||||
footer_text.addWidget(note)
|
||||
@@ -921,6 +946,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
self._progress_timer.timeout.connect(self._render_progress)
|
||||
self._sync_actions()
|
||||
self._navigation_changed(self.tabs.currentIndex())
|
||||
self._apply_agreement_visibility(self._agreement_visible)
|
||||
|
||||
REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
|
||||
("differences", "只看不同段落"))
|
||||
@@ -1063,6 +1089,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
return host
|
||||
|
||||
def _goto(self, key: str) -> None:
|
||||
if not self._agreement_visible:
|
||||
key = "candidate" if key == "per_herb" else "report" if key in {"comparison_raw", "history"} else key
|
||||
page = self.tab_pages.get(key)
|
||||
if page is not None:
|
||||
self.tabs.setCurrentWidget(page)
|
||||
@@ -1070,7 +1098,10 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
def _navigation_changed(self, index: int) -> None:
|
||||
widget = self.tabs.widget(index)
|
||||
current = next((key for key, page in self.tab_pages.items() if page is widget), "")
|
||||
self.rail.set_current(current)
|
||||
if not self._agreement_visible and current in {"per_herb", "comparison_raw", "history"}:
|
||||
self._goto("report")
|
||||
return
|
||||
self.rail.set_current("per_herb" if current == "candidate" and not self._agreement_visible else current)
|
||||
self._current_page = current
|
||||
self._apply_density()
|
||||
|
||||
@@ -1101,6 +1132,36 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
if field in MODELS:
|
||||
self.model_views[field]["report"].setFocus()
|
||||
|
||||
def _apply_agreement_visibility(self, visible: bool) -> None:
|
||||
"""One server decision controls every comparison surface, without losing review."""
|
||||
|
||||
self._agreement_visible = visible
|
||||
self.agreement.set_agreement_visible(visible)
|
||||
self.agreement_note.setVisible(visible)
|
||||
self.heading.setText("诊断与药方对照" if visible else "诊断与药方报告")
|
||||
self.disclaimer.setText(DISCLAIMER if visible else "候选建议仅供医生复核。")
|
||||
self.comparison_panel.set_agreement_visible(visible)
|
||||
self.rail.names["overview"].setText("对比总览" if visible else "报告复核")
|
||||
self.rail.names["per_herb"].setText("候选与逐味" if visible else "AI 候选处方")
|
||||
self.nav_buttons["history"].setVisible(visible)
|
||||
for key in ("differences", "consensus"):
|
||||
self.rail.focus_values[key].parentWidget().setVisible(visible)
|
||||
if not visible:
|
||||
self._batch = agreement_safe_batch(self._batch, False) if self._batch else {}
|
||||
self._history_scope = {key: agreement_safe_batch(row, False) for key, row in self._history_scope.items()}
|
||||
self.history_page.set_history([])
|
||||
self.candidates_page.set_batch({})
|
||||
for views in self.model_views.values():
|
||||
views["comparison"].clear()
|
||||
self.comparison_panel.set_batch(self._batch)
|
||||
self.rail.set_focus({})
|
||||
self.rail.badges["overview"].clear()
|
||||
self.rail.badges["per_herb"].clear()
|
||||
self.rail.badges["overview"].hide()
|
||||
self.rail.badges["per_herb"].hide()
|
||||
if self._current_page in {"per_herb", "comparison_raw", "history"}:
|
||||
self._goto("report")
|
||||
|
||||
@property
|
||||
def save_review_button(self) -> QPushButton:
|
||||
"""The review card's own save action for the model currently being annotated."""
|
||||
@@ -1206,9 +1267,12 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
return
|
||||
self._read_access_denied = False
|
||||
data = mapping(result)
|
||||
self._history_agreement_visible = data.get("agreement_visible") is True
|
||||
self._apply_agreement_visibility(self._history_agreement_visible)
|
||||
self._feature_enabled = data.get("enabled", True) is not False
|
||||
rows = data.get("lists") or []
|
||||
self.history_page.set_history([mapping(row) for row in rows])
|
||||
rows = [agreement_safe_batch(row, self._history_agreement_visible and mapping(row).get("agreement_visible") is True)
|
||||
for row in data.get("lists") or []]
|
||||
self.history_page.set_history(rows if self._agreement_visible else [])
|
||||
self.history.blockSignals(True)
|
||||
self.history.clear()
|
||||
self._history_scope = {}
|
||||
@@ -1321,6 +1385,8 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
|
||||
def _read_failed(self, error: Exception, prefix: str) -> None:
|
||||
self._timer.stop()
|
||||
self._history_agreement_visible = False
|
||||
self._apply_agreement_visibility(False)
|
||||
text = str(error).lower()
|
||||
restricted = isinstance(error, (ValueError, AuthenticationExpiredError)) or getattr(error, "status_code", None) in {401, 403, 404} or any(
|
||||
marker in text for marker in ("forbidden", "unauthorized", "permission", "access_revoked", "not found", "权限", "无权", "登录", "归属", "不存在")
|
||||
@@ -1354,6 +1420,10 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
if int(batch.get("id") or 0) != batch_id or not identity_matches or (self.prescription_id and int(batch.get("prescription_id") or 0) != self.prescription_id):
|
||||
self._detail_error(ValueError("报告归属与当前处方或诊单不一致"), generation)
|
||||
return
|
||||
visible = (self._history_agreement_visible and batch.get("agreement_visible") is True
|
||||
and mapping(expected).get("agreement_visible") is True)
|
||||
self._apply_agreement_visibility(visible)
|
||||
batch = agreement_safe_batch(batch, visible)
|
||||
first_load = not self._batch
|
||||
self._batch = batch
|
||||
self._read_access_denied = False
|
||||
@@ -1368,7 +1438,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
self.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}")
|
||||
self._set_chips([
|
||||
(label(batch.get("validity")), "info" if current_batch(batch) else "warn"),
|
||||
(label(batch.get("comparison_type")), "neutral"),
|
||||
(label(batch.get("comparison_type")) if self._agreement_visible else "", "neutral"),
|
||||
])
|
||||
self.batch_summary.setText(f"{compact_state} {_reason(batch.get('error_message') or batch.get('error_code'))}\n资料截止:{format_record_time(batch.get('cutoff_at'))} · 批次建立:{format_record_time(batch.get('created_at'))}")
|
||||
self._render_facts(batch)
|
||||
@@ -1411,9 +1481,11 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
"risk_assessment": report.get("risk_assessment"),
|
||||
}
|
||||
_set_html(views["candidate"], _candidate_html(candidate_content))
|
||||
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
|
||||
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
||||
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
||||
comparison_html = ""
|
||||
if self._agreement_visible:
|
||||
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
|
||||
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
||||
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
||||
_set_html(views["comparison"], comparison_html)
|
||||
source_html = _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))})
|
||||
reference_lines = [line for line in report_provenance_lines(model)
|
||||
@@ -1428,7 +1500,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
views["comment"].setPlainText(str(review.get("comment") or ""))
|
||||
index = views["review_state"].findData(review.get("status"))
|
||||
views["review_state"].setCurrentIndex(max(0, index))
|
||||
self.candidates_page.set_batch(batch)
|
||||
self.candidates_page.set_batch(batch if self._agreement_visible else {})
|
||||
self.sources_page.set_batch(batch)
|
||||
self.pipeline_page.set_batch(batch)
|
||||
self._sync_failures(batch)
|
||||
@@ -1488,6 +1560,12 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
"restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
|
||||
"consensus": panel.consensus_count() if hasattr(panel, "consensus_count") else None,
|
||||
})
|
||||
if not self._agreement_visible:
|
||||
for key in ("differences", "consensus"):
|
||||
self.rail.focus_values[key].clear()
|
||||
for key in ("overview", "per_herb"):
|
||||
self.rail.badges[key].clear()
|
||||
self.rail.badges[key].hide()
|
||||
|
||||
def _render_facts(self, batch: Any) -> None:
|
||||
snapshot = mapping(mapping(batch).get("doctor_snapshot"))
|
||||
@@ -1707,6 +1785,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.repository, self.permissions = repository, permissions
|
||||
self._generation = 0
|
||||
self._agreement_visible = False
|
||||
self.setWindowTitle("医生处方 AI 一致度统计")
|
||||
self.setStyleSheet(dialog_style() + rail_qss())
|
||||
self.resize(1180, 860)
|
||||
@@ -1742,6 +1821,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
self.report.document().setDefaultStyleSheet(_report_css())
|
||||
self.report.setMinimumHeight(130)
|
||||
root.addWidget(self.report, 1)
|
||||
self.panel.hide()
|
||||
|
||||
def showEvent(self, event: Any) -> None:
|
||||
super().showEvent(event)
|
||||
@@ -1750,9 +1830,19 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
|
||||
def hideEvent(self, event: Any) -> None:
|
||||
self._generation += 1
|
||||
self._clear_statistics()
|
||||
super().hideEvent(event)
|
||||
|
||||
def _clear_statistics(self) -> None:
|
||||
self._agreement_visible = False
|
||||
self.panel.set_statistics({})
|
||||
self.panel.hide()
|
||||
self.report.clear()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._clear_statistics()
|
||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
self.summary.setText("没有查看医生统计的权限。")
|
||||
return
|
||||
@@ -1763,20 +1853,33 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
if doctor and (not doctor.isdecimal() or int(doctor) <= 0):
|
||||
self.summary.setText("请填写有效的医生编号。")
|
||||
return
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self.summary.setText("正在读取统计…")
|
||||
self.report.clear()
|
||||
self.summary.setText("正在核验一致度查看权限…")
|
||||
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
||||
if not callable(method):
|
||||
self.summary.setText("当前账号无权查看一致度统计。")
|
||||
return
|
||||
date_from, date_to = self.date_from.date().toString("yyyy-MM-dd"), self.date_to.date().toString("yyyy-MM-dd")
|
||||
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, int(doctor) if doctor else None), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
|
||||
run_async(method, on_success=lambda result: self._capabilities_ready(result, generation, date_from, date_to, int(doctor) if doctor else None),
|
||||
on_error=lambda error: self._error(error, generation))
|
||||
|
||||
def _capabilities_ready(self, result: Any, generation: int, date_from: str, date_to: str, doctor: int | None) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
if mapping(result).get("agreement_visible") is not True or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
self._clear_statistics()
|
||||
self.summary.setText("当前账号无权查看一致度统计。")
|
||||
return
|
||||
self._agreement_visible = True
|
||||
self.summary.setText("正在读取统计…")
|
||||
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, doctor), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
|
||||
|
||||
def _error(self, error: Exception, generation: int) -> None:
|
||||
if generation == self._generation:
|
||||
self.summary.setText("统计暂不可用:" + _error_text(error))
|
||||
self.report.clear()
|
||||
self._clear_statistics()
|
||||
|
||||
def _ready(self, result: Any, generation: int) -> None:
|
||||
if generation != self._generation:
|
||||
if generation != self._generation or not self._agreement_visible:
|
||||
return
|
||||
data = mapping(result)
|
||||
if data.get("enabled") is False:
|
||||
@@ -1786,6 +1889,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
return
|
||||
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
|
||||
self.panel.set_statistics(data)
|
||||
self.panel.show()
|
||||
sections = []
|
||||
for row in data.get("doctors") or []:
|
||||
doctor = mapping(row)
|
||||
|
||||
@@ -190,6 +190,8 @@ class AgreementBar(QFrame):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(22, 16, 22, 16)
|
||||
layout.setSpacing(24)
|
||||
self._agreement_visible = True
|
||||
self._metric_chrome: list[QWidget] = []
|
||||
self.blocks: dict[str, dict[str, Any]] = {}
|
||||
for index, key in enumerate(("qwen", "openai")):
|
||||
if index:
|
||||
@@ -197,11 +199,14 @@ class AgreementBar(QFrame):
|
||||
divider.setObjectName("AiFactDivider")
|
||||
divider.setFixedWidth(1)
|
||||
layout.addWidget(divider)
|
||||
layout.addWidget(self._delta_block())
|
||||
delta = self._delta_block()
|
||||
layout.addWidget(delta)
|
||||
self._metric_chrome.extend((divider, delta))
|
||||
divider = QFrame(self)
|
||||
divider.setObjectName("AiFactDivider")
|
||||
divider.setFixedWidth(1)
|
||||
layout.addWidget(divider)
|
||||
self._metric_chrome.append(divider)
|
||||
# Both heads line up at the top, so a model that failed does not slide its column down.
|
||||
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
@@ -294,7 +299,7 @@ class AgreementBar(QFrame):
|
||||
carrier.setFixedHeight(4)
|
||||
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||
carrier.setVisible(False)
|
||||
self.blocks[key] = {"score": score, "coverage": coverage, "elapsed": elapsed, "track": track,
|
||||
self.blocks[key] = {"score": score, "caption": caption, "coverage": coverage, "elapsed": elapsed, "track": track,
|
||||
"status": status, "failure": failure, "failure_text": failure_text,
|
||||
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
|
||||
return holder
|
||||
@@ -313,6 +318,30 @@ class AgreementBar(QFrame):
|
||||
|
||||
self.blocks[key]["retry_slot"].addWidget(button)
|
||||
|
||||
def set_agreement_visible(self, visible: bool) -> None:
|
||||
"""Keep model states and retries available when the metric is restricted."""
|
||||
|
||||
self._agreement_visible = visible
|
||||
for widget in self._metric_chrome:
|
||||
widget.setVisible(visible)
|
||||
for key, block in self.blocks.items():
|
||||
for field in ("score", "caption", "track"):
|
||||
block[field].setVisible(visible)
|
||||
if visible:
|
||||
block["carrier"].setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||
if not visible:
|
||||
block["score"].clear()
|
||||
block["score"].setToolTip("")
|
||||
block["track"].set_values(None, None, "")
|
||||
block["track"].setAccessibleDescription("")
|
||||
block["carrier"].setValue(0)
|
||||
block["carrier"].setToolTip("")
|
||||
block["carrier"].setAccessibleName("")
|
||||
if not visible:
|
||||
self.delta.clear()
|
||||
self.delta_note.clear()
|
||||
self.overlap.clear()
|
||||
|
||||
def set_failure(self, key: str, message: str, retryable: bool) -> None:
|
||||
block = self.blocks[key]
|
||||
block["failure_text"].setText(message)
|
||||
@@ -351,7 +380,7 @@ class AgreementBar(QFrame):
|
||||
elapsed: Mapping[str, str] | None = None) -> None:
|
||||
data = _mapping(batch)
|
||||
models = _mapping(data.get("models"))
|
||||
scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")}
|
||||
scores = {key: _score(_mapping(models.get(key))) if self._agreement_visible else None for key in ("qwen", "openai")}
|
||||
for key in ("qwen", "openai"):
|
||||
block = self.blocks[key]
|
||||
value = scores[key]
|
||||
@@ -364,6 +393,9 @@ class AgreementBar(QFrame):
|
||||
spent = _mapping(elapsed).get(key, "")
|
||||
block["elapsed"].setText(spent)
|
||||
block["elapsed"].setVisible(bool(spent))
|
||||
if not self._agreement_visible:
|
||||
self.set_agreement_visible(False)
|
||||
return
|
||||
if scores["qwen"] is None or scores["openai"] is None:
|
||||
self.delta.setText("—")
|
||||
self.delta_note.setText("两个模型都可比后才给差值")
|
||||
|
||||
@@ -537,6 +537,9 @@ class DoseDiffPanel(QFrame):
|
||||
item = self.body_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
extent = _axis_extent(deltas)
|
||||
@@ -620,6 +623,9 @@ class ConclusionsPanel(QFrame):
|
||||
entry = self.body_layout.takeAt(0)
|
||||
widget = entry.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
for index, text in enumerate(items, start=1):
|
||||
@@ -844,6 +850,9 @@ class RiskPanel(QFrame):
|
||||
entry = self.body_layout.takeAt(0)
|
||||
widget = entry.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
flagged = []
|
||||
@@ -1015,6 +1024,9 @@ class ChecklistPanel(QFrame):
|
||||
entry = self.body_layout.takeAt(0)
|
||||
widget = entry.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
grouped: dict[str, tuple[str, str, int, str]] = {}
|
||||
@@ -1132,6 +1144,7 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
self._batch: dict[str, Any] = {}
|
||||
self.rows: list[ReviewDoseRow] = []
|
||||
self._review_widget: QWidget | None = None
|
||||
self._agreement_visible = True
|
||||
self._summary = "选择报告后查看诊断与药方"
|
||||
self.setStyleSheet(f"""
|
||||
QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
|
||||
@@ -1150,7 +1163,7 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
page = QWidget(self.page_scroll)
|
||||
self.page_scroll.setWidget(page)
|
||||
outer.addWidget(self.page_scroll)
|
||||
root = QVBoxLayout(page)
|
||||
root = self._workspace_layout = QVBoxLayout(page)
|
||||
root.setContentsMargins(0, 0, 6, 0)
|
||||
root.setSpacing(14)
|
||||
|
||||
@@ -1201,6 +1214,10 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
layout.takeAt(0)
|
||||
for index in range(layout.columnCount()):
|
||||
layout.setColumnStretch(index, 0)
|
||||
if not self._agreement_visible:
|
||||
self.first.addWidget(self.checklist, 0, 0)
|
||||
self.first.setColumnStretch(0, 1)
|
||||
return
|
||||
cards = (self.conclusions, self.attribution, self.checklist)
|
||||
if columns == 1:
|
||||
for row, card in enumerate(cards):
|
||||
@@ -1289,7 +1306,31 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
self._batch = deepcopy(data)
|
||||
self._render()
|
||||
|
||||
def set_agreement_visible(self, visible: bool) -> None:
|
||||
"""Use the existing checklist as a report-only review workspace."""
|
||||
|
||||
if visible == self._agreement_visible:
|
||||
return
|
||||
self._agreement_visible = visible
|
||||
for widget in (self.conclusions, self.attribution, self.doses, self.risks, self.links["comparison"]):
|
||||
widget.setVisible(visible)
|
||||
self._workspace_layout.setStretch(0, 5 if visible else 1)
|
||||
self._workspace_layout.setStretch(1, 4 if visible else 0)
|
||||
self._columns = 0
|
||||
self._reflow(1 if self.width() < self.STACK_BELOW else 3)
|
||||
self._render()
|
||||
|
||||
def _render(self) -> None:
|
||||
if not self._agreement_visible:
|
||||
self.rows = []
|
||||
self.doses.set_rows([])
|
||||
self.risks.set_rows([])
|
||||
self.attribution.apply({})
|
||||
self.conclusions.set_items([])
|
||||
self.checklist.set_items(checklist_items(self._batch))
|
||||
self._summary = "请结合报告与资料缺口记录复核意见。"
|
||||
self.summary_changed.emit(self._summary)
|
||||
return
|
||||
self.rows, _states = review_rows(self._batch)
|
||||
deltas = dose_deltas(self.rows)
|
||||
self.doses.set_rows(deltas)
|
||||
|
||||
@@ -52,6 +52,7 @@ from PySide6.QtWidgets import (
|
||||
from .. import icons, motion
|
||||
from ..dialogs.issued_prescription_ai import (
|
||||
PrescriptionAiStatisticsDialog,
|
||||
agreement_safe_batch,
|
||||
agreement_text,
|
||||
batch_running,
|
||||
can_open_issued_ai,
|
||||
@@ -1007,6 +1008,10 @@ class PrescriptionsPage(QWidget):
|
||||
self._mutation_pending = False
|
||||
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
||||
self._ai_enabled = False
|
||||
self._agreement_visible = False
|
||||
self._capability_agreement_visible = False
|
||||
self._ai_capability_token = 0
|
||||
self._ai_capability_pending = False
|
||||
self._ai_request_token = 0
|
||||
self._ai_pending = False
|
||||
self._ai_timer = QTimer(self)
|
||||
@@ -1048,7 +1053,7 @@ class PrescriptionsPage(QWidget):
|
||||
self.orders_button.clicked.connect(lambda: self._open_orders())
|
||||
header.add_action(self.orders_button)
|
||||
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
||||
self.ai_statistics_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/statistics", default=False) and callable(getattr(repository, "prescription_ai_statistics", None)))
|
||||
self.ai_statistics_button.hide()
|
||||
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
|
||||
header.add_action(self.ai_statistics_button)
|
||||
self.add_button = QPushButton("新增处方", header)
|
||||
@@ -1437,6 +1442,7 @@ class PrescriptionsPage(QWidget):
|
||||
self._refresh_pending = False
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._load_ai_capabilities()
|
||||
page_size = self._page_size
|
||||
start_time, end_time = self._date_filters()
|
||||
filters: dict[str, Any] = {
|
||||
@@ -1656,6 +1662,7 @@ class PrescriptionsPage(QWidget):
|
||||
return
|
||||
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
||||
self._set_ai_columns(False)
|
||||
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
||||
return
|
||||
if not callable(method):
|
||||
@@ -1687,8 +1694,8 @@ class PrescriptionsPage(QWidget):
|
||||
def _set_ai_columns(self, enabled: bool) -> None:
|
||||
changed = enabled != self._ai_enabled
|
||||
self._ai_enabled = enabled
|
||||
for column in (11, 12):
|
||||
self.table.setColumnHidden(column, not enabled)
|
||||
self.table.setColumnHidden(11, not enabled)
|
||||
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
|
||||
if enabled:
|
||||
header = self.table.horizontalHeader()
|
||||
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
||||
@@ -1699,10 +1706,54 @@ class PrescriptionsPage(QWidget):
|
||||
if changed:
|
||||
self._decorate_rows()
|
||||
|
||||
def _set_agreement_visible(self, visible: bool) -> None:
|
||||
self._agreement_visible = visible
|
||||
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
|
||||
self.ai_statistics_button.setVisible(
|
||||
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
|
||||
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
|
||||
if not visible:
|
||||
self._ai_statuses = {key: agreement_safe_batch(row, False) for key, row in self._ai_statuses.items()}
|
||||
for index in range(self.table.rowCount()):
|
||||
item = self.table.item(index, 12)
|
||||
if item is not None:
|
||||
item.setText("")
|
||||
item.setToolTip("")
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, "")
|
||||
|
||||
def _load_ai_capabilities(self) -> None:
|
||||
self._ai_capability_token += 1
|
||||
token, generation = self._ai_capability_token, self._generation
|
||||
self._ai_request_token += 1
|
||||
self._ai_pending = False
|
||||
self._capability_agreement_visible = False
|
||||
self._set_agreement_visible(False)
|
||||
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
||||
self._ai_capability_pending = callable(method)
|
||||
if not callable(method):
|
||||
return
|
||||
run_async(method,
|
||||
on_success=lambda result: self._ai_capabilities_ready(result, token, generation),
|
||||
on_error=lambda _error: self._ai_capabilities_ready({}, token, generation))
|
||||
|
||||
def _ai_capabilities_ready(self, result: Any, token: int, generation: int) -> None:
|
||||
if token != self._ai_capability_token or generation != self._generation:
|
||||
return
|
||||
self._ai_capability_pending = False
|
||||
self._capability_agreement_visible = _row_mapping(result).get("agreement_visible") is True
|
||||
self._set_agreement_visible(self._capability_agreement_visible)
|
||||
if self._capability_agreement_visible:
|
||||
# A previous deny discarded scores, including completed cached batches.
|
||||
# Fetch them again after a fresh grant rather than displaying that cache.
|
||||
self._ai_statuses.clear()
|
||||
if self.isVisible() and not self._loading:
|
||||
self._load_ai_statuses()
|
||||
|
||||
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
|
||||
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
||||
return
|
||||
data = _row_mapping(result)
|
||||
self._set_agreement_visible(self._capability_agreement_visible and data.get("agreement_visible") is True)
|
||||
self._set_ai_columns(data.get("enabled") is True)
|
||||
if not self._ai_enabled:
|
||||
self._ai_timer.stop()
|
||||
@@ -1713,15 +1764,17 @@ class PrescriptionsPage(QWidget):
|
||||
self.ai_status_notice.setToolTip("")
|
||||
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
||||
return
|
||||
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
|
||||
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告。"
|
||||
+ ("可查看药味与剂量一致度。" if self._agreement_visible else ""))
|
||||
self.ai_status_notice.setToolTip("")
|
||||
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
|
||||
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
|
||||
+ ("可查看逐味对照。" if self._agreement_visible else ""))
|
||||
for value in ids:
|
||||
self._ai_statuses[value] = {}
|
||||
for batch in data.get("items") or []:
|
||||
value = _int(get_value(batch, "prescription_id"), 0)
|
||||
if value in ids:
|
||||
self._ai_statuses[value] = _row_mapping(batch)
|
||||
self._ai_statuses[value] = agreement_safe_batch(batch, self._agreement_visible and _row_mapping(batch).get("agreement_visible") is True)
|
||||
self._render_ai_statuses()
|
||||
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
||||
self._ai_timer.start()
|
||||
@@ -1741,13 +1794,13 @@ class PrescriptionsPage(QWidget):
|
||||
if batch is None and not blank:
|
||||
continue
|
||||
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
|
||||
agreement = "" if blank else agreement_text(batch)
|
||||
agreement = "" if blank or not self._agreement_visible else agreement_text(batch)
|
||||
for column, text in ((11, state), (12, agreement)):
|
||||
item = self.table.item(index, column)
|
||||
if item.text() != text:
|
||||
item.setText(text)
|
||||
changed = True
|
||||
item.setToolTip("" if blank else status_tooltip(batch))
|
||||
item.setToolTip("" if blank or column == 12 and not self._agreement_visible else status_tooltip(batch))
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
if changed:
|
||||
self.table.resizeRowsToContents()
|
||||
@@ -1757,6 +1810,7 @@ class PrescriptionsPage(QWidget):
|
||||
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
||||
if token == self._ai_request_token and generation == self._generation:
|
||||
self._ai_timer.stop()
|
||||
self._set_agreement_visible(False)
|
||||
self._set_ai_columns(False)
|
||||
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
||||
self.ai_status_notice.setToolTip(friendly_error(error))
|
||||
@@ -1781,13 +1835,13 @@ class PrescriptionsPage(QWidget):
|
||||
if _is_blank_prescription(row):
|
||||
return
|
||||
menu = QMenu(self.table)
|
||||
action = menu.addAction("AI 报告 / 逐味对照")
|
||||
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
|
||||
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
||||
self._ai_context_menu = menu
|
||||
menu.popup(self.table.viewport().mapToGlobal(position))
|
||||
|
||||
def _open_ai_statistics(self) -> None:
|
||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
if not self._agreement_visible or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
return
|
||||
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
@@ -2207,12 +2261,14 @@ class PrescriptionsPage(QWidget):
|
||||
if self.table.rowCount() == 0 and not self._loading:
|
||||
self.refresh()
|
||||
elif not self._loading:
|
||||
self._load_ai_statuses()
|
||||
self._load_ai_capabilities()
|
||||
|
||||
def hideEvent(self, event: Any) -> None:
|
||||
self._ai_timer.stop()
|
||||
self._ai_scroll_timer.stop()
|
||||
self._ai_request_token += 1
|
||||
self._ai_capability_token += 1
|
||||
self._ai_capability_pending = False
|
||||
self._ai_pending = False
|
||||
super().hideEvent(event)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from doctor_workstation.ui.pages import prescriptions as page_module
|
||||
|
||||
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]:
|
||||
return {
|
||||
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2,
|
||||
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2, "agreement_visible": True,
|
||||
"patient_id": 301, "diagnosis_id": 501, "status": status,
|
||||
"validity": "current", "comparison_type": "independent_baseline",
|
||||
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
|
||||
@@ -48,10 +48,14 @@ class Repository(DemoDoctorRepository):
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self.batches = [batch()]
|
||||
self.enabled = True
|
||||
self.agreement_visible = True
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": self.agreement_visible, "can_manage_visibility": False}
|
||||
|
||||
def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]:
|
||||
self.calls.append(("reports", params))
|
||||
return {"enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
|
||||
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
|
||||
|
||||
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
||||
self.calls.append(("detail", batch_id))
|
||||
@@ -71,7 +75,7 @@ class Repository(DemoDoctorRepository):
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
self.calls.append(("statuses", list(ids)))
|
||||
return {"enabled": self.enabled, "items": [{"prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
|
||||
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "items": [{"agreement_visible": self.agreement_visible, "prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -595,6 +599,9 @@ def test_list_ignores_response_after_filter_generation_changes(application: QApp
|
||||
|
||||
def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None:
|
||||
class StatisticsRepository:
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True}
|
||||
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
return {"total_count": 3, "patient_count": 2, "doctors": [{"doctor_name": "张医生", "total_count": 3, "patient_count": 2, "models": {"qwen": {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": {"non_independent": 3}}, "openai": {"eligible_count": 1, "coverage_rate": 33.3, "mean": 0, "median": 0}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
||||
dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"])
|
||||
@@ -635,6 +642,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
||||
return {"saved": True}
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
repository.get_prescription_ai_capabilities()
|
||||
assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []}
|
||||
repository.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20)
|
||||
repository.get_prescription_ai_report(40)
|
||||
@@ -644,6 +652,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
||||
repository.review_prescription_ai(40, "qwen", "reviewed", "核对完成")
|
||||
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
|
||||
assert client.calls == [
|
||||
("GET", "tcm.prescriptionAi/capabilities", {}),
|
||||
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
|
||||
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
|
||||
("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}),
|
||||
@@ -660,6 +669,217 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
||||
repository.retry_prescription_ai(40, "other")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
|
||||
def test_agreement_denied_or_missing_keeps_reports_review_and_retry(
|
||||
application: QApplication, immediate: None, flag: Any,
|
||||
) -> None:
|
||||
repository = Repository()
|
||||
repository.agreement_visible = flag
|
||||
value = repository.batches[0]
|
||||
value["agreement_visible"] = flag
|
||||
value["models"]["openai"].update(status="failed", error_message="上游超时")
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], diagnosis_id=501,
|
||||
current_user={"root": 1, "role_ids": [1]})
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
assert not dialog._agreement_visible
|
||||
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||
assert dialog.model_views["qwen"]["comment"].isEnabled()
|
||||
assert dialog.model_views["qwen"]["save"].isVisible()
|
||||
assert dialog.model_views["openai"]["retry"].isVisible()
|
||||
assert dialog.model_views["openai"]["retry"].isEnabled()
|
||||
assert dialog.history.count() == 1
|
||||
assert dialog.comparison_panel.checklist.isVisible()
|
||||
assert dialog.comparison_panel.conclusions.isHidden()
|
||||
assert not dialog.comparison_panel.rows
|
||||
assert not dialog.comparison_panel.doses.rows
|
||||
assert dialog.nav_buttons["history"].isHidden()
|
||||
assert dialog.agreement_note.isHidden()
|
||||
for key, views in dialog.model_views.items():
|
||||
assert not views["score"].text() and not views["score"].toolTip()
|
||||
assert not views["agreement_bar"].toolTip() and not views["agreement_bar"].accessibleName()
|
||||
assert views["gauge"]._value is None and not views["gauge"].accessibleDescription()
|
||||
assert not views["comparison"].toPlainText()
|
||||
assert "comparison" not in dialog._batch["models"][key]
|
||||
assert not dialog.agreement.delta.text() and not dialog.agreement.overlap.text()
|
||||
for key in ("differences", "consensus"):
|
||||
assert not dialog.rail.focus_values[key].text()
|
||||
assert dialog.rail.focus_values[key].parentWidget().isHidden()
|
||||
dialog._goto("per_herb")
|
||||
assert dialog.tabs.currentWidget() is dialog.tab_pages["candidate"]
|
||||
assert "黄芪" in dialog.model_views["qwen"]["candidate"].toPlainText()
|
||||
dialog._goto("comparison_raw")
|
||||
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
|
||||
dialog._goto("history")
|
||||
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["reports", "history_batch", "detail"])
|
||||
def test_each_report_visibility_flag_is_required(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, missing: str) -> None:
|
||||
repository = Repository()
|
||||
reports, detail = repository.list_prescription_ai_reports, repository.get_prescription_ai_report
|
||||
def read_reports(**params: Any) -> dict[str, Any]:
|
||||
data = reports(**params)
|
||||
if missing == "reports":
|
||||
data.pop("agreement_visible")
|
||||
if missing == "history_batch":
|
||||
data["lists"][0].pop("agreement_visible")
|
||||
return data
|
||||
def read_detail(batch_id: int) -> dict[str, Any]:
|
||||
data = detail(batch_id)
|
||||
if missing == "detail":
|
||||
data.pop("agreement_visible")
|
||||
return data
|
||||
monkeypatch.setattr(repository, "list_prescription_ai_reports", read_reports)
|
||||
monkeypatch.setattr(repository, "get_prescription_ai_report", read_detail)
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||
dialog.show()
|
||||
assert not dialog._agreement_visible
|
||||
assert not dialog.model_views["qwen"]["score"].text()
|
||||
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_agreement_revocation_and_failed_read_clear_cached_views_and_survive_theme(
|
||||
application: QApplication, immediate: None,
|
||||
) -> None:
|
||||
repository = Repository()
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||
dialog.show()
|
||||
assert dialog.model_views["qwen"]["score"].text() == "0.0%"
|
||||
old_batch, old_generation = deepcopy(dialog._batch), dialog._generation
|
||||
dialog.model_views["qwen"]["comment"].setPlainText("保留复核草稿")
|
||||
repository.agreement_visible = False
|
||||
dialog.refresh()
|
||||
dialog._detail_ready(old_batch, old_generation, 40)
|
||||
assert not dialog._agreement_visible
|
||||
assert not dialog.model_views["qwen"]["score"].text()
|
||||
assert dialog.history_page.table.rowCount() == 0
|
||||
assert not dialog.history_page.chart._points
|
||||
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
|
||||
dialog._switch_theme()
|
||||
application.processEvents()
|
||||
assert not dialog._agreement_visible and not dialog.model_views["qwen"]["score"].text()
|
||||
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
|
||||
dialog._switch_theme()
|
||||
repository.agreement_visible = True
|
||||
dialog.refresh()
|
||||
assert dialog._agreement_visible
|
||||
dialog._detail_error(RuntimeError("offline"), dialog._generation)
|
||||
assert not dialog._agreement_visible
|
||||
assert not dialog.model_views["qwen"]["score"].text()
|
||||
assert "comparison" not in dialog._batch["models"]["qwen"]
|
||||
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||
dialog._detail_ready(old_batch, dialog._generation, 40)
|
||||
assert not dialog._agreement_visible
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_revoked_agreement_cache_discards_flat_comparison_metadata() -> None:
|
||||
value = batch()
|
||||
sentinel = "previous-private-comparison"
|
||||
value["baseline_exclusions_json"] = sentinel
|
||||
value["models"]["qwen"].update({
|
||||
"reason": sentinel, "comparison_reason_code": sentinel,
|
||||
"dictionary_version": sentinel, "error_code": "",
|
||||
})
|
||||
value["models"]["openai"].update({
|
||||
"error_code": "UPSTREAM_TIMEOUT", "reason": "模型请求超时",
|
||||
})
|
||||
sanitized = ai.agreement_safe_batch(value, False)
|
||||
assert sentinel not in str(sanitized)
|
||||
assert "千问分析" in str(sanitized["models"]["qwen"]["report"])
|
||||
assert sanitized["models"]["openai"]["reason"] == "模型请求超时"
|
||||
assert value["models"]["qwen"]["reason"] == sentinel
|
||||
|
||||
|
||||
def test_list_capability_failure_revocation_and_late_status_cannot_restore_scores(
|
||||
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = Repository()
|
||||
page = page_module.PrescriptionsPage(repository, ["*"], {"root": 1})
|
||||
page.resize(1366, 800)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert not page.table.isColumnHidden(12)
|
||||
old_statuses = repository.list_prescription_ai_statuses([801, 802])
|
||||
old_token = page._ai_request_token
|
||||
old_capability_token = page._ai_capability_token
|
||||
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||
page._load_ai_capabilities()
|
||||
page._ai_statuses_ready(old_statuses, [801, 802], old_token, page._generation)
|
||||
page._ai_capabilities_ready({"agreement_visible": True}, old_capability_token, page._generation)
|
||||
assert page.table.isColumnHidden(12) and not page.table.isColumnHidden(11)
|
||||
assert page.ai_statistics_button.isHidden()
|
||||
assert page.ai_report_button.isVisible()
|
||||
assert page._ai_statuses
|
||||
for index in range(page.table.rowCount()):
|
||||
item = page.table.item(index, 12)
|
||||
assert not item.text() and not item.toolTip() and not item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
assert all("score" not in model for row in page._ai_statuses.values() for model in row.get("models", {}).values())
|
||||
page.close()
|
||||
|
||||
|
||||
def test_list_rechecks_completed_scores_after_hide_and_show(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repository = Repository()
|
||||
original = repository.list_prescription_ai_statuses
|
||||
def statuses(ids: list[int]) -> dict[str, Any]:
|
||||
data = original(ids)
|
||||
for row in data["items"]:
|
||||
row["status"] = "success"
|
||||
for model in row["models"].values():
|
||||
model["status"] = "success"
|
||||
return data
|
||||
monkeypatch.setattr(repository, "list_prescription_ai_statuses", statuses)
|
||||
page = page_module.PrescriptionsPage(repository, ["*"])
|
||||
page.resize(1366, 800)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert "0%" in page.table.item(0, 12).text()
|
||||
before = len([call for call in repository.calls if call[0] == "statuses"])
|
||||
page.hide()
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert len([call for call in repository.calls if call[0] == "statuses"]) > before
|
||||
assert "0%" in page.table.item(0, 12).text()
|
||||
page.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
|
||||
def test_direct_statistics_requires_strict_server_capability(application: QApplication, immediate: None, flag: Any) -> None:
|
||||
class StatisticsRepository:
|
||||
calls = 0
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": flag, "can_manage_visibility": True}
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
self.calls += 1
|
||||
return {}
|
||||
repository = StatisticsRepository()
|
||||
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||
dialog.show()
|
||||
assert repository.calls == 0
|
||||
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_statistics_failed_capability_clears_old_charts_and_ignores_late_result(
|
||||
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = ChineseStatisticsRepository()
|
||||
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||
dialog.show()
|
||||
assert dialog.panel.isVisible() and dialog.report.toPlainText()
|
||||
old_generation = dialog._generation
|
||||
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||
dialog.refresh()
|
||||
dialog._ready(repository.prescription_ai_statistics(), old_generation)
|
||||
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
|
||||
assert not dialog.panel.distribution.has_data()
|
||||
assert dialog.panel.doctors.rowCount() == 0
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stale_list_score_is_not_attached_to_current_prescription() -> None:
|
||||
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
|
||||
assert ai.agreement_text(value) == "千问 —\nOpenAI —"
|
||||
@@ -773,7 +993,7 @@ def test_actual_history_pagination_contract(application: QApplication, immediate
|
||||
def reports(**params: Any) -> dict[str, Any]:
|
||||
repository.calls.append(("reports", params))
|
||||
page = params["page_no"]
|
||||
return {"lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
|
||||
return {"agreement_visible": True, "lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
|
||||
monkeypatch.setattr(repository, "list_prescription_ai_reports", reports)
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||
dialog.show()
|
||||
@@ -795,6 +1015,9 @@ def test_server_empty_batch_statuses_are_not_fake_scores(status: str, expected:
|
||||
|
||||
def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None:
|
||||
class StatisticsRepository:
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True}
|
||||
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
strata = [{"versions": {"model_version": version, "algorithm_version": "v1", "prompt_version": "p1", "dictionary_version": "d1"}, "count": 1, "mean": mean, "median": mean, "distribution": {"[0,20)": 1 if mean == 0 else 0}, "sample_status": "insufficient_sample"} for version, mean in (("model-1", 0), ("model-2", 80))]
|
||||
return {"total_count": 2, "patient_count": 1, "doctors": [{"doctor_id": 7, "doctor_name": "张医生", "total_count": 2, "patient_count": 1, "paired_count": 0, "models": {"qwen": {"eligible_count": 2, "coverage_rate": 100, "mean": None, "median": None, "excluded_reasons": {}, "strata": strata}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
||||
@@ -965,6 +1188,9 @@ def test_internal_version_labels_keep_revision_numbers(version: str, expected: s
|
||||
|
||||
|
||||
class ChineseStatisticsRepository:
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True}
|
||||
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
|
||||
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 业务订单辅方袋数:后端只读调查
|
||||
|
||||
调查日期:2026-10-17。范围为业务订单详情的服用参数修改链路;未修改业务代码、未读取业务数据库或配置中的敏感信息。仓库 `.trellis/` 不存在,已按现有代码约定调查。
|
||||
|
||||
## 结论
|
||||
|
||||
辅方「5g · 1袋」中的袋数已经持久化在 `zyt_tcm_prescription.aux_usage.dosage_bag_count`,无需新增数据库字段。业务订单的修改入口是 `POST /tcm.prescriptionOrder/patchPrescriptionUsage`,当前只更新辅方每天次数和开立天数,没有接受或保存辅方袋数。建议沿用此入口增加可选参数 `aux_dosage_bag_count`,限制为 1~5 的整数;未传时保留旧袋数,兼容现有调用方。
|
||||
|
||||
## 字段与读写链路
|
||||
|
||||
- `server/sql/1.9.20260520/add_prescription_aux_usage.sql`:处方表的 `aux_usage` 是已有 JSON 列。
|
||||
- `server/app/common/model/tcm/Prescription.php:21`:`aux_usage` 配置为 JSON,关联数组读写。主方袋数为处方独立字段 `dosage_bag_count`;辅方袋数位于 JSON 中。`bags_per_dose` 是另一个字段,不应用来保存本次截图的袋数。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:183`:`normalizeAuxUsage()` 维护 `dosage_amount`、`dosage_bag_count`、`need_decoction`、`bags_per_dose`、`times_per_day`、`usage_days`、`prescription_name`。`normalizeDosageBagCount()` 将袋数约束到 1~5,默认 1。
|
||||
- `server/app/adminapi/validate/tcm/PrescriptionValidate.php:15`:通用处方主方袋数采用 `integer|between:1,5`,可复用该业务口径。
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php:240`:调用 `post()->goCheck('patchPrescriptionUsage')`,再传入管理员身份调用逻辑层。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:5770`:公开方法进入受保护事务,内部 `patchPrescriptionUsageLocked()` 加载订单与关联处方。当前合并旧 `aux_usage` 后仅改 `times_per_day`、`usage_days`,其余键保持不变。
|
||||
- 同文件 `detail():1212` 复用处方详情,导出函数 `formatUsageSegmentForExport():3756` 和 `normalizeAuxUsageForExport():3807` 已读取辅方 `dosage_bag_count`。保存正确 JSON 键后,无需另建展示或导出字段。
|
||||
- 辅方存在性以 `herbs[].formula_type === '辅方'` 判定;残留 `aux_usage` 不代表仍有辅方。
|
||||
|
||||
## 校验现状与必须注意的问题
|
||||
|
||||
当前业务逻辑强制主方次数 1~6、主方开立天数 1~999、订单服用天数 1~999;存在辅方时要求辅方次数及开立天数,并分别检查 1~6、1~999。
|
||||
|
||||
发现既有验证器场景命名问题:`PrescriptionOrderValidate.php:136` 定义的是 `patchPrescriptionUsage()`,而非 `scenePatchPrescriptionUsage()`。ThinkPHP `vendor/topthink/framework/src/think/Validate.php:1673` 的 `getScene()` 只调用 `scene` 前缀方法。因此控制器当前场景校验不会执行该方法中的 `append()`;静态 `$scene` 虽然列了用量字段,但这些字段没有基础 `$rule`,实际主要只校验已有的 `id` 规则。逻辑层先 `(int)` 转换再判断范围,也会把部分非整数输入截断为整数。
|
||||
|
||||
已用只加载 Composer 和内存验证器的 PHP 探针复现:同一组非法次数/天数参数,`scene('patchPrescriptionUsage')->check(...)` 返回 `true`,手动先调用旧 `patchPrescriptionUsage()->check(...)` 返回 `false`。探针不初始化业务应用、不连接数据库。
|
||||
|
||||
最小修复应正确使用 `scenePatchPrescriptionUsage()`,把新参数加进场景并追加 `integer|between:1,5`。业务逻辑还应在保存前验证袋数确实为整数并在范围内,避免直接调用逻辑方法时发生小数截断。无需改变其他接口的验证场景。
|
||||
|
||||
## 权限、状态与事务
|
||||
|
||||
- 现有接口权限为 `tcm.prescriptionOrder/patchPrescriptionUsage`,菜单迁移在 `server/sql/1.9.20260708/add_prescription_order_patch_usage_menu.sql`;新增袋数应沿用此权限。
|
||||
- `AuthMiddleware` 根据菜单 URI 校验管理员权限;现有框架对未注册在菜单表的普通 URI 存在历史放行逻辑,因此部署应确保已有菜单迁移已执行。本次无需新增权限或迁移。
|
||||
- 逻辑层先检查订单存在、`canAccessOrder()`、药房快照可变、订单未取消、已关联有效处方,以及 `PrescriptionLogic::canViewPrescription()`。
|
||||
- `canAccessOrder()` 沿用当前全量角色、处方订单菜单访问、订单创建人、开方人授权或数据域可见创建人等口径;处方可见性另检查超管、创建人、医助、共享、可见角色及现有相关权限等。
|
||||
- `LockedPharmacySnapshotMutation::execute()` 在 `Db::transaction()` 中锁订单行及药房提交声明行;内部返回 `false` 会导致回滚。新增字段必须留在此现有事务内部。
|
||||
- `PharmacyRemoteSnapshotPolicy` 在已有药房远端单号、提交时间或声明状态为 `PENDING`、`UNKNOWN`、`PENDING_RECONCILE`、`SUCCESS` 时锁定修改。新增袋数不可绕开该限制。
|
||||
|
||||
## 日志
|
||||
|
||||
当前写入 `zyt_tcm_prescription_order_log`,`action = patch_rx_usage`,摘要为主方次数/天数、辅方次数/天数及订单天数的前后值。建议在原辅方段落加上「袋数 旧值 → 新值」,沿用当前日志动作,确保能回溯这次更改。
|
||||
|
||||
`writeLog():5723` 默认是非严格日志,日志保存失败会吞掉异常;本接口沿用该默认行为。是否改成严格日志属于额外行为变更,不是实现辅方袋数所必需。
|
||||
|
||||
## 最小修改建议
|
||||
|
||||
1. 修改 `PrescriptionOrderValidate` 的正确场景方法,仅对新增可选 `aux_dosage_bag_count` 检查整数及 1~5 范围。
|
||||
2. 在 `patchPrescriptionUsageLocked()` 中,存在辅方且新参数已提供时才更新 `aux_usage['dosage_bag_count']`;继续复制旧 JSON,保留用量、名称、代煎等所有其他键。不修改主方 `dosage_bag_count`。
|
||||
3. 缺少新参数时保留旧袋数;无辅方时沿用现有忽略辅方修改的逻辑。不因编辑次数或天数而把旧袋数重置为默认值。
|
||||
4. 扩展 `patch_rx_usage` 摘要记录袋数前后值。
|
||||
5. 验证有效 1、5,非法 0、6、小数、非数字,省略字段兼容,JSON 其他键保留,无辅方不产生新辅方用法,权限/取消/药房锁定失败,以及保存后回读与日志。参数校验至少要通过真实 `scene('patchPrescriptionUsage')` 路径测试,不能仅直接调用场景方法。
|
||||
|
||||
## 现有测试与本次验证
|
||||
|
||||
- 未发现针对辅方袋数修改或 `patch_rx_usage` 摘要的既有功能测试。
|
||||
- `server/tests/pharmacy/run.php:809` 对本接口的受保护事务、错误清理及回滚信息有源码契约检查;它不验证真实辅方袋数持久化。
|
||||
- `server/tests/pharmacy/prescription_order_export_name_regression.php` 覆盖辅方存在性及名称回退,本次运行 **7 项通过**。
|
||||
- `php tests/pharmacy/run.php` 本次基线运行在 `run.php:870` 失败,提示 `tracking correction must append a strict operation log containing old and new logistics values`。这是快递修改日志的源码文本断言,调查时未修改任何后端业务代码,应与本次新增功能分开判断。
|
||||
- 真实 ORM/事务测试可参考 `PrescriptionOrderReleaseAndTimeTest.php` 或 `PrescriptionOrderTrackingAuditTest.php` 的临时数据库夹具;它们要求显式指定隔离 MySQL 端口,不能直接用业务数据库。目前未运行这些数据库测试。
|
||||
|
||||
推荐从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
php -l server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php
|
||||
php -l server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||
php server/tests/pharmacy/run.php
|
||||
php server/tests/pharmacy/prescription_order_export_name_regression.php
|
||||
```
|
||||
|
||||
若补充独立辅方袋数行为测试,应使用可控临时数据库或现有内存替身,覆盖上述范围、合并保存及权限/状态拒绝;测试命令随新测试实际路径确定。已有真实 ORM 测试仅在已准备隔离 MySQL 时运行,并按对应文件头说明设置环境变量。
|
||||
|
||||
## 实施结果(2026-10-17)
|
||||
|
||||
调查完成后,按授权实现了最小后端扩展:
|
||||
|
||||
- `PrescriptionOrderValidate` 已改为正确的 `scenePatchPrescriptionUsage()`,新增可选 `aux_dosage_bag_count`。使用场景闭包严格验证 1~5 整数/整数字符串;缺少字段可通过,但显式空值、布尔值、小数或其他类型会拒绝,避免框架普通可选规则跳过空值。
|
||||
- `PrescriptionOrderLogic` 仅在处方含辅方且 `prescription_type` 为「浓缩水丸」时处理新袋数,逻辑层独立重复严格校验;合并原 JSON 保存该键,省略字段保留原值,其他处方类型和无辅方处方忽略袋数变更。原有主方、次数/天数、权限、取消限制、药房锁及事务边界继续沿用。
|
||||
- 在 `patch_rx_usage` 原日志中增加「辅方袋数 X袋 → Y袋」;只在实际提供并处理袋数字段时记录,不改变原日志失败容错行为。
|
||||
- 控制器仅更新注释。未新增数据库迁移,未修改本分工之外的前端代码。
|
||||
|
||||
新增 `server/tests/PrescriptionOrderAuxBagCountTest.php`,直接运行真实场景验证器和公开业务逻辑入口,使用独立本地 MySQL 随机临时库,结束后自动删除该库;不加载业务数据库配置。覆盖整数边界、非法输入、JSON/主方字段保留、保存后数据库及模型回读、旧请求省略字段、旧 JSON 无袋数字段、非浓缩水丸、无辅方及残留 JSON、日志、权限/取消/药房快照拒绝,以及处方已保存后订单更新失败的真实事务回滚。
|
||||
|
||||
验证结果:
|
||||
|
||||
- 新增真实验证器/ORM/事务测试:**83 项断言通过**。
|
||||
- 相关 4 个 PHP 文件语法检查通过。
|
||||
- 辅方导出名称回归:**7 项通过**。
|
||||
- 3 个修改过的现有 PHP 文件 `git -c core.whitespace=cr-at-eol diff --check` 通过。
|
||||
- 上文记录的药房套件快递日志文本断言基线失败未扩大修改。
|
||||
|
||||
在已准备隔离 MySQL 实例后,可从仓库根目录重跑:
|
||||
|
||||
```powershell
|
||||
$env:ZYT_AUX_BAG_TEST_MYSQL_PORT='13483'
|
||||
php server/tests/PrescriptionOrderAuxBagCountTest.php
|
||||
php server/tests/pharmacy/prescription_order_export_name_regression.php
|
||||
```
|
||||
|
||||
本次测试使用 `.codex-tasks/aux-bag-count-mysql/data` 的专用实例,绑定本机回环地址与 13483 端口。测试会拒绝未指定端口或默认 3306 端口。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 处方 AI 一致度可见范围:后台实现
|
||||
|
||||
日期:2026-10-17。范围仅后台新文件与本报告、验证截图;没有修改 `server/` 或桌面 `app/`,没有提交、发布或连接生产接口。已读根 `AGENTS.md`,当前工作区无 `.trellis/`。
|
||||
|
||||
## 交付文件
|
||||
|
||||
- `admin/src/views/setting/prescription_ai_visibility/index.vue`:独立配置页面,沿用现有 Element Plus 设置表单与底部保存栏。
|
||||
- `admin/src/api/setting/prescription_ai_visibility.ts`:专用 capabilities、配置读取/保存、角色与用户选项接口及类型;对配置响应进行运行时校验。
|
||||
- `admin/tests/prescription-ai-visibility.test.cjs`:8 项 Node 测试,编译真实 Vue SFC、运行真实 setup 状态,并模拟 API;不调用真实后端。
|
||||
- `artifacts/prescription-ai-visibility-admin-local.png`:本地模拟浏览器截图。
|
||||
|
||||
## 行为
|
||||
|
||||
角色多选可搜索名称;用户多选标签包含姓名与账号,因此均可搜索。选项完全来自 `tcm.prescriptionAi/visibilityOptions`,不额外请求角色列表或管理员列表接口。当前已配置但不在活跃选项中的 ID 以明确占位标签保留;只有用户显式移除后保存才取消该项。
|
||||
|
||||
页面明确说明:角色或指定用户任一命中即可;两份名单均为空时所有账号(包括 root)均隐藏一致度、逐味对照与一致度统计;root 可管理策略,但管理权限不自动赋予查看权。AI 报告继续按原有权限开放,已有报告不受影响,原有患者/处方数据范围继续适用。
|
||||
|
||||
先读取 `capabilities.can_manage_visibility`,只有严格为 `true` 才读取配置与选项。两者全部成功后才填入并启用表单;首次失败不展示空表单,重新加载失败保留既有选择并禁用保存。保存期间禁用输入、重载及重复提交;失败保留当前选择并给出重试/重新读取提示;成功应用后端返回的规范化名单。配置响应缺字段、含非数字/非正整数 ID 等异常会拒绝,不会降级成可保存的空策略。
|
||||
|
||||
后端菜单需指向 `setting/prescription_ai_visibility/index`,管理权限为 `tcm.prescriptionAi/manageVisibility`;菜单迁移及服务端自鉴权由后端任务实现。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `node --test tests/prescription-ai-visibility.test.cjs`:8/8 通过。覆盖固定接口契约、配置坏响应、pending/失败禁止保存、选项失败不部分覆盖策略、缺失 ID 保留、姓名账号标签、空名单与规范化回显、防重复提交、保存失败保留、管理与查看能力分离、真实模板编译。
|
||||
2. 对新增 API、Vue 页面、测试执行 Prettier `--check`:通过。
|
||||
3. 针对新增 API 与页面的临时 `vue-tsc` 项目(严格模板,使用真实 Element Plus/Vue 类型,隔离 request/feedback 的外部依赖):通过,无类型错误。
|
||||
4. 全后台 `vue-tsc --noEmit --pretty false --incremental false --composite false`:退出 2,报告 61 项新文件范围外的错误;没有错误涉及 `prescription_ai_visibility`。完整日志位于 `%TEMP%/zyt-prescription-ai-visibility-vue-tsc.log`。本任务没有扩大修改范围修复这些错误。
|
||||
5. 独立 `127.0.0.1:5568` Vite 浏览器模拟,使用真实新页面、Element Plus、后台样式与 FooterBtns;所有 API 均映射到本地假数据模块,没有加载主应用或真实网络请求。确认成功读取、缺失 ID 标签、账号 `li.assistant` 搜索、保存 pending 禁用、保存成功提示、加载失败禁用保存、保存失败保留修改、空名单警示及无管理权限状态。
|
||||
|
||||
未运行 `npm run build`,避免其自带 release 脚本;实际 Vite 浏览器编译已成功。临时验证 harness 位于忽略的 `admin/node_modules/.cache/prescription-ai-visibility/`,不属于业务交付。
|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,63 @@
|
||||
# 处方 AI 一致度授权:后端实施记录
|
||||
|
||||
日期:2026-10-17。仅修改 server 后端及本报告;未提交、未部署、未连接生产数据库或模型服务。
|
||||
|
||||
## 行为
|
||||
|
||||
- 新服务 `server/app/common/service/prescriptionai/PrescriptionAiVisibility.php` 使用 ConfigService 一条 JSON:`type=prescription_ai_visibility` / `name=agreement_policy`,字段仅 `role_ids:int[]`、`admin_ids:int[]`。
|
||||
- 默认空、缺失或格式错误策略全部不可查看一致度。角色或单用户命中任一即可;查看没有 root、通配符或管理权限豁免。
|
||||
- 账号每次重新检查 `disable=0`、`delete_time IS NULL`;角色关系实时查询并排除软删除角色。配置读取无长期缓存,授权或撤销下次请求立即生效。
|
||||
- 独立管理权限为 `tcm.prescriptionAi/manageVisibility`。root 可管理,其余账号须持此权限;三个管理入口均在服务端统一验证,防止未注册路由中间件默认放行产生越权。所有入口仍由控制器重载有效账号,拒绝未知参数与错误 HTTP 方法。
|
||||
- statuses 顶层及各 item、reports 顶层及各 batch、detail 顶层均返回 `agreement_visible`。未授权时共享 Logic 投影删除整个 comparison、扁平 score/herb_score、比较状态/原因/版本、比较类型/基线等派生数据;失败模型生成错误原因保留。
|
||||
- full 模型正文使用固定字段及嵌套结构投影,拒绝历史正文未知 raw/result/score、调试对象、伪造 progress 等旁路。报告、候选方案、来源与覆盖说明、进度、医生原方、复核意见保留。
|
||||
- statistics 在扫描/聚合之前拒绝没有白名单资格的账号;原有动作 RBAC 和处方/患者/来源范围校验仍执行。后台分析、比较、手动重试与复核的既有权限流程未加指标限制。
|
||||
|
||||
## 固定接口
|
||||
|
||||
| HTTP | `tcm.prescriptionAi/` 后 action | 数据 |
|
||||
| --- | --- | --- |
|
||||
| GET | capabilities | `{agreement_visible:boolean, can_manage_visibility:boolean}`,登录账号自查 |
|
||||
| GET | getVisibilityConfig | `{role_ids:number[],admin_ids:number[]}`,仅管理者 |
|
||||
| POST | setVisibilityConfig | 接收且仅接收上述两数组,返回去重排序后的策略;仅管理者 |
|
||||
| GET | visibilityOptions | `{roles:[{id,name}],admins:[{id,name,account}]}`,仅管理者 |
|
||||
|
||||
选项及保存列表上界:500 个角色、5000 个后台用户。选项只返回活跃记录,超过上界明确报错,不静默截断。保存只验证新提交的名单,因此失效 ID 可从旧名单中删除后保存,不会被旧数据阻塞。角色无 disable/status 列,按软删除判断。
|
||||
|
||||
## 迁移
|
||||
|
||||
新增 `server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql`:
|
||||
|
||||
- 幂等创建系统设置下 type C 的“处方AI一致度授权”菜单,perms 为 `tcm.prescriptionAi/manageVisibility`,component 为 `setting/prescription_ai_visibility/index`。
|
||||
- 幂等预置一条空 JSON 策略,不自动授权任何非 root 角色,不填入查看白名单。
|
||||
- 不新增业务表,不改变既有报告或队列数据。上线时需要先执行该迁移;本次仅在隔离测试数据库中执行并重放验证。
|
||||
|
||||
## 修改文件
|
||||
|
||||
1. `server/app/common/service/prescriptionai/PrescriptionAiVisibility.php`(新):策略、管理校验、实时匹配、有限选项、输出投影。
|
||||
2. `server/app/adminapi/controller/tcm/PrescriptionAiController.php`:四个接口。
|
||||
3. `server/app/adminapi/logic/tcm/PrescriptionAiLogic.php`:统一能力标记、指标裁剪、统计授权、正文投影。
|
||||
4. `server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql`(新):幂等菜单及空策略。
|
||||
5. `server/tests/PrescriptionAiVisibilityTest.php`(新):无应用初始化的纯离线策略/投影回归。
|
||||
6. `server/tests/PrescriptionAiQueueTest.php`:隔离真实 ORM、Cipher、Logic、Controller 及迁移回归。
|
||||
|
||||
## 验证结果
|
||||
|
||||
PHP 8.2.9;新增/修改的 5 个 PHP 文件全部 `php -l` 通过。`git diff --check -- server` 通过。全仓 diffcheck 中另有并行桌面端文件换行问题,已通知主代理,本任务未修改 app 文件。
|
||||
|
||||
纯离线:
|
||||
|
||||
- `php server/tests/PrescriptionAiVisibilityTest.php`:22 checks。
|
||||
- `php server/tests/PrescriptionAiProgressTest.php`:38 checks。
|
||||
- `php server/tests/PrescriptionAiComparisonTest.php`:266 checks。
|
||||
- `php server/tests/PrescriptionAiStatisticsTest.php`:64 checks。
|
||||
- `php server/tests/PrescriptionAiConfigurationTest.php`:通过。
|
||||
|
||||
隔离 MySQL:主代理建立的 `127.0.0.1:33447`,root 空密码,显式 `ZYT_AI_TEST_MYSQL_PORT=33447`。这些脚本不 App::initialize,不读生产 .env,创建并最终删除各自唯一 `prescription_ai_test_*` 临时库:
|
||||
|
||||
- `php server/tests/PrescriptionAiQueueTest.php`:113 checks。
|
||||
- `php server/tests/PrescriptionAiQueueTest.php --legacy-progress-schema`:111 checks。
|
||||
- `php server/tests/PrescriptionAiPipelineTest.php`:125 checks。
|
||||
|
||||
新增集成覆盖:默认空名单含 root 拒绝查看、root 管理与查看分离、独立非 root 管理权限、GET/POST/未知字段、伪 root/星号 token、配置越权拒绝、角色/个人任一授权、多角色、即时撤销、软删除角色、禁用/删除账号、无效策略、活跃最小选项与超限、菜单禁用即时撤销、allowlist 不替代动作 RBAC、真实加密旧正文注入及嵌套旁路、三个读取出口完整裁剪、统计拒绝、保留报告/候选/原方/复核/失败原因、限制指标后仍可重试/复核、迁移执行两次。
|
||||
|
||||
报告是实现交付,不表示生产迁移已经执行。测试 MySQL 进程由主代理负责最终关闭。
|
||||
@@ -0,0 +1,118 @@
|
||||
# 处方 AI 一致度授权:后端只读研究
|
||||
|
||||
日期:2026-10-17。范围:D:/web/zyt 本地源码;没有调用生产接口、连接数据库、调用模型或运行会初始化业务配置的测试。只新增本报告,未修改业务代码。
|
||||
|
||||
已读取根 AGENTS.md;工作区不存在 `.trellis/`。本文最初保留“仅一致度 / 整个 AI 模块”两种边界,随后按主代理明确的实现方向整理:**仅一致度独立授权,保留 AI 报告;角色或单个后台用户命中即可;默认空名单;root 可管理,但查看一致度也必须命中白名单。**
|
||||
|
||||
## 1. 现有权限模型
|
||||
|
||||
- `server/app/adminapi/logic/auth/AuthLogic.php:91`:`getAuthByAdminId` 经 `admin_role -> system_role_menu -> system_menu` 取权限,忽略 `is_disable=1` 的菜单。多角色权限取并集。
|
||||
- `server/app/common/model/auth/Admin.php:52`:`role_id` 是 `AdminRole` 关联查询结果,不是单值;新策略必须支持任一角色命中。
|
||||
- 没有发现通用“用户直接拥有菜单权限”表或管理机制。`AuthLogic:109` 会追加 `QywxPromotionOperatorAccess` 专用共享页面权限,是业务特例,不宜为了一个字段的可见性扩展全局 RBAC。
|
||||
- `server/app/common/service/qywx/QywxPromotionOperatorAccess.php:38`:可参考其服务端动态单用户判断、软删除过滤、菜单启用检查;不应复用该业务表。
|
||||
- `server/app/common/cache/AdminAuthCache.php:73`:菜单权限缓存最长 3600 秒;`:122` 清理具体账号缓存键。若本功能仅独立策略读取而不往 AuthLogic 注入权限,便不需要变更该全局缓存;每次请求实时读取策略可立即撤销。
|
||||
- `server/app/adminapi/http/middleware/AuthMiddleware.php:129` 附近:未注册为菜单的 URI 有默认放行逻辑。因此**新配置接口必须自带服务端管理鉴权**,不能只加菜单或依赖前端 `v-perms`。
|
||||
|
||||
一致度的最终允许条件应是:账号有效 AND 现有该动作 RBAC 权限通过 AND 原有处方/患者/来源快照数据范围通过 AND 一致度白名单命中。名单只增加指标可见资格,不授予患者或报告访问权限。root 仍按既有逻辑拥有普通 AI 动作权限,但不能绕过新的指标白名单。
|
||||
|
||||
## 2. 可直接采用的存储与接口形态
|
||||
|
||||
`server/app/common/service/ConfigService.php:32` 支持将数组 JSON 存到 `config` 表;`:65` 读取并反序列化。已有 `setting/DesktopWorkstationController.php` 与 `DesktopWorkstationLogic.php:39` 展示独立 getConfig/setConfig 的配置管理范式。
|
||||
|
||||
建议仅存一条对象(名字为候选,不代表已定路由):
|
||||
|
||||
```json
|
||||
{
|
||||
"role_ids": [1, 7],
|
||||
"admin_ids": [23, 91]
|
||||
}
|
||||
```
|
||||
|
||||
- 候选存储:`type=prescription_ai_visibility`, `name=agreement_policy`。
|
||||
- 缺失、格式错误、空名单均解释为不可查看一致度,不回退到“全部可见”。对象包含两个键,避免 ConfigService 将空数组视为假值并走默认配置的语义造成意外。
|
||||
- 写入要求两个字段均为数组;元素须正整数/规范十进制整数字符串,拒绝对象、浮点、布尔、负数及字符串拼接;去重排序,验证角色/账号实际有效;限制数量及拒绝未知字段。不要静默把任意字符串 `intval` 后授予权限。
|
||||
- 一次覆盖单 JSON,避免分别保存角色、用户导致半更新。`config` 安装表没有 `(type,name)` 唯一索引(`server/public/install/db/like.sql:142`),可由幂等迁移预置这一条空策略,避免首次并发创建;不要为本功能顺带对全表增加唯一约束。
|
||||
- 不存模型密钥、不新增业务表,不修改 `prescription_analysis.enabled` 或后台工作队列设置。
|
||||
|
||||
候选 API:
|
||||
|
||||
| 接口 | 返回/接收字段 | 必须执行的服务端鉴权 |
|
||||
| --- | --- | --- |
|
||||
| GET `tcm.prescriptionAi/capabilities` | `{agreement_visible:boolean, can_manage_agreement_visibility:boolean}` | 登录且实时有效账号;只返回当前账号布尔能力,不泄露名单 |
|
||||
| GET 专用 `getVisibilityConfig` | `{role_ids:number[], admin_ids:number[]}` | root 或独立管理权限 |
|
||||
| POST 专用 `setVisibilityConfig` | 严格上述两数组;返回规范化后的策略 | 同一管理鉴权;必须 POST |
|
||||
| GET 专用 `visibilityOptions` | `roles:[{id,name}]`, `admins:[{id,name,account,role_ids}]`,可增加有界搜索/分页 | 同一管理鉴权;只投影必要字段 |
|
||||
|
||||
配置 API 既可设在 `PrescriptionAiController`,也可采用 `setting.prescriptionAiVisibility/getConfig|setConfig|options`;关键是最终 action/perms 一致且逻辑层鉴权。若采用一个专用“管理一致度授权”权限,所有 get/set/options 均调用共同 `requireManage`,默认不向普通角色自动授予。不要把名单加到 `ConfigController::getConfig`:该控制器 `server/app/adminapi/controller/ConfigController.php:27` 明确 `notNeedLogin=['getConfig','dict']`。
|
||||
|
||||
## 3. 角色和用户选项:已有 API 与 active 字段
|
||||
|
||||
| 对象 | 当前可用 API | 实现与约束 |
|
||||
| --- | --- | --- |
|
||||
| 系统角色 | GET `/auth.role/all`,前端 `roleAll()` | `RoleController:118` -> `RoleLogic:181`;`SystemRole` 使用 SoftDelete,自动排除软删除;按 sort/id 降序 |
|
||||
| 后台用户 | GET `/auth.admin/lists?exclude_disabled=1&page_no=1&page_size=...`,前端 `adminLists()` | `AdminLists:165` 排除 disable;返回 `lists/count` 的列表封装,支持 name/account 模糊搜索 |
|
||||
| 后台全部用户 | 前端有 `adminAll()` wrapper `/auth.admin/all` | **后台 AdminController 没有 all action,不可直接使用此 wrapper** |
|
||||
|
||||
模型事实:
|
||||
|
||||
- `Admin`:`delete_time IS NULL` 且 `disable=0` 才是活跃账号,见 `PrescriptionAiAccess::actor` 第 15 行;必要字段 `id/name/account/root`;角色经 `admin_role` 关联。
|
||||
- `SystemRole`:`delete_time IS NULL`,没有发现 `disable` 或 `status` 字段;`server/public/install/db/like.sql:655` 及现有迁移仅见额外 `data_scope`。**不要对角色添加 `disable=0` 条件。**
|
||||
- `system_menu.is_disable` 是菜单状态,与系统角色不同。
|
||||
- `AdminLists` 返回手机号、登录 IP、头像等较多字段,授权下拉建议在新管理接口中直接复用模型查询,并只投影 id/name/account/role_ids。
|
||||
- `AdminLists::queryWhere` 一般默认不套 data_scope,只有显式 `apply_data_scope=1` 才启用;管理授权选项须由共同管理鉴权保护。其 role_id 过滤在该角色无成员时不会强制空集,因此若需角色联动筛选,专用 options 应正确处理空集。
|
||||
- `BaseDataLists.php:102`:`page_type=1` 表示分页,其它值只是返回最大条数,不是真正无限制;不能假定一次 `page_type=0` 可取完所有后台用户。
|
||||
- 即使保存时验证过 ID,查看时也只匹配实时有效账号与有效角色;被禁用/软删除账号不可使用旧 token,角色变更不可依赖 token 的历史 role_id。
|
||||
|
||||
## 4. 现有处方 AI 数据保护与必须覆盖的出口
|
||||
|
||||
`server/app/adminapi/controller/tcm/PrescriptionAiController.php:71` 的 handle 拒绝未知入参,校验 GET/POST,调用 `PrescriptionAiAccess::actor` 重新加载有效账号,不直接信任请求内角色。`PrescriptionAiAccess.php` 还分别校验处方、诊单及来源诊单范围。应保留这些保护,并在新增 capabilities/配置入口也使用同等实时账号检查。
|
||||
|
||||
`PrescriptionAiLogic.php` 的重要出口:
|
||||
|
||||
| 入口 | 当前信息 | 仅一致度授权时的处理 |
|
||||
| --- | --- | --- |
|
||||
| `statuses:22` | 列表轮询调用 `models(...,false)`,有 `score/herb_score/comparison_status/comparison_reason_code/reason/algorithm_version/dictionary_version` | 返回 `agreement_visible`;未授权时使用统一投影去掉所有比较字段,保留模型生成状态/进度 |
|
||||
| `reports:62` | 历史列表同样 `models(...,false)`,不能只保护 statuses | 同上;继续先按 visibleBatch 过滤再分页,不泄漏隐藏来源计数 |
|
||||
| `detail:107` | `models(...,true)` 解密正文,又包含医生原方快照 | 未授权仍保留报告、候选方及医生原方(按保留报告的当前范围),统一去掉比较数据;不要返回原始解密 body |
|
||||
| `statistics:206` | 均值、中位数、直方图、版本分层、paired_count 等全是这一指标的衍生信息 | 新白名单检查失败时直接抛 DomainException;必须在扫描/聚合前拒绝;不以 mean=null 等方式继续返回统计 |
|
||||
| `regenerate/retry/review` | 返回批次状态或 saved,没有返回比较正文 | 仅指标限制不应阻止既有报告操作;仍保留现有动作 RBAC、处方与来源范围 |
|
||||
|
||||
具体易漏点:
|
||||
|
||||
1. `models:285` 的数据库摘要列不仅 `score`,还包括 `herb_score`、比较状态/原因与版本。
|
||||
2. `models:302-315` 现为 `array_merge(task,result,body,progress)`。加一条 `unset($model['score'])` 不够:解密 body 里的 `comparison` 含同一分数,并可能包含历史未知字段或顶层别名;原始 body 不应不加选择地 merge 到对外模型。
|
||||
3. `Store::complete:348` 当前加密 body 的固定键是 `report,candidate,comparison,coverage,usage,knowledge_source,ancient_book_sources`,不是必须输出的完整 API 对象。建议按这些已知业务字段构造响应,单独按能力投影 comparison。这样历史 body 即使含 `score`, `raw`, `result`, `response`, `body`, `comparison_dump` 等未知附加键,也不会直接公开。
|
||||
4. `Comparison::compare:126` 的完整 comparison 除分数外还包含 `rows`、药量 ratio/贡献、`matched_count`、doctor/candidate_count、`usage_differences` 和 `normalization` 中的 denominator/contribution_sum,足以反推指标。未授权应移除**整个 comparison 对象**。
|
||||
5. `models:310` 的扁平 `reason` 取自 `body.comparison.reason`,也是比较派生信息;无结果任务的 `reason` 则是生成错误信息。应区别处理或在统一投影中使用保留的 `error_message` 展示模型失败原因,避免比较 reason 保留下来。
|
||||
6. `formatBatch:348` 还输出 `comparison_type`, `baseline_eligible`, `baseline_exclusion_reasons`,也应从未授权响应中移除。`coverage_status/source_summary/missing` 属于报告资料覆盖信息,可保留。
|
||||
7. 不把 `body_cipher` / `context_cipher` / `progress_cipher` / 原始 AI 调用响应增加到任何返回值。现有 Progress 采用固定字段投影并本地生成 label/notice,可继续复用;比较阶段名称本身不包含指标结果。
|
||||
8. 受保护字段应该被完全省略,而非伪装为 0 或“未生成”;布尔 `agreement_visible=false` 告诉 UI 不渲染指标功能。
|
||||
|
||||
保留医生原方与候选方案意味着用户仍可人工比较药味;本需求保护的是系统计算的一致度与相关比较/统计数据。若用户后续改为整个模块不可见,则应在所有 AI 动作的业务入口一并拒绝,前端隐藏全部入口;不能把当前字段裁剪方案误当作整模块授权。
|
||||
|
||||
## 5. MCP 与其它旁路
|
||||
|
||||
`server/app/mcp/catalog/review/tcm.php:185` 已开放 `detail/reports/statuses/statistics`,并说明依赖各入口内置数据范围保护。必须将授权/投影放在共享业务层,HTTP 控制器最终输出前才过滤会遗漏直接 Logic 测试或其它调用通路。新管理配置接口不应自动加入开放 MCP catalog。
|
||||
|
||||
对 `server/app` 的引用检索未发现这四个入口之外直接返回 `prescription_ai_result/body_cipher` 的业务出口;Store/Worker 为写入与后台计算。仍应在实现完成后检索新增调用与 MCP generated 元数据,确认统计内置保护说明匹配新规则。
|
||||
|
||||
## 6. menu 迁移惯例
|
||||
|
||||
- 当前处方 AI 迁移 `server/database/migrations/2026_09_09_prescription_ai_analysis.sql:132`:通过现有处方列表 perms 查父级;`INSERT ... SELECT ... WHERE NOT EXISTS(perms)` 幂等创建 type A 动作。
|
||||
- 原有 7 个动作分别注册 statuses/reports/detail/regenerate/retry/review/statistics;读权限继承自 patientAiReports,写权限继承自 generatePatientAiReport。**本需求不应把管理授权能力自动继承给这些读写角色。**
|
||||
- 现有设置页范式 `2026_08_21_desktop_workstation_update_menu.sql`:查询系统设置 type M 父级;type C 页面使用 getConfig perms/path/component,子级 type A 保存动作;时间戳 UNIX_TIMESTAMP,空 path/component 给动作,is_show=1/is_disable=0,事务包裹。
|
||||
- 可参考其幂等菜单写法,但本需求管理菜单默认 root 可用;非 root 只有明确授予独立管理权限后可管理。不要复制“所有设置角色自动继承”的授予部分。
|
||||
- 新 capabilities 仅返回当前身份能力且必须登录,是否注册菜单权限需与中间件设计一致;避免将自查接口变成必须先拥有一致度权限才可调用的循环依赖。
|
||||
|
||||
## 7. 可复用测试模式(本次未运行)
|
||||
|
||||
1. **纯函数/契约测试**:`server/tests/PrescriptionAiProgressTest.php`。直接 require autoload、不初始化 App,用固定 fixture 检查允许字段;策略匹配、规范化、投影最适合此方式。
|
||||
2. **无环境隔离桩**:`server/tests/DiagnosisValidateEditSceneTest.php:9` 创建 `new think\App()` 但不 initialize;在真实类加载前以 class_alias 安装内存模型/DB/Auth double。适合验证真实入口会拒绝未授权 statistics 与配置写入,避免初始化业务 .env。
|
||||
3. **真实加密/数据库 API 集成**:`server/tests/PrescriptionAiQueueTest.php:5` 明确禁止 initialize,必须显式 `ZYT_AI_TEST_MYSQL_PORT`,只连接 `127.0.0.1` 空密码临时 MySQL,数据库名只允许 `prescription_ai_test_[a-f0-9]{12}`,显式注入隔离 DB/config 并最终清理;现有 fixture 已覆盖 statuses/reports/detail/statistics、结果 Cipher、失败模型和旧 body 注入。扩展时可添加 config/system_role 夹具、非 root 多角色及账号。
|
||||
4. **迁移幂等**:QueueTest 重放 migration 两次并检查菜单数,可直接复用新菜单和空策略迁移的验证方式。
|
||||
5. **多角色缓存边界**:`AdminMultiRoleRegressionTest.php` 说明角色 ID 去重排序及撤销角色后 token/cache 失效;新指标策略应按实时 role_id 与新策略判断,不从旧 token 缓存白名单结果。
|
||||
6. **本地配置文件测试**:`PrescriptionAiConfigurationTest.php` 使用自定义 env fixture,绝不 bootstrap。可用于确认本次没有意外改变模型启用/生成配置。
|
||||
|
||||
最低必要用例:默认空/非法策略全部不可见(包括 root);只选角色/只选用户/任一匹配/多角色并集/其它账号不匹配;禁用或软删除账号、删除角色、实时撤销;未授权仍能读既有报告内容但 statuses/reports/detail 任何层均不存在比较字段或未知原始 body;授权用户数值保持不变;未授权 statistics 拒绝而不聚合;非管理者不能读名单、改名单或列选项;root 可管理但不会因此获得 view;已有处方/来源权限撤销仍拒绝报告。加入带唯一哨兵的嵌套 comparison/unknown raw body fixture,序列化整个响应检验哨兵不存在,不能只验证 UI 字段或顶层 score。
|
||||
|
||||
不建议直接运行会 `App::initialize()` 的旧测试(例如 PrescriptionAiSecretSafetyTest)来证明本特性;本功能可以完全使用上述隔离模式完成有效验证。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 处方 AI 一致度可见范围:桌面实现交接
|
||||
|
||||
日期:2026-10-17。范围仅 `app/` 与本报告/离线截图;未修改 `admin/`、`server/`,未 commit、部署或调用生产接口。
|
||||
|
||||
## 完成行为
|
||||
|
||||
- 新增 `DoctorRepository` / `RemoteDoctorRepository.get_prescription_ai_capabilities()`,GET `tcm.prescriptionAi/capabilities`。
|
||||
- 一致度只接受服务端明确的 `agreement_visible is True`。没有本地 root、角色 ID、`*`、管理策略能力的豁免;缺失字段、非布尔 true、能力请求失败均拒绝。
|
||||
- 列表把 AI 状态与第 12 列一致度分离:状态仍按现有权限及分析开关加载,能力查询不阻塞状态请求。分数另需 capability + statuses 顶层 flag + 每批次 flag;统计按钮另需原 `statistics` 权限。
|
||||
- 列表隐藏/重显、刷新都会重查能力;重新授权会重新取已完成批次的分数,避免从已清理的缓存展示空白。
|
||||
- 共用报告(包括诊单入口)要求 reports 顶层、历史批次、detail 均明确授权。一致度受限时仍保留报告正文、模型筛选、AI 候选原文、来源与资料缺口、处理进度、历史批次选择与分页、重新分析、重试和复核。
|
||||
- 受限时总览使用原有复核清单作为“报告复核”安全骨架,隐藏结论、组成归属、剂量图、风险剂量图及差异/共识计数。原“候选与逐味”导航改为“AI 候选处方”,跳到现有候选原文页;逐味页、比较原文和历史趋势不可导航进入。
|
||||
- 顶部保留模型状态、资料状态与失败重试行,只隐藏分数、重合度、差值和仪表刻度;因此没有丢失原来附着在仪表卡上的重试按钮。
|
||||
- 未授权数据进入缓存前清除 comparison、score、herb_score、比较状态/类型、算法版本及相关聚合元数据。撤权和读取失败同时清空旧数字、图表数据、比较 HTML、历史图/表、tooltip、accessibility 值和派生计数。
|
||||
- 请求 token / generation 拒绝旧异步响应恢复分数;主题重建保持能力状态和复核草稿。
|
||||
- 统计对话框直接构造也先请求 capability;未明确授权不发 statistics 请求。每次查询先清图,能力/统计失败、隐藏窗口、旧响应都会清理或拒绝旧结果。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `app/src/doctor_workstation/services/repository.py`:能力接口协议及远程实现。
|
||||
- `app/src/doctor_workstation/ui/pages/prescriptions.py`:独立列/统计能力、列表缓存清理、异步 guard、受限入口文案。
|
||||
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py`:共享数据清理、报告各入口统一切换、统计 capability 门禁。
|
||||
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py`:仪表指标切换,同时保留状态与重试。
|
||||
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py`:复核安全骨架;旧行先清文本、hide、detach、delete,防窗口瞬闪和残留轴控件。
|
||||
- `app/tests/test_issued_prescription_ai.py`:既有授权 fixture 明确 true;新增拒绝/缺失字段/非布尔 true、root 不豁免、撤权、失败、旧回调、主题、列表重显与统计直接入口回归。
|
||||
- `app/scripts/render_issued_prescription_ai.py`:原有授权预览 fixture 明确 true。
|
||||
- `app/scripts/render_prescription_ai_visibility.py`:可重复生成授权/受限离线截图。
|
||||
|
||||
变更行使用 LF,未改变的行按 HEAD 保留原始行尾 bytes,避免仓库原有混合换行导致整文件 diff。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 核心 AI 测试,249 项通过(独立进程):
|
||||
|
||||
`app/.venv/Scripts/python.exe -m pytest app/tests/test_issued_prescription_ai.py app/tests/test_issued_prescription_ai_workspace.py app/tests/test_issued_prescription_ai_pages.py app/tests/test_issued_prescription_ai_comparison.py -q --tb=short`
|
||||
|
||||
2. 外围权限与处方界面测试,32 项通过(独立进程):
|
||||
|
||||
`app/.venv/Scripts/python.exe -m pytest app/tests/test_permissions.py app/tests/test_prescription_security_ui.py app/tests/test_prescription_list_density.py app/tests/test_prescriptions_tech_blue.py -q --tb=short`
|
||||
|
||||
3. Ruff 对上述 8 个变更 Python 文件检查通过。
|
||||
4. `git diff --check -- app` 通过。
|
||||
5. 新截图脚本离线执行通过;已目视检查受限列表、正文、候选原文、复核/重试和统计拒绝截图。
|
||||
|
||||
联合运行这 281 项时,核心及前面外围测试共 272 项无失败后,最后一组 `ShellWindow` 交互测试出现 Qt 同进程状态阻塞,已中断该联合进程;同一外围组单独执行通过。先前定位到的两个真实回归(旧控件瞬闪、旧轴残留)均已修复,相关定向测试通过。本交接采用上述两个独立进程验证结果,不宣称联合进程完整通过。
|
||||
|
||||
## 截图
|
||||
|
||||
目录:`artifacts/prescription-ai-visibility/`。全部为内存演示数据,无生产读取。
|
||||
|
||||
- `list-authorized.png` / `list-restricted.png`
|
||||
- `report-authorized.png` / `report-restricted.png`
|
||||
- `review-restricted.png`:复核清单、表单、保存与模型重试仍可用。
|
||||
- `candidate-restricted.png`:候选原文可读,无贡献/一致度。
|
||||
- `statistics-restricted.png`:直接统计入口拒绝,空图表且未请求统计。
|
||||
|
||||
服务端仍需按既定策略在 read API 屏蔽结构化比较,并在 statistics 强制校验;桌面不会从角色或登录快照重建授权。授权策略变更在下一次能力/状态/报告读取时更新显示,当前没有新增服务端推送通道。
|
||||
@@ -0,0 +1,113 @@
|
||||
# 已开处方 AI 一致度可见范围:前端只读研究
|
||||
|
||||
日期:2026-10-17。范围:桌面 `app/`、后台 `admin/` 的入口、登录元数据、配置承载点和测试;未修改业务代码,未调用任何生产接口。已阅读根目录 `AGENTS.md`;当前工作区 `.trellis/workflow.md` 不存在。
|
||||
|
||||
主任务已确定实施默认:仅一致度独立授权、保留报告;新独立设置页;角色或个人任一命中允许;空名单全隐藏;root 可管理策略,但查看分数也必须命中名单。API 使用 `GET tcm.prescriptionAi/capabilities -> {agreement_visible: bool}`,statuses/reports 顶层及每批次附该 flag,detail 顶层附 flag;未授权剔除 score/herb_score/comparison,statistics 拒绝。下文早期建议中的命名/承载页/初始行为均以上述约定为准。
|
||||
|
||||
## 结论
|
||||
|
||||
- 截图对应桌面 `PrescriptionsPage` 的第 12 列 `与 AI 一致度`。当前列显隐只跟随服务端 AI 总开关 `enabled`,没有按角色或个人控制一致度的能力。
|
||||
- 现有权限只拆分 `statuses/reports/detail/statistics/regenerate/retry/review`。`reports + detail` 共同允许打开报告,不区分“能看报告”和“能看分数”。单靠收回现有权限无法实现“保留 AI 报告、隐藏一致度”。
|
||||
- 仅隐藏列表列不完整:报告顶部双模型分数、逐味原文、总览结论、历史趋势、医生统计也展示一致度。
|
||||
- 桌面已经从 `auth.admin/mySelf` 得到用户 ID、多个角色 ID、root 和权限集合;但运行中的会话权限不适合单独承担可见范围判定。推荐服务端每次读 AI 数据返回当前账号的明确 capability,并在响应中屏蔽未授权分数;桌面统一使用这个判定渲染。
|
||||
- 后台没有现成处方 AI 配置页。最小配置入口可放现有“医生工作站”设置页单独配置区;该页目前专门管理升级包,建议新建独立读写配置接口,而不是把授权策略挂到其免登录更新检查响应。
|
||||
|
||||
## 桌面入口与显示面
|
||||
|
||||
| 位置 | 当前行为 | 仅一致度控制的接入点 |
|
||||
| --- | --- | --- |
|
||||
| `app/src/doctor_workstation/ui/pages/prescriptions.py:1050` | 顶部 `AI 一致度统计`;当前只查 `tcm.prescriptionAi/statistics` | 叠加一致度 capability;响应拒绝时清空既有统计 |
|
||||
| 同文件 `:1200`、`:1583`、`:1765`、`:1773` | 工具栏 AI 报告、每行报告按钮、右键 `AI 报告 / 逐味对照` | 若保留报告应继续可用;文案可缩成 `AI 报告`,避免暗示全部对比可见 |
|
||||
| 同文件 `:1255`–`:1273` | 第 11 列 AI 状态、第 12 列与 AI 一致度;两列初始隐藏 | 分离两列显隐,不能继续用 `_set_ai_columns` 同时切换 |
|
||||
| 同文件 `:1289`、`:1344` | 单击两列打开同一报告;自定义 cell widget 的事件过滤也处理两列 | 隐藏分数时保留状态打开报告的行为 |
|
||||
| 同文件 `:1647`–`:1762` | 只给可见处方批量拉 `statuses`,缓存后渲染;`enabled` 同时开启两列 | 读 capability、清掉旧第 12 列文本/tooltip/accessible text 及缓存中的分数,保留状态轮询 |
|
||||
| `app/src/doctor_workstation/ui/dialogs/diagnosis.py:1080`、`:1726`、`:1983` | 诊单只读页也能按 `diagnosis_id` 打开已开处方 AI 历史 | 必须走同一共享报告权限与 capability |
|
||||
| `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py:401`、`:1817` | `can_open_issued_ai` + `present_issued_prescription_ai` 是共享报告入口 | 保留 reports/detail guard,另设 `can_view_agreement`;不要把报告 guard 改成分数 guard |
|
||||
| 同文件 `:664`、`:1377`–`:1417`、`:1459` | `AgreementBar`、model score、可访问名/tooltip、原文 `comparison` 都有分数 | 分数不授权时不构建/隐藏分数区域,不填分数到隐藏控件,并清理原文内容 |
|
||||
| 同文件 `:1210`;`issued_prescription_ai_pages.py:1156`、`:1191` | `HistoryPage` 从 `reports.lists` 渲染历史分数趋势,兼容 `comparison.score` 与模型顶层 `score` | 历史仍可选;关闭趋势分数,服务端历史摘要也需要去分数 |
|
||||
| `issued_prescription_ai_workspace.py:674`–`:687` | 总览结论有双方一致率、纯药味重合度和百分点差 | 一致度受限时不得拼出这些结论 |
|
||||
| `issued_prescription_ai_pages.py:364`、`:464` | 候选与逐味页说明算法、读取贡献行 | 要确认“仅一致度”是否包括每味贡献;至少不能泄露 contribution 与算法分子/分母 |
|
||||
| `issued_prescription_ai_console.py:260`、`:295`、`:378` | 顶部仪表、无障碍名称、纯药味重合度 | 与主报告 capability 保持一致 |
|
||||
| `issued_prescription_ai.py:1705`–`:1813`;`issued_prescription_ai_pages.py:1374` | 医生统计对话框;分布、均值、中位数、明细 HTML | 现有 statistics API 权限以外还应要求一致度可见;拒绝后整个统计关闭 |
|
||||
|
||||
补充:原处方详情 `dialogs/prescription.py` 没有独立已开处方一致度入口;目前可见入口集中在上述列表和诊单共享报告。后台 `admin/src/views/consumer/prescription/components/PrescriptionAiReportDialog.vue` 是另一个旧的处方解读报告流程,当前 `admin/src/views` / `admin/src/api` 没搜到 `tcm.prescriptionAi` 或一致度 UI,不应把它误当成本次双模型已开处方分析界面。
|
||||
|
||||
## 请求边界与元数据
|
||||
|
||||
`app/src/doctor_workstation/services/repository.py:1609`–`:1648` 透传以下服务端字典,新增 capability 无需先扩大类型 DTO:
|
||||
|
||||
- `statuses(ids)`:模型顶层 `score`、`comparison_status`,用于列表。
|
||||
- `reports(prescription_id XOR diagnosis_id)`:历史摘要;传入 `page_no/page_size`。
|
||||
- `detail(batch_id)`:完整 model `comparison`,用于报告全部页面。
|
||||
- `statistics(date_from,date_to,doctor_id?)`:独立统计读接口。
|
||||
- `regenerate/retry/review` 是显式写接口,此需求若只限制读分数,不必连带禁止已有 AI 生成和复核动作。
|
||||
|
||||
现有登录承载:
|
||||
|
||||
- `core/models.py:127`–`:176` 的 `UserProfile` 已包含 `id`、`role_ids`、`root`、`permissions`、完整 `raw`;多角色解析支持 `role_ids` / `role_id` / `roles`。
|
||||
- `services/repository.py:902`–`:927` 的 `get_session()` 读取 `auth.admin/mySelf`,构造 `PermissionSet` 并存 session。
|
||||
- `core/permissions.py:63`–`:83` 支持全局 `*` 与路由前缀 `/*`。`ui/widgets.py` 的 `has_permission` 已统一前端权限语义,新增功能不要另写角色硬编码。
|
||||
- 后台 `admin/src/stores/modules/user.ts:110` 左右同样读取 `data.user/data.permissions/data.menu`。服务端 `AdminLogic.php:319` 已填 `role_ids`。
|
||||
- 注意:`mySelf` 的 metadata 只是登录时快照;服务端改授权后应通过 AI read 响应刷新 capability,不能等用户下次登录才隐藏分数。
|
||||
|
||||
## 后台配置与选择器复用
|
||||
|
||||
### 可承载配置的页面
|
||||
|
||||
`admin/src/views/setting/desktop_workstation/index.vue` 有成熟 `el-form` + `getData` + `handleSubmit` + `footer-btns` 模式,当前管理升级策略;API 在 `admin/src/api/setting/desktop_workstation.ts`,对应 `setting.desktop_workstation/getConfig,setConfig`。可新增独立“处方 AI 一致度可见范围”设置区,提供角色多选 + 用户多选,并明确 OR 语义(命中任一角色,或被指定用户,均可见)。保存权限宜独立管理,避免升级包操作员默认拥有授权分配能力。
|
||||
|
||||
若希望未来扩展整个 AI 模块范围,新建 `setting/prescription_ai` 页面更清晰;当前没有可直接扩展的同名页。`ai_mcp/grant` 只管理员工外部 AI 助手令牌撤销,并非本功能授权。
|
||||
|
||||
### 角色与用户
|
||||
|
||||
- `admin/src/api/perms/role.ts:8` 的 `roleAll({})` → `/auth.role/all` 可复用。角色多选 UI 在 `views/permission/admin/edit.vue:73`–`:91`,`optionsData.role` 来自 `roleAll`;处方页 `consumer/prescription/index.vue:3303` 也有获取及失败降级模式。
|
||||
- 当前角色配置 `views/permission/role/auth.vue` 是菜单树,保存 `menu_id` 到 `roleEdit`;适合给配置页面授予管理权限,但无法直接表达个人白名单。
|
||||
- `admin/src/api/perms/admin.ts:4` 的 `adminLists` → `/auth.admin/lists` 是已实现的人员来源。`AdminLists.php:82` 支持 `name/account` 搜索,`:158` 支持角色筛选,`:165` 支持 `exclude_disabled=1`,列表带 `id/name/account/role_id`。建议用户多选下拉做姓名/账号远程分页,显示 `姓名(账号)`。
|
||||
- **不要直接复用 `adminAll`**:前端虽导出了 `/auth.admin/all` 包装,但当前 `AdminController.php` 无 `all()`,全仓也没找到该路径映射。
|
||||
- `admin/src/components` 没有通用后台用户多选组件。可用 Element Plus `el-select multiple filterable remote` 现有模式(处方页已有远程患者选择,但其数据类型和接口不能直接当员工选择器)。
|
||||
- 现有处方模型 `visible_role_ids` 是处方审核/数据可见范围,不是分数可见范围;不要复用该字段造成整个处方行被隐藏。
|
||||
|
||||
## 仅一致度、保留 AI 报告的最小实现建议
|
||||
|
||||
1. 服务端策略保留为独立 role IDs + admin IDs;服务端返回 `can_view_agreement`(名称仅建议)而不是让客户端拼白名单。root 的默认行为、未配置的初始行为须与产品决策一致。
|
||||
2. `statuses/reports/detail` 每个响应显式带当前 capability;未授权时剔除顶层/嵌套 `score`、`herb_score`、逐味 `contribution` 以及对应聚合分母,避免旧客户端或缓存旁路。统计接口直接拒绝未授权读。`enabled` 仍只代表分析功能总开关。
|
||||
3. 桌面以独立布尔能力驱动第 12 列和统计按钮;第 11 列分析状态、AI 报告按钮与 reports/detail 权限保持现状。`_set_ai_columns` 应拆开或接受第二个参数。改变能力或请求失败时清理旧数据,不能只 `hide()`。
|
||||
4. 共享报告统一把 capability 传至 AgreementBar / Workspace / HistoryPage / CandidatesPage;受限时保留完整报告、候选处方、来源、进度、复核与历史批次选择,但去掉一致度数字、趋势、贡献和相关说明。完整报告存量文本是否可能内嵌一致度,需要服务端研究确认;如允许模型原文包含此数值,仅过滤结构化字段不够。
|
||||
5. 新后台配置区复用现有表单样式与 roleAll;人员选择用 adminLists 或专门受配置管理权限保护的最小字段 options 接口,避免配置管理员为了选人还必须拥有完整管理员列表权限。
|
||||
|
||||
待用户确认:“仅一致度”是只控制分数/重合度/贡献统计,还是连逐味剂量比较也一起隐藏;后者需要隐藏总览和候选逐味相关导航,但仍可保留报告正文。整个 AI 模块方案则简单得多:按新策略门禁所有 AI 入口和 API;不能在未确认时用此方案替代仅一致度方案。
|
||||
|
||||
## 可复用测试
|
||||
|
||||
本次只读研究未运行测试。现有测试使用内存 fake repository + `run_async` immediate fixture,不需要生产 API。
|
||||
|
||||
- `app/tests/test_issued_prescription_ai.py:440` `test_list_batches_visible_ids_and_stops_on_hide`:可延伸验证受限账号仍拉状态、只隐藏第 12 列。
|
||||
- 同文件 `:462`、`:500`、`:525`、`:564`:空处方、总开关关闭、缺少 statuses 权限的入口及列显隐基线。
|
||||
- 同文件 `:428` `test_view_permission_cannot_mutate`:保持 reports/detail 与写权限解耦。
|
||||
- 同文件 `:1178` `test_access_revocation_clears_reports_and_all_progress`:可延伸为只撤销分数能力后清空数字,但正文保留。
|
||||
- 同文件 `:596`、`:796`、`:977`:统计 null/版本分层/排除原因;补未授权统计不发请求且清空旧图。
|
||||
- 同文件 `:626` `test_repository_exact_read_and_mutation_contracts`:可加服务端 capability 透传及不误改 mutation 请求。
|
||||
- 同文件 `:697`、`:714`、`:1236`:追加列表缓存、右键目标、tooltip;可防分数在缓存/无障碍文本中残留。
|
||||
- `app/tests/test_issued_prescription_ai_pages.py:288`–`:326`:历史读取嵌套和扁平两种分数;`:361`–`:418`:统计图表。新增受限 capability 时两种数据形状都不显示分数。
|
||||
- `app/tests/test_issued_prescription_ai_workspace.py:239`:不捏造一致率差值;可加受限时不生成现有分数结论。
|
||||
- `app/tests/test_permissions.py`:`*`、前缀通配符、AND/OR、空权限语义;如果新增菜单按钮权限应保持兼容。
|
||||
- `app/tests/test_repository_parity.py`:可复用登录/session 的用户 ID、role_ids 解析测试结构。
|
||||
- 后台当前无处方 AI 授权配置测试;`admin/tests/*.test.cjs` 是 Node 内建测试模式,`admin/package.json` 有 `type-check`,没有统一 test script。应新增只针对配置读写归一化、空选择与人员搜索的必要测试,避免写只镜像模板的快照测试。
|
||||
|
||||
## 实施交接:最小切换与 fixture
|
||||
|
||||
1. `issued_prescription_ai.py` 增加统一 `_apply_agreement_visibility()`(建议名)。`_build_ui` 默认隐藏;`_history_ready` / `_detail_ready` 应用 `agreement_visible is True`。缺失 flag 按 false;主题切换 `_switch_theme` 会重建 UI,必须继承能力并重新应用。从有权切成无权时,先清空旧 payload/图,再渲染正文。
|
||||
2. 顶部 `self.agreement` 与 `self.agreement_note` 一起隐藏。注意每个模型的 **retry 按钮目前经 `self.agreement.attach_action(key,retry)` 放在仪表区域**(`:891`);如要求保留重试,需要移到进度页/独立状态区域。
|
||||
3. 导航为 `NAV_PRIMARY`(`:68`),`self.nav_buttons` 是 `self.rail.buttons`(`:738`);overview/per_herb/history 都不是普通可见 tab,而是 rail 控制隐藏 tabBar 的 `QTabWidget`。隐藏导航后还要给 `_goto`(`:1065`)加拒绝/回落到 report,防 `_open_supporting_report`(`:1097`)和主题恢复旧页绕过。
|
||||
4. 最小完整隔离方式:受限时隐藏 overview 与 per_herb 导航/页面、comparison_raw 原文页;默认切 report。报告原文、candidate、sources/gaps、pipeline、original 继续可用。`HistoryPage` 可保持批次列表但隐藏图和 3/4 分数列;更省改动可隐藏 history 页面,顶部 `self.history` 批次选择与分页仍保持完整历史访问。
|
||||
5. **不能直接隐藏 overview 就结束**:`review_model` 与完整 review 表单在 `:898`–`:899` 被 reparent 到 `comparison_panel.checklist`。隐藏总览会丢失现有复核 UI。若保留复核,需把 selector + review widget 移到正文旁/独立复核页;或保留 overview 骨架,只隐藏 `conclusions/attribution/doses/risks` 及 comparison 链接,保留 checklist。`PrescriptionReviewWorkspace` 的这些已命名属性在 `issued_prescription_ai_workspace.py:1159`–`:1173`,有 `set_review_selector/set_review_widget`(`:1245`、`:1252`)。
|
||||
6. `StepRail` 的 `focus_values`(`issued_prescription_ai_console.py:475`)与 `_render_console` 的 badges/focus 含差异、共识药味等派生计数。若 comparison 整体移除,清空/隐藏相关焦点行,避免把它们误显示为 0。保留来源缺口和报告数量即可。
|
||||
7. `HistoryPage`(pages.py:1145)左 card 当前是局部变量 `left`;如仅隐藏趋势需将其保存为实例属性。表格 3/4 列是两模型分数,5 列算法;`strata` 和 `note` 也有“分数可以比较”等文案,受限时应清理。`set_history` 即使拿到剔除 comparison 的历史,也会保留这些文案,不能依赖没有数字自然隐藏。
|
||||
8. `CandidatesPage` 的 `cards` 显示原方/候选处方;下方表格同时混合剂量与贡献。若保留候选卡片,则只隐藏/跳过逐味 body;否则整页隐藏最少。候选独立原文页 `tab_pages['candidate']` 依旧能保留原始候选数据。
|
||||
9. `issued_prescription_ai.py:1414`–`:1417` 的 comparison HTML 应清空并跳过填充,而非仅藏页面。主界面 `model_views[*]['score']` 的 text/tooltip/accessibility、AgreementBar 内 gauge 和历史 `TrendBars` 都要拿空数据重置,避免 capability 撤销后留下旧值。
|
||||
|
||||
可直接扩展 fixture:
|
||||
|
||||
- `app/tests/test_issued_prescription_ai.py:24` 的 `batch()`,`:45` 的 `Repository`、`:86` 的 `no_network`(拒绝 socket)、`:95` 的 `immediate`;Repository 加 `agreement_visible` 属性和 capabilities 方法,read 字典统一附 flag。该组合最适合测从有权到无权的异步回调、缓存清理和主题重建。
|
||||
- `app/tests/test_issued_prescription_ai_pages.py:35` 的 `batch(**overrides)`、`:66` 的 `per_herb`、`history` fixture,可用同一批次先 true 再 false,确认 score fallback 两种形状都无残留。
|
||||
- `app/tests/test_issued_prescription_ai_workspace.py:25` 的 `batch(count)` 与 `:43` 的 `workspace` fixture 带真实保存逐味 identity 和 review slot;适合确认受限骨架不影响 review、report links 与布局。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 处方 AI 一致度授权:验收记录
|
||||
|
||||
日期:2026-10-17。
|
||||
|
||||
## 用户确认范围
|
||||
|
||||
仅控制“与 AI 一致度”、一致度详情(逐味对照、贡献、差异等)与一致度统计,保留 AI 分析报告。授权对象可以是指定系统角色,也可以是具体后台用户,任一命中即可。
|
||||
|
||||
## 完成行为
|
||||
|
||||
- 新增后台“系统设置 → 处方AI一致度授权”页面,使用角色、用户两个可搜索多选框配置名单。
|
||||
- 默认空名单对所有账号隐藏,包括 root;管理策略的权限与查看分数的权限分开。
|
||||
- 服务端每次读取检查有效账号与实时角色归属。原有报告动作权限、患者/处方/来源范围继续执行,白名单不会扩大数据范围。
|
||||
- 未授权账号的状态、历史和详情响应移除一致度及其派生数据,统计请求在聚合前拒绝;模型报告、AI 候选方案、进度、来源、重试、重新分析与复核保留。
|
||||
- 桌面按服务端明确的布尔能力控制列、详情、导航和统计;缺失、失败按不可见处理。撤权后清理旧图表、比较原文、缓存、提示与可访问数据,旧异步回调不会恢复分数。
|
||||
- 最终复核补齐了桌面缓存中比较原因、原因码、药材字典版本与基线序列化字段的清理,并增加保留报告/生成失败说明、不修改原输入的回归。
|
||||
|
||||
策略变更在下一次能力/状态/报告读取时反映到桌面;没有新增推送通道。
|
||||
|
||||
## 验证
|
||||
|
||||
| 范围 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 最终桌面核心 AI 组合(主代理复跑) | 250 passed,14.57s,exit 0 |
|
||||
| 桌面外围权限/处方界面(独立进程) | 32 passed |
|
||||
| 后台实际 Vue setup/API 模拟(主代理复跑) | 8/8 passed |
|
||||
| PHP 纯离线权限/投影(主代理复跑) | 22 checks passed |
|
||||
| 隔离 MySQL 真实 ORM/Cipher/Logic/Controller(主代理复跑) | 113 checks passed |
|
||||
| 后端 worker Pipeline / 旧进度 schema | 125 / 111 checks passed |
|
||||
| 后端比较、统计、进度、配置回归 | 通过,详见后端报告 |
|
||||
| Ruff、PHP lint、限定任务文件 diff 检查 | 通过 |
|
||||
| 本地后台浏览器与桌面离屏截图 | 已检查授权/受限、报告保留、重试/复核、统计拒绝 |
|
||||
|
||||
临时 MySQL 仅绑定 127.0.0.1:33447,独立初始化且不读取生产配置;临时数据库由测试清理,进程已由主代理正常关闭。未调用生产数据库、模型接口或发布流程。
|
||||
|
||||
已知验证边界:将桌面核心与 ShellWindow 外围组放在同一进程运行时,最后一组发生 Qt 阻塞;分进程全部通过。后台全量类型检查有 61 项范围外错误,新增文件的严格模板类型检查通过。当前工作区还有其它任务在修改订单辅方袋数,本验收没有修改其文件;最终 diff 检查限定本任务文件。
|
||||
|
||||
## 上线与配置
|
||||
|
||||
代码尚未部署。需要执行 [幂等迁移](../server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql),部署服务端及后台,并更新桌面端。迁移创建菜单和空策略,不自动把任何角色或账号加入查看名单。
|
||||
|
||||
管理员进入“系统设置 → 处方AI一致度授权”,选择角色或用户并保存后,相应账号在下次读取时显示一致度。已有处方和报告无需重新生成。
|
||||
|
||||
## 交付与预览
|
||||
|
||||
- [后端实施与测试](prescription-ai-visibility-backend-implementation.md)
|
||||
- [后台实施与测试](prescription-ai-visibility-admin-implementation.md)
|
||||
- [桌面实施与测试](prescription-ai-visibility-desktop-implementation.md)
|
||||
- [后台授权配置预览](prescription-ai-visibility-admin-local.png)
|
||||
- [受限账号处方列表](prescription-ai-visibility/list-restricted.png)
|
||||
- [受限账号保留的报告](prescription-ai-visibility/report-restricted.png)
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -6,10 +6,32 @@ namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\tcm\PrescriptionAiLogic;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility;
|
||||
use DomainException;
|
||||
|
||||
final class PrescriptionAiController extends BaseAdminController
|
||||
{
|
||||
public function capabilities()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::capabilities($this->adminId));
|
||||
}
|
||||
|
||||
public function getVisibilityConfig()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::getConfig($this->adminId));
|
||||
}
|
||||
|
||||
public function setVisibilityConfig()
|
||||
{
|
||||
return $this->handle(true, ['role_ids', 'admin_ids'],
|
||||
fn (array $p): array => PrescriptionAiVisibility::setConfig($p, $this->adminId));
|
||||
}
|
||||
|
||||
public function visibilityOptions()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::options($this->adminId));
|
||||
}
|
||||
|
||||
public function statuses()
|
||||
{
|
||||
return $this->handle(false, ['ids'], function (array $p): array {
|
||||
|
||||
@@ -235,7 +235,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
|
||||
* 任意履约状态下修改主辅方服用参数(浓缩水丸袋数、次数、开立天数)及订单服用天数,并记录日志
|
||||
*/
|
||||
public function patchPrescriptionUsage()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStatistics;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -22,15 +23,16 @@ final class PrescriptionAiLogic
|
||||
public static function statuses(array $ids, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('statuses', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
if (!Store::enabled()) {
|
||||
return ['enabled' => false, 'items' => []];
|
||||
return ['enabled' => false, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
|
||||
throw new DomainException('最多查询100个有效处方');
|
||||
}
|
||||
if ($ids === []) {
|
||||
return ['enabled' => true, 'items' => []];
|
||||
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||
}
|
||||
$subjects = Db::name('prescription_ai_subject')->whereIn('prescription_id', $ids)->column('latest_batch_id', 'prescription_id');
|
||||
$batches = $subjects ? Db::name('prescription_ai_batch')->whereIn('id', array_values($subjects))->select()->toArray() : [];
|
||||
@@ -50,18 +52,20 @@ final class PrescriptionAiLogic
|
||||
// Do not reveal that a report with a broader source scope exists.
|
||||
continue;
|
||||
}
|
||||
$items[] = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
|
||||
$item = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
|
||||
'prescription_id' => $id, 'batch_id' => null,
|
||||
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
|
||||
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
|
||||
];
|
||||
$items[] = Visibility::projectBatch($item, $agreementVisible);
|
||||
}
|
||||
return ['enabled' => true, 'items' => $items];
|
||||
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => $items];
|
||||
}
|
||||
|
||||
public static function reports(array $params, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('reports', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
$rxId = (int) ($params['prescription_id'] ?? 0);
|
||||
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||
if (($rxId > 0) === ($diagnosisId > 0)) {
|
||||
@@ -100,22 +104,24 @@ final class PrescriptionAiLogic
|
||||
} while (count($chunk) === 200);
|
||||
$rows = array_slice($visible, ($page - 1) * $size, $size);
|
||||
$modelMap = self::models(array_column($rows, 'id'), false);
|
||||
return ['lists' => array_map(static fn ($b): array => self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $rows),
|
||||
return ['agreement_visible' => $agreementVisible,
|
||||
'lists' => array_map(static fn ($b): array => Visibility::projectBatch(self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $agreementVisible), $rows),
|
||||
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
|
||||
}
|
||||
|
||||
public static function detail(int $batchId, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
$batch = self::loadBatch($batchId, $actor, $info);
|
||||
$models = self::models([$batchId], true);
|
||||
$models = self::models([$batchId], true, $agreementVisible);
|
||||
$detail = self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
|
||||
// loadBatch has checked both current prescription and historical source permissions.
|
||||
// This cipher is saved at enqueue time, so it is available before models start and
|
||||
// remains the correct original when the user selects an older report revision.
|
||||
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
|
||||
: DoctorSnapshot::project((new Cipher())->decrypt($batch['prescription_cipher'], 'prescription'));
|
||||
return $detail;
|
||||
return Visibility::projectBatch($detail, $agreementVisible);
|
||||
}
|
||||
|
||||
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
|
||||
@@ -205,6 +211,9 @@ final class PrescriptionAiLogic
|
||||
|
||||
public static function statistics(array $params, int $actor, array $info): array
|
||||
{
|
||||
if (!Visibility::agreementVisible($actor)) {
|
||||
throw new DomainException('无权查看处方AI一致度统计');
|
||||
}
|
||||
self::requirePermission('statistics', $actor, $info);
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
|
||||
@@ -271,7 +280,7 @@ final class PrescriptionAiLogic
|
||||
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
|
||||
}
|
||||
|
||||
private static function models(array $batchIds, bool $full): array
|
||||
private static function models(array $batchIds, bool $full, bool $agreementVisible = true): array
|
||||
{
|
||||
if ($batchIds === []) {
|
||||
return [];
|
||||
@@ -301,7 +310,7 @@ final class PrescriptionAiLogic
|
||||
foreach ($results as $result) {
|
||||
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
||||
if ($full) {
|
||||
$body = self::localizeSourceMetadata($body);
|
||||
$body = Visibility::reportBody(self::localizeSourceMetadata($body), $agreementVisible);
|
||||
}
|
||||
unset($result['body_cipher']);
|
||||
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
||||
|
||||
@@ -5763,7 +5763,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
|
||||
* 业务订单详情场景:任意履约状态下更新主辅方服用参数,变更必须写入操作日志
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
@@ -5774,7 +5774,8 @@ class PrescriptionOrderLogic
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo),
|
||||
false // 服用参数允许在提交药房后修正,继续保留订单/提交记录行锁与事务。
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
@@ -5799,21 +5800,13 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status === 4) {
|
||||
self::setError('已取消的订单不可修改');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) ($order->prescription_id ?? 0);
|
||||
if ($rxId <= 0) {
|
||||
self::setError('该订单未关联处方');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$rx) {
|
||||
self::setError('处方不存在');
|
||||
|
||||
@@ -5828,25 +5821,37 @@ class PrescriptionOrderLogic
|
||||
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
||||
$usageDays = (int) ($params['usage_days'] ?? 0);
|
||||
$medDays = (int) ($params['medication_days'] ?? 0);
|
||||
if ($timesPerDay < 1 || $timesPerDay > 6) {
|
||||
if (!self::isPrescriptionUsageInteger($params['times_per_day'] ?? null, 6)) {
|
||||
self::setError('主方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($usageDays < 1 || $usageDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['usage_days'] ?? null, 999)) {
|
||||
self::setError('主方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($medDays < 1 || $medDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['medication_days'] ?? null, 999)) {
|
||||
self::setError('订单服用天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$isWaterPill = (string) $rx->prescription_type === '浓缩水丸';
|
||||
$dosageBagCount = null;
|
||||
if ($isWaterPill && array_key_exists('dosage_bag_count', $params)) {
|
||||
if (!self::isPrescriptionUsageInteger($params['dosage_bag_count'], 5)) {
|
||||
self::setError('主方袋数须为 1~5 的整数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$dosageBagCount = (int) $params['dosage_bag_count'];
|
||||
}
|
||||
|
||||
$hasAux = self::prescriptionHasAuxFormula($rx);
|
||||
$auxTimesPerDay = null;
|
||||
$auxUsageDays = null;
|
||||
$auxDosageBagCount = null;
|
||||
if ($hasAux) {
|
||||
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
||||
self::setError('含辅方处方须填写辅方服用参数');
|
||||
@@ -5855,20 +5860,29 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
||||
$auxUsageDays = (int) $params['aux_usage_days'];
|
||||
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_times_per_day'], 6)) {
|
||||
self::setError('辅方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_usage_days'], 999)) {
|
||||
self::setError('辅方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($isWaterPill && array_key_exists('aux_dosage_bag_count', $params)) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_dosage_bag_count'], 5)) {
|
||||
self::setError('辅方袋数须为 1~5 的整数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$auxDosageBagCount = (int) $params['aux_dosage_bag_count'];
|
||||
}
|
||||
}
|
||||
|
||||
$oldTimes = (int) ($rx->times_per_day ?? 0);
|
||||
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
||||
$oldDosageBagCount = max(1, (int) ($rx->dosage_bag_count ?? 1));
|
||||
$oldMedDays = (int) ($order->medication_days ?? 0);
|
||||
$oldAuxUsage = $rx->aux_usage;
|
||||
if (is_string($oldAuxUsage)) {
|
||||
@@ -5880,16 +5894,48 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
||||
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
|
||||
$oldAuxDosageBagCount = max(1, (int) ($oldAuxUsage['dosage_bag_count'] ?? 1));
|
||||
|
||||
$changes = [
|
||||
'主方每天次数' => [$oldTimes, $timesPerDay, '次'],
|
||||
'主方开立天数' => [$oldUsageDays, $usageDays, '天'],
|
||||
];
|
||||
if ($dosageBagCount !== null) {
|
||||
$changes['主方袋数'] = [$oldDosageBagCount, $dosageBagCount, '袋'];
|
||||
}
|
||||
if ($hasAux) {
|
||||
$changes['辅方每天次数'] = [$oldAuxTimes, $auxTimesPerDay, '次'];
|
||||
$changes['辅方开立天数'] = [$oldAuxUsageDays, $auxUsageDays, '天'];
|
||||
if ($auxDosageBagCount !== null) {
|
||||
$changes['辅方袋数'] = [$oldAuxDosageBagCount, $auxDosageBagCount, '袋'];
|
||||
}
|
||||
}
|
||||
$changes['订单服用天数'] = [$oldMedDays, $medDays, '天'];
|
||||
$parts = [];
|
||||
foreach ($changes as $label => [$oldValue, $newValue, $unit]) {
|
||||
if ($oldValue !== $newValue) {
|
||||
$parts[] = sprintf('%s %d%s → %d%s', $label, $oldValue, $unit, $newValue, $unit);
|
||||
}
|
||||
}
|
||||
if ($parts === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$rxUpdates = [
|
||||
'times_per_day' => $timesPerDay,
|
||||
'usage_days' => $usageDays,
|
||||
];
|
||||
if ($dosageBagCount !== null) {
|
||||
$rxUpdates['dosage_bag_count'] = $dosageBagCount;
|
||||
}
|
||||
if ($hasAux) {
|
||||
$auxUsage = $oldAuxUsage;
|
||||
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
||||
$auxUsage['usage_days'] = $auxUsageDays;
|
||||
if ($auxDosageBagCount !== null) {
|
||||
$auxUsage['dosage_bag_count'] = $auxDosageBagCount;
|
||||
}
|
||||
$rxUpdates['aux_usage'] = $auxUsage;
|
||||
}
|
||||
$rx->save($rxUpdates);
|
||||
@@ -5897,31 +5943,8 @@ class PrescriptionOrderLogic
|
||||
$order->medication_days = $medDays;
|
||||
$order->save();
|
||||
|
||||
$parts = [
|
||||
sprintf(
|
||||
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldTimes > 0 ? $oldTimes : 0,
|
||||
$oldUsageDays > 0 ? $oldUsageDays : 0,
|
||||
$timesPerDay,
|
||||
$usageDays
|
||||
),
|
||||
];
|
||||
if ($hasAux) {
|
||||
$parts[] = sprintf(
|
||||
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
|
||||
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
|
||||
$auxTimesPerDay,
|
||||
$auxUsageDays
|
||||
);
|
||||
}
|
||||
$parts[] = sprintf(
|
||||
'订单设置 %d天 → %d天',
|
||||
$oldMedDays > 0 ? $oldMedDays : 0,
|
||||
$medDays
|
||||
);
|
||||
$summary = '服用参数:' . implode(';', $parts);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary, true);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -5931,6 +5954,13 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
private static function isPrescriptionUsageInteger($value, int $max): bool
|
||||
{
|
||||
return (is_int($value) || is_string($value))
|
||||
&& preg_match('/^[1-9][0-9]*$/D', (string) $value) === 1
|
||||
&& (int) $value <= $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
|
||||
*/
|
||||
|
||||
@@ -100,7 +100,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'uploadToPharmacy' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'],
|
||||
'updateAmount' => ['id', 'amount'],
|
||||
'setShipMode' => ['id', 'ship_mode'],
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
@@ -133,14 +133,28 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
->append('express_company', 'max:20');
|
||||
}
|
||||
|
||||
public function patchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
public function scenePatchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
|
||||
$this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('times_per_day', 'require|integer|between:1,6')
|
||||
->append('usage_days', 'require|integer|between:1,999')
|
||||
->append('medication_days', 'require|integer|between:1,999')
|
||||
->append('aux_times_per_day', 'integer|between:1,6')
|
||||
->append('aux_usage_days', 'integer|between:1,999');
|
||||
|
||||
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
|
||||
$this->append($field, [static function ($value, array $data) use ($field, $label) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (is_int($value) || is_string($value)) && preg_match('/^[1-5]$/D', (string) $value) === 1
|
||||
? true
|
||||
: $label . '袋数须为 1~5 的整数';
|
||||
}]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\prescriptionai;
|
||||
|
||||
use app\common\service\ConfigService;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** Independent access to agreement metrics; never grants access to a patient or report. */
|
||||
final class PrescriptionAiVisibility
|
||||
{
|
||||
public const CONFIG_TYPE = 'prescription_ai_visibility';
|
||||
public const CONFIG_NAME = 'agreement_policy';
|
||||
public const MAX_ROLES = 500;
|
||||
public const MAX_ADMINS = 5000;
|
||||
|
||||
private const COMPARISON_FIELDS = ['comparison', 'score', 'herb_score', 'comparison_status',
|
||||
'comparison_reason_code', 'comparison_type', 'baseline_eligible', 'baseline_exclusion_reasons',
|
||||
'baseline_exclusions_json', 'algorithm_version', 'dictionary_version',
|
||||
'raw', 'raw_body', 'raw_response', 'result', 'response', 'body', 'body_cipher', 'context_cipher', 'progress_cipher'];
|
||||
|
||||
// Scalar leaves and explicitly shaped lists prevent historical/debug body fields from escaping at any depth.
|
||||
private const REPORT_BODY_SCHEMA = [
|
||||
'report' => ['summary' => true, 'diagnosis' => true, 'treatment_advice' => true,
|
||||
'risk_assessment' => ['*' => ['label' => true, 'level' => true, 'evidence_references' => ['*' => true]]],
|
||||
'evidence_references' => ['*' => true], 'missing_information' => ['*' => true]],
|
||||
'candidate' => ['status' => true, 'reason' => true, 'prescription_name' => true, 'prescription_type' => true,
|
||||
'dose_basis' => true, 'usage_instruction' => true, 'times_per_day' => true, 'usage_days' => true,
|
||||
'rationale' => true, 'risk_warnings' => ['*' => true], 'evidence_references' => ['*' => true],
|
||||
'herbs' => ['*' => ['name' => true, 'dosage' => true, 'unit' => true, 'dose_basis' => true,
|
||||
'processing' => true, 'formula_type' => true, 'instructions' => true, 'evidence_references' => ['*' => true]]]],
|
||||
'coverage' => ['status' => true, 'complete' => true, 'source_complete' => true, 'source_ids' => ['*' => true],
|
||||
'token_budget_method' => true, 'clinical_interpretation_verified' => true,
|
||||
'missing' => ['*' => ['source_id' => true, 'code' => true, 'critical' => true]],
|
||||
'files' => ['*' => ['file_id' => true, 'type' => true, 'status' => true, 'transmitted' => true,
|
||||
'version_verified' => true, 'reason' => true]]],
|
||||
'usage' => ['total_calls' => true, 'calls' => ['*' => ['stage' => true, 'input_hash' => true, 'latency_ms' => true,
|
||||
'ok' => true, 'file_count' => true, 'input_token_upper_bound' => true, 'error_code' => true,
|
||||
'usage' => ['prompt_tokens' => true, 'completion_tokens' => true, 'total_tokens' => true]]]],
|
||||
'knowledge_source' => ['title' => true, 'url' => true, 'path' => true, 'commit' => true, 'sections' => ['*' => true]],
|
||||
'ancient_book_sources' => ['*' => ['title' => true, 'chapter' => true, 'excerpt' => true,
|
||||
'path' => true, 'line' => true, 'commit' => true, 'url' => true]],
|
||||
];
|
||||
|
||||
public static function capabilities(int $adminId): array
|
||||
{
|
||||
$actor = PrescriptionAiAccess::actor($adminId);
|
||||
if ($actor === null) {
|
||||
throw new DomainException('账号已停用或无权访问');
|
||||
}
|
||||
return ['agreement_visible' => self::visibleForActor($adminId),
|
||||
'can_manage_visibility' => PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')];
|
||||
}
|
||||
|
||||
public static function agreementVisible(int $adminId): bool
|
||||
{
|
||||
return PrescriptionAiAccess::actor($adminId) !== null && self::visibleForActor($adminId);
|
||||
}
|
||||
|
||||
public static function getConfig(int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
return self::loadPolicy();
|
||||
}
|
||||
|
||||
public static function setConfig(array $params, int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
$policy = self::normalizePolicy($params);
|
||||
foreach (['role_ids' => 'system_role', 'admin_ids' => 'admin'] as $field => $table) {
|
||||
if ($policy[$field] === []) {
|
||||
continue;
|
||||
}
|
||||
$query = Db::name($table)->whereIn('id', $policy[$field])->whereNull('delete_time');
|
||||
if ($table === 'admin') {
|
||||
$query->where('disable', 0);
|
||||
}
|
||||
if ((int) $query->count() !== count($policy[$field])) {
|
||||
throw new DomainException($table === 'admin' ? '授权用户不存在或已停用,请移除失效用户' : '授权角色不存在,请移除失效角色');
|
||||
}
|
||||
}
|
||||
// Store one object so readers never observe half of a role/user policy update.
|
||||
ConfigService::set(self::CONFIG_TYPE, self::CONFIG_NAME, $policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
public static function options(int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
// The endpoint is intentionally bounded. Never silently truncate an authorization selector.
|
||||
$roles = Db::name('system_role')->whereNull('delete_time')->field('id,name')->order('id')
|
||||
->limit(self::MAX_ROLES + 1)->select()->toArray();
|
||||
$admins = Db::name('admin')->whereNull('delete_time')->where('disable', 0)->field('id,name,account')->order('id')
|
||||
->limit(self::MAX_ADMINS + 1)->select()->toArray();
|
||||
if (count($roles) > self::MAX_ROLES || count($admins) > self::MAX_ADMINS) {
|
||||
throw new DomainException('授权选项超过上限(500个角色、5000个用户),请联系管理员');
|
||||
}
|
||||
return ['roles' => array_map(static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $roles),
|
||||
'admins' => array_map(static fn (array $a): array => ['id' => (int) $a['id'], 'name' => (string) $a['name'],
|
||||
'account' => (string) $a['account']], $admins)];
|
||||
}
|
||||
|
||||
public static function normalizePolicy(array $params): array
|
||||
{
|
||||
if (array_diff(array_keys($params), ['role_ids', 'admin_ids']) !== []
|
||||
|| !array_key_exists('role_ids', $params) || !array_key_exists('admin_ids', $params)) {
|
||||
throw new DomainException('授权配置须包含且仅包含角色和用户列表');
|
||||
}
|
||||
return ['role_ids' => self::ids($params['role_ids'], self::MAX_ROLES),
|
||||
'admin_ids' => self::ids($params['admin_ids'], self::MAX_ADMINS)];
|
||||
}
|
||||
|
||||
/** Build the response from known saved-body fields, never merge an arbitrary decrypted object. */
|
||||
public static function reportBody(array $body, bool $agreementVisible): array
|
||||
{
|
||||
$projected = self::projectValue($body, self::REPORT_BODY_SCHEMA);
|
||||
if ($agreementVisible && array_key_exists('comparison', $body)) {
|
||||
$projected['comparison'] = $body['comparison'];
|
||||
}
|
||||
return $projected;
|
||||
}
|
||||
|
||||
public static function projectBatch(array $batch, bool $agreementVisible): array
|
||||
{
|
||||
if (!$agreementVisible) {
|
||||
$batch = self::withoutComparison($batch);
|
||||
foreach ($batch['models'] ?? [] as $key => $model) {
|
||||
// A failed task's reason is a generation error; a successful result's reason is a comparison.
|
||||
if (($model['error_code'] ?? '') === '') {
|
||||
unset($batch['models'][$key]['reason']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$batch['agreement_visible'] = $agreementVisible;
|
||||
return $batch;
|
||||
}
|
||||
|
||||
private static function withoutComparison(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $item) {
|
||||
if (in_array($key, self::COMPARISON_FIELDS, true)) {
|
||||
unset($value[$key]);
|
||||
} elseif (is_array($item)) {
|
||||
$value[$key] = self::withoutComparison($item);
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function projectValue($value, $schema)
|
||||
{
|
||||
if ($schema === true) {
|
||||
return is_scalar($value) || $value === null ? $value : null;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
if (array_key_exists('*', $schema)) {
|
||||
return array_map(static fn ($item) => self::projectValue($item, $schema['*']), array_values($value));
|
||||
}
|
||||
$result = [];
|
||||
foreach ($schema as $key => $shape) {
|
||||
if (array_key_exists($key, $value)) {
|
||||
$result[$key] = self::projectValue($value[$key], $shape);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function requireManage(int $adminId): void
|
||||
{
|
||||
$actor = PrescriptionAiAccess::actor($adminId);
|
||||
if ($actor === null || !PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')) {
|
||||
throw new DomainException('无权管理处方AI一致度授权');
|
||||
}
|
||||
}
|
||||
|
||||
private static function visibleForActor(int $adminId): bool
|
||||
{
|
||||
$policy = self::loadPolicy();
|
||||
if (in_array($adminId, $policy['admin_ids'], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($policy['role_ids'] === []) {
|
||||
return false;
|
||||
}
|
||||
// Read current membership and active roles, never the login token's role snapshot.
|
||||
return Db::name('admin_role')->alias('ar')->join('system_role r', 'r.id=ar.role_id')
|
||||
->where('ar.admin_id', $adminId)->whereIn('r.id', $policy['role_ids'])->whereNull('r.delete_time')->count() > 0;
|
||||
}
|
||||
|
||||
private static function loadPolicy(): array
|
||||
{
|
||||
$empty = ['role_ids' => [], 'admin_ids' => []];
|
||||
$stored = ConfigService::get(self::CONFIG_TYPE, self::CONFIG_NAME, $empty);
|
||||
if (!is_array($stored)) {
|
||||
return $empty;
|
||||
}
|
||||
try {
|
||||
return self::normalizePolicy($stored);
|
||||
} catch (DomainException $e) {
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static function ids($raw, int $max): array
|
||||
{
|
||||
if (!is_array($raw) || !array_is_list($raw) || count($raw) > $max) {
|
||||
throw new DomainException('授权列表格式或数量无效');
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($raw as $id) {
|
||||
if ((!is_int($id) && !is_string($id)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $id)) {
|
||||
throw new DomainException('授权标识须为有效正整数');
|
||||
}
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Agreement visibility is a separate allowlist. This migration grants no role access.
|
||||
START TRANSACTION;
|
||||
|
||||
SET @rx_ai_visibility_setting := (
|
||||
SELECT id FROM zyt_system_menu
|
||||
WHERE type='M' AND (paths IN ('setting','/setting') OR name='系统设置')
|
||||
ORDER BY CASE WHEN paths IN ('setting','/setting') THEN 0 ELSE 1 END,id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO zyt_system_menu
|
||||
(pid,type,name,icon,sort,perms,paths,component,selected,params,is_cache,is_show,is_disable,create_time,update_time)
|
||||
SELECT COALESCE(@rx_ai_visibility_setting,0),'C','处方AI一致度授权','el-icon-Lock',91,
|
||||
'tcm.prescriptionAi/manageVisibility','prescription_ai_visibility','setting/prescription_ai_visibility/index',
|
||||
'','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zyt_system_menu WHERE perms='tcm.prescriptionAi/manageVisibility'
|
||||
);
|
||||
|
||||
-- Seed one complete empty policy. Runtime ConfigService writes update this same row.
|
||||
INSERT INTO zyt_config (type,name,value,create_time,update_time)
|
||||
SELECT 'prescription_ai_visibility','agreement_policy','{"role_ids":[],"admin_ids":[]}',UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zyt_config WHERE type='prescription_ai_visibility' AND name='agreement_policy'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -11,6 +11,9 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
||||
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||
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 think\Container;
|
||||
use think\facade\Db;
|
||||
@@ -56,11 +59,15 @@ $root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' =>
|
||||
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_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_dept (admin_id INT,dept_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('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');
|
||||
@@ -86,6 +93,87 @@ try {
|
||||
if (trim($statement) !== '') { $pdo->exec($statement); }
|
||||
}
|
||||
$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) {
|
||||
$progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql');
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
@@ -225,6 +313,45 @@ try {
|
||||
'report body cannot override trusted task progress');
|
||||
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');
|
||||
$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);
|
||||
$o2 = Store::claimTask('openai');
|
||||
$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();
|
||||
$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(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";
|
||||
} finally {
|
||||
$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}`");
|
||||
}
|
||||