更新bug
@@ -103,40 +103,40 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门订单量占比</h2>
|
||||
<p>按订单创建人归属,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 单</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
<section v-if="showRankings" class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}订单量占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
|
||||
<strong><span>{{ formatNumber(item.value) }} 单</span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门金额占比</h2>
|
||||
<p>仅统计未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}金额占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
|
||||
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
|
||||
</article>
|
||||
</section>
|
||||
@@ -156,7 +156,7 @@
|
||||
default-expand-all
|
||||
class="detail-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="280" fixed="left">
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
|
||||
{{ row.name }}
|
||||
@@ -178,34 +178,34 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="120" align="right">
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="104" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开口率" min-width="96" align="right">
|
||||
<el-table-column label="开口率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号率" min-width="96" align="right">
|
||||
<el-table-column label="挂号率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊率" min-width="96" align="right">
|
||||
<el-table-column label="面诊率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约率" min-width="96" align="right">
|
||||
<el-table-column label="预约率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊接诊率" min-width="116" align="right">
|
||||
<el-table-column label="面诊接诊率" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接诊率" min-width="96" align="right">
|
||||
<el-table-column label="接诊率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ROI" min-width="86" align="right">
|
||||
<el-table-column label="ROI" min-width="72" align="right">
|
||||
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
|
||||
@@ -291,9 +291,9 @@ type MediaChannelOption = {
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden'
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
@@ -364,8 +364,13 @@ const mediaChannelGroups = computed(() => {
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
})
|
||||
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
|
||||
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
|
||||
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
color: ['#0f9185', '#2f78df'],
|
||||
@@ -447,9 +452,14 @@ function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatShare(value: any, total: number) {
|
||||
if (total <= 0) return '0.0%'
|
||||
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function nullablePercent(value: any) {
|
||||
return value === null || value === undefined ? '未设置' : formatPercent(value)
|
||||
@@ -464,10 +474,11 @@ function compactNumber(value: number) {
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function barWidth(value: any, maximum: number) {
|
||||
if (maximum <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
|
||||
}
|
||||
function barWidth(value: any, total: number) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (total <= 0 || numericValue <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
|
||||
}
|
||||
|
||||
function progressValue(value: any) {
|
||||
return Math.max(0, Math.min(100, Number(value || 0)))
|
||||
@@ -477,22 +488,29 @@ onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
.metric-card {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
@@ -566,7 +584,7 @@ onMounted(loadDashboard)
|
||||
}
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { padding: 16px; border-radius: 10px; }
|
||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
@@ -580,18 +598,26 @@ onMounted(loadDashboard)
|
||||
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.bar-list { display: grid; gap: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
|
||||
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
|
||||
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
|
||||
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
|
||||
.bar-track i.is-teal { background: #15998d; }
|
||||
.bar-track i.is-blue { background: #307bdf; }
|
||||
|
||||
.detail-panel { padding-bottom: 10px; }
|
||||
.detail-table {
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
.detail-panel { padding-bottom: 10px; overflow: hidden; }
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-table__inner-wrapper),
|
||||
:deep(.el-scrollbar) { max-width: 100%; }
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
|
||||
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
|
||||
strong.is-parent { color: #172033; font-weight: 700; }
|
||||
@@ -632,7 +658,7 @@ onMounted(loadDashboard)
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="metric-card">
|
||||
<article v-if="dashboard.scope.kind !== 'assistant'" class="metric-card">
|
||||
<div class="metric-label-row">
|
||||
<span>本人本月业绩</span>
|
||||
<el-tooltip :content="dashboard.meta.commission_note" placement="top">
|
||||
@@ -151,11 +151,11 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>{{ dashboard.rankings.appointments.title }}</h2>
|
||||
<p>{{ appointmentRankingSubtitle }}</p>
|
||||
<p v-if="appointmentRankingSubtitle">{{ appointmentRankingSubtitle }}</p>
|
||||
</div>
|
||||
<div class="panel-header-actions">
|
||||
<el-tree-select
|
||||
v-if="dashboard.rankings.appointments.kind !== 'doctor'"
|
||||
v-if="showRankingDeptSelect"
|
||||
v-model="rankingDeptId"
|
||||
:data="dashboard.filters.ranking_departments"
|
||||
:props="departmentTreeProps"
|
||||
@@ -164,7 +164,7 @@
|
||||
clearable
|
||||
filterable
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
placeholder="全部部门"
|
||||
class="ranking-dept-select"
|
||||
@change="handleRankingDeptChange"
|
||||
/>
|
||||
@@ -176,12 +176,16 @@
|
||||
v-for="(item, index) in dashboard.rankings.appointments.items"
|
||||
:key="`appointment-${item.id}-${index}`"
|
||||
class="ranking-row"
|
||||
:class="{ 'is-self': item.is_self }"
|
||||
>
|
||||
<span class="ranking-index" :class="{ 'is-top': index < 2 }">{{ index + 1 }}</span>
|
||||
<span class="ranking-index" :class="{ 'is-top': index < 2, 'is-self': item.is_self }">{{ index + 1 }}</span>
|
||||
<div class="ranking-main">
|
||||
<div class="ranking-copy">
|
||||
<strong>{{ item.name || '未命名成员' }}</strong>
|
||||
<span>{{ formatNumber(item.count) }} 挂号</span>
|
||||
<strong>
|
||||
{{ item.name || '未命名成员' }}
|
||||
<i v-if="item.is_self" class="ranking-self-tag">我</i>
|
||||
</strong>
|
||||
<span>{{ formatNumber(item.count) }} {{ rankingCountUnit }}</span>
|
||||
</div>
|
||||
<div class="ranking-meter" aria-hidden="true">
|
||||
<span :style="{ width: rankingWidth(item.count, appointmentRankingMax) }" />
|
||||
@@ -190,14 +194,16 @@
|
||||
<b class="ranking-value">{{ formatNumber(item.count) }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="72" description="当前数据范围内暂无挂号排行" />
|
||||
<el-empty v-else :image-size="72" :description="`当前数据范围内暂无${rankingCountUnit}排行`" />
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel ranking-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>{{ dashboard.rankings.performance.title }}</h2>
|
||||
<p>{{ dashboard.rankings.performance.scope_label }}内今日计入业绩的业务订单</p>
|
||||
<p>
|
||||
{{ performanceRankingSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="panel-meta">今日</span>
|
||||
</div>
|
||||
@@ -206,11 +212,15 @@
|
||||
v-for="(item, index) in dashboard.rankings.performance.items"
|
||||
:key="`performance-${item.id}-${index}`"
|
||||
class="ranking-row"
|
||||
:class="{ 'is-self': item.is_self }"
|
||||
>
|
||||
<span class="ranking-index" :class="{ 'is-top': index < 2 }">{{ index + 1 }}</span>
|
||||
<span class="ranking-index" :class="{ 'is-top': index < 2, 'is-self': item.is_self }">{{ index + 1 }}</span>
|
||||
<div class="ranking-main">
|
||||
<div class="ranking-copy">
|
||||
<strong>{{ item.name || '未命名部门' }}</strong>
|
||||
<strong>
|
||||
{{ item.name || (dashboard.rankings.performance.kind === 'person' ? '未命名成员' : '未命名部门') }}
|
||||
<i v-if="item.is_self" class="ranking-self-tag">我</i>
|
||||
</strong>
|
||||
<span>{{ formatNumber(item.count) }} 个诊单</span>
|
||||
</div>
|
||||
<div class="ranking-meter" aria-hidden="true">
|
||||
@@ -335,6 +345,7 @@ interface RankingItem {
|
||||
name: string
|
||||
count: number
|
||||
amount: number
|
||||
is_self?: boolean
|
||||
}
|
||||
|
||||
type ComparisonDirection = 'up' | 'down' | 'flat'
|
||||
@@ -355,10 +366,12 @@ const emptyComparison = (): MetricComparison => ({
|
||||
const createInitialDashboard = () => ({
|
||||
scope: {
|
||||
key: '',
|
||||
kind: '',
|
||||
scope_value: 4,
|
||||
label: '',
|
||||
is_limited: true,
|
||||
viewer_name: '',
|
||||
viewer_id: 0,
|
||||
role_ids: [] as number[],
|
||||
role_names: [] as string[],
|
||||
department_names: [] as string[],
|
||||
@@ -401,11 +414,13 @@ const createInitialDashboard = () => ({
|
||||
appointments: {
|
||||
title: '实时挂号排行',
|
||||
kind: 'assistant',
|
||||
metric: 'registration',
|
||||
scope_label: '',
|
||||
items: [] as RankingItem[],
|
||||
},
|
||||
performance: {
|
||||
title: '今日部门业绩排行',
|
||||
kind: 'dept',
|
||||
scope_label: '',
|
||||
items: [] as RankingItem[],
|
||||
},
|
||||
@@ -413,6 +428,7 @@ const createInitialDashboard = () => ({
|
||||
filters: {
|
||||
ranking_departments: [] as any[],
|
||||
ranking_dept_id: 0,
|
||||
ranking_selectable: true,
|
||||
},
|
||||
trend: {
|
||||
date_range: [] as string[],
|
||||
@@ -533,9 +549,34 @@ const currentDate = computed(() => new Intl.DateTimeFormat('zh-CN', {
|
||||
weekday: 'short',
|
||||
}).format(now.value))
|
||||
|
||||
const showRankingDeptSelect = computed(() => (
|
||||
dashboard.filters.ranking_selectable !== false
|
||||
&& dashboard.scope.kind !== 'group_leader'
|
||||
&& dashboard.rankings.appointments.kind !== 'doctor'
|
||||
&& dashboard.filters.ranking_departments.length > 0
|
||||
))
|
||||
|
||||
const rankingCountUnit = computed(() => (
|
||||
dashboard.rankings.appointments.metric === 'appointment' ? '预约' : '挂号'
|
||||
))
|
||||
|
||||
const appointmentRankingSubtitle = computed(() => {
|
||||
if (dashboard.rankings.appointments.metric === 'appointment') {
|
||||
return ''
|
||||
}
|
||||
const actor = dashboard.rankings.appointments.kind === 'doctor' ? '医生' : '成员'
|
||||
return `${dashboard.rankings.appointments.scope_label}内${actor}已支付且实收低于 10 元的订单数`
|
||||
const scopeLabel = dashboard.rankings.appointments.scope_label || '当前部门'
|
||||
return `${scopeLabel}内${actor}已支付且实收低于 10 元的订单数`
|
||||
})
|
||||
|
||||
const performanceRankingSubtitle = computed(() => {
|
||||
const scopeLabel = dashboard.rankings.performance.scope_label || '当前部门'
|
||||
if (dashboard.rankings.performance.kind === 'person') {
|
||||
return dashboard.scope.kind === 'group_leader'
|
||||
? '本小组内今日计入业绩的个人订单'
|
||||
: `${scopeLabel}内今日计入业绩的个人订单`
|
||||
}
|
||||
return `${scopeLabel}内今日计入业绩的业务订单`
|
||||
})
|
||||
|
||||
const appointmentRankingMax = computed(() => Math.max(
|
||||
@@ -656,7 +697,7 @@ const loadDashboard = async () => {
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const res: any = await performanceDashboardOverview({
|
||||
ranking_dept_id: rankingDeptId.value,
|
||||
ranking_dept_id: loaded.value ? (rankingDeptId.value || 0) : undefined,
|
||||
// 驾驶舱是实时数据,避免浏览器或反向代理复用旧的 GET 响应。
|
||||
_t: Date.now(),
|
||||
})
|
||||
@@ -998,6 +1039,11 @@ onBeforeUnmount(() => {
|
||||
padding: 7px 9px;
|
||||
border-radius: 11px;
|
||||
background: var(--dash-surface-muted);
|
||||
|
||||
&.is-self {
|
||||
background: color-mix(in srgb, var(--dash-accent) 16%, var(--dash-surface));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dash-accent) 42%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-index {
|
||||
@@ -1017,6 +1063,24 @@ onBeforeUnmount(() => {
|
||||
color: #fff;
|
||||
background: var(--dash-accent);
|
||||
}
|
||||
|
||||
&.is-self:not(.is-top) {
|
||||
color: var(--dash-accent);
|
||||
background: color-mix(in srgb, var(--dash-accent) 18%, var(--el-fill-color-light));
|
||||
font-weight: 750;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-self-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 0 5px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
background: var(--dash-accent);
|
||||
}
|
||||
|
||||
.ranking-main {
|
||||
@@ -1032,6 +1096,9 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 5px;
|
||||
|
||||
strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 188 KiB |
@@ -43,7 +43,7 @@ from .repository import (
|
||||
ItemT = TypeVar("ItemT")
|
||||
|
||||
|
||||
DEMO_PERMISSIONS: tuple[str, ...] = (
|
||||
DEMO_PERMISSIONS: tuple[str, ...] = (
|
||||
"doctor.appointment/lists",
|
||||
"doctor.appointment/reception",
|
||||
"doctor.appointment/notifyAssistant",
|
||||
@@ -59,12 +59,12 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
||||
"tcm.diagnosis/delete",
|
||||
"tcm.diagnosis/readonlyDetail",
|
||||
"tcm.diagnosis/aiReports",
|
||||
"tcm.diagnosis/generateAiReports",
|
||||
"tcm.diagnosis/editAiReport",
|
||||
"tcm.diagnosis/aiAnalysis",
|
||||
"tcm.diagnosis/aiAssistant",
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
"tcm.diagnosis/generateAiReports",
|
||||
"tcm.diagnosis/editAiReport",
|
||||
"tcm.diagnosis/aiAnalysis",
|
||||
"tcm.diagnosis/aiAssistant",
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
"tcm.diagnosis/dailyRecord",
|
||||
"tcm.diagnosis/chufang",
|
||||
"tcm.diagnosis/huifang",
|
||||
@@ -120,12 +120,12 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
||||
"tcm.diagnosis/guahao",
|
||||
"tcm.diagnosis/fillIdCard",
|
||||
"doctor.medicine/lists",
|
||||
)
|
||||
|
||||
PATIENT_AI_MEDICAL_DISCLAIMER = (
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
)
|
||||
|
||||
PATIENT_AI_MEDICAL_DISCLAIMER = (
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
|
||||
|
||||
class DemoDoctorRepository:
|
||||
@@ -155,12 +155,12 @@ class DemoDoctorRepository:
|
||||
self._template_ai_reports: dict[int, list[dict[str, Any]]] = (
|
||||
self._build_template_ai_reports()
|
||||
)
|
||||
self._diagnosis_ai_reports: dict[int, list[dict[str, Any]]] = (
|
||||
self._build_diagnosis_ai_reports()
|
||||
)
|
||||
self._patient_ai_reports: dict[int, list[dict[str, Any]]] = (
|
||||
self._build_patient_ai_reports()
|
||||
)
|
||||
self._diagnosis_ai_reports: dict[int, list[dict[str, Any]]] = (
|
||||
self._build_diagnosis_ai_reports()
|
||||
)
|
||||
self._patient_ai_reports: dict[int, list[dict[str, Any]]] = (
|
||||
self._build_patient_ai_reports()
|
||||
)
|
||||
self._prescriptions = self._build_prescriptions()
|
||||
self._medicines = self._build_medicines()
|
||||
self._patient_orders = self._build_patient_orders()
|
||||
@@ -987,7 +987,7 @@ class DemoDoctorRepository:
|
||||
consultation, generated=True, status="success"
|
||||
)
|
||||
|
||||
def edit_diagnosis_ai_report(
|
||||
def edit_diagnosis_ai_report(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
@@ -1027,155 +1027,155 @@ class DemoDoctorRepository:
|
||||
target["edited_by"] = 1001
|
||||
target["edited_time"] = int(now.timestamp())
|
||||
target["edited_at"] = now.isoformat(sep=" ")
|
||||
return {
|
||||
"diagnosis_id": consultation.id,
|
||||
"report": deepcopy(target),
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
}
|
||||
|
||||
def analyze_diagnosis_ai(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
*,
|
||||
task: str = "custom",
|
||||
) -> dict[str, Any]:
|
||||
"""Return a deterministic offline answer matching the server DTO."""
|
||||
|
||||
clean_prompt = prompt.strip()
|
||||
if not clean_prompt:
|
||||
raise ValueError("prompt is required")
|
||||
if len(clean_prompt) > 500:
|
||||
raise ValueError("prompt must not exceed 500 characters")
|
||||
if task not in {
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"medication_review",
|
||||
"exam_review",
|
||||
"complication_risk",
|
||||
"guideline_review",
|
||||
"custom",
|
||||
}:
|
||||
raise ValueError("task is not supported")
|
||||
with self._lock:
|
||||
consultation = self._find_consultation(diagnosis_id)
|
||||
model_key = (
|
||||
"openai"
|
||||
if task in {"exam_review", "complication_risk", "guideline_review"}
|
||||
else "qwen"
|
||||
)
|
||||
return {
|
||||
"diagnosis_id": consultation.id,
|
||||
"answer": (
|
||||
f"已结合{consultation.patient_name}的当前病历分析“{clean_prompt}”。"
|
||||
"请由医生结合四诊与最新检验结果复核。"
|
||||
),
|
||||
"model_key": model_key,
|
||||
"model_label": "千问" if model_key == "qwen" else "OpenAI",
|
||||
"task": task,
|
||||
}
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
model: Literal["qwen", "openai"] = "qwen",
|
||||
) -> dict[str, Any]:
|
||||
"""Return one model's structured offline analysis for the demo case."""
|
||||
|
||||
if diagnosis_id <= 0:
|
||||
raise ValueError("diagnosis_id must be positive")
|
||||
clean_model = str(model).strip().lower()
|
||||
if clean_model not in {"qwen", "openai"}:
|
||||
raise ValueError("model must be qwen or openai")
|
||||
with self._lock:
|
||||
consultation = self._find_consultation(diagnosis_id)
|
||||
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
|
||||
diagnosis = str(raw.get("clinical_diagnosis") or "待进一步辨证").strip()
|
||||
complaint = str(raw.get("chief_complaint") or "主诉信息待完善").strip()
|
||||
try:
|
||||
glucose = float(raw.get("fasting_blood_sugar"))
|
||||
except (TypeError, ValueError):
|
||||
glucose = None
|
||||
risk_assessment: list[dict[str, str]] = []
|
||||
if glucose is not None and glucose >= 7.0:
|
||||
risk_assessment.append({"label": "高血糖风险", "level": "high"})
|
||||
elif glucose is not None and glucose >= 6.1:
|
||||
risk_assessment.append({"label": "血糖波动风险", "level": "medium"})
|
||||
if "眠" in complaint or "睡眠" in str(raw.get("remark") or ""):
|
||||
risk_assessment.append({"label": "睡眠质量风险", "level": "low"})
|
||||
if not risk_assessment:
|
||||
risk_assessment.append({"label": "需持续随访", "level": "low"})
|
||||
treatment = str(
|
||||
raw.get("treatment_advice")
|
||||
or raw.get("treatment_principle")
|
||||
or raw.get("prescription_advice")
|
||||
or f"围绕{diagnosis}继续完善四诊信息,并结合最新检验结果调整方案。"
|
||||
).strip()
|
||||
diagnosis_advice = f"{diagnosis};重点复核:{complaint}"
|
||||
if clean_model == "openai":
|
||||
diagnosis_advice = f"{diagnosis};建议同步核对主诉与客观检查:{complaint}"
|
||||
treatment = f"{treatment} 同时复核近期检查趋势与用药安全性。"
|
||||
return {
|
||||
"diagnosis_advice": diagnosis_advice,
|
||||
"risk_assessment": risk_assessment,
|
||||
"treatment_advice": treatment,
|
||||
"model_key": clean_model,
|
||||
"model_label": "千问" if clean_model == "qwen" else "OpenAI",
|
||||
"model_name": (
|
||||
"qwen3.6-35b-demo" if clean_model == "qwen" else "openai-demo"
|
||||
),
|
||||
"generated_at": datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
}
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
"""Return every saved patient snapshot without generating a new one."""
|
||||
|
||||
if patient_id <= 0:
|
||||
raise ValueError("patient_id must be positive")
|
||||
with self._lock:
|
||||
consultation = self._find_patient_consultation(patient_id)
|
||||
return self._patient_ai_reports_payload(patient_id, consultation)
|
||||
|
||||
def generate_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
*,
|
||||
model: Literal["qwen", "openai"],
|
||||
) -> dict[str, Any]:
|
||||
"""Append one deterministic demo snapshot while retaining every version."""
|
||||
|
||||
if patient_id <= 0:
|
||||
raise ValueError("patient_id must be positive")
|
||||
clean_model = str(model).strip().lower()
|
||||
if clean_model not in {"qwen", "openai"}:
|
||||
raise ValueError("model must be qwen or openai")
|
||||
with self._lock:
|
||||
consultation = self._find_patient_consultation(patient_id)
|
||||
current = self._patient_ai_reports.setdefault(patient_id, [])
|
||||
version = 1 + sum(
|
||||
str(row.get("model_key") or "").strip().lower() == clean_model
|
||||
for row in current
|
||||
)
|
||||
generated = self._make_patient_ai_report(
|
||||
patient_id,
|
||||
consultation,
|
||||
model=clean_model,
|
||||
generated_at=datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
version=version,
|
||||
)
|
||||
current.append(generated)
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"generated_report": deepcopy(generated),
|
||||
"report": deepcopy(generated),
|
||||
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
|
||||
"source_summary": deepcopy(generated.get("source_summary", {})),
|
||||
}
|
||||
|
||||
def list_medicines(
|
||||
return {
|
||||
"diagnosis_id": consultation.id,
|
||||
"report": deepcopy(target),
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
}
|
||||
|
||||
def analyze_diagnosis_ai(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
*,
|
||||
task: str = "custom",
|
||||
) -> dict[str, Any]:
|
||||
"""Return a deterministic offline answer matching the server DTO."""
|
||||
|
||||
clean_prompt = prompt.strip()
|
||||
if not clean_prompt:
|
||||
raise ValueError("prompt is required")
|
||||
if len(clean_prompt) > 500:
|
||||
raise ValueError("prompt must not exceed 500 characters")
|
||||
if task not in {
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"medication_review",
|
||||
"exam_review",
|
||||
"complication_risk",
|
||||
"guideline_review",
|
||||
"custom",
|
||||
}:
|
||||
raise ValueError("task is not supported")
|
||||
with self._lock:
|
||||
consultation = self._find_consultation(diagnosis_id)
|
||||
model_key = (
|
||||
"openai"
|
||||
if task in {"exam_review", "complication_risk", "guideline_review"}
|
||||
else "qwen"
|
||||
)
|
||||
return {
|
||||
"diagnosis_id": consultation.id,
|
||||
"answer": (
|
||||
f"已结合{consultation.patient_name}的当前病历分析“{clean_prompt}”。"
|
||||
"请由医生结合四诊与最新检验结果复核。"
|
||||
),
|
||||
"model_key": model_key,
|
||||
"model_label": "千问" if model_key == "qwen" else "OpenAI",
|
||||
"task": task,
|
||||
}
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
model: Literal["qwen", "openai"] = "qwen",
|
||||
) -> dict[str, Any]:
|
||||
"""Return one model's structured offline analysis for the demo case."""
|
||||
|
||||
if diagnosis_id <= 0:
|
||||
raise ValueError("diagnosis_id must be positive")
|
||||
clean_model = str(model).strip().lower()
|
||||
if clean_model not in {"qwen", "openai"}:
|
||||
raise ValueError("model must be qwen or openai")
|
||||
with self._lock:
|
||||
consultation = self._find_consultation(diagnosis_id)
|
||||
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
|
||||
diagnosis = str(raw.get("clinical_diagnosis") or "待进一步辨证").strip()
|
||||
complaint = str(raw.get("chief_complaint") or "主诉信息待完善").strip()
|
||||
try:
|
||||
glucose = float(raw.get("fasting_blood_sugar"))
|
||||
except (TypeError, ValueError):
|
||||
glucose = None
|
||||
risk_assessment: list[dict[str, str]] = []
|
||||
if glucose is not None and glucose >= 7.0:
|
||||
risk_assessment.append({"label": "高血糖风险", "level": "high"})
|
||||
elif glucose is not None and glucose >= 6.1:
|
||||
risk_assessment.append({"label": "血糖波动风险", "level": "medium"})
|
||||
if "眠" in complaint or "睡眠" in str(raw.get("remark") or ""):
|
||||
risk_assessment.append({"label": "睡眠质量风险", "level": "low"})
|
||||
if not risk_assessment:
|
||||
risk_assessment.append({"label": "需持续随访", "level": "low"})
|
||||
treatment = str(
|
||||
raw.get("treatment_advice")
|
||||
or raw.get("treatment_principle")
|
||||
or raw.get("prescription_advice")
|
||||
or f"围绕{diagnosis}继续完善四诊信息,并结合最新检验结果调整方案。"
|
||||
).strip()
|
||||
diagnosis_advice = f"{diagnosis};重点复核:{complaint}"
|
||||
if clean_model == "openai":
|
||||
diagnosis_advice = f"{diagnosis};建议同步核对主诉与客观检查:{complaint}"
|
||||
treatment = f"{treatment} 同时复核近期检查趋势与用药安全性。"
|
||||
return {
|
||||
"diagnosis_advice": diagnosis_advice,
|
||||
"risk_assessment": risk_assessment,
|
||||
"treatment_advice": treatment,
|
||||
"model_key": clean_model,
|
||||
"model_label": "千问" if clean_model == "qwen" else "OpenAI",
|
||||
"model_name": (
|
||||
"qwen3.6-35b-demo" if clean_model == "qwen" else "openai-demo"
|
||||
),
|
||||
"generated_at": datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
}
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
"""Return every saved patient snapshot without generating a new one."""
|
||||
|
||||
if patient_id <= 0:
|
||||
raise ValueError("patient_id must be positive")
|
||||
with self._lock:
|
||||
consultation = self._find_patient_consultation(patient_id)
|
||||
return self._patient_ai_reports_payload(patient_id, consultation)
|
||||
|
||||
def generate_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
*,
|
||||
model: Literal["qwen", "openai"],
|
||||
) -> dict[str, Any]:
|
||||
"""Append one deterministic demo snapshot while retaining every version."""
|
||||
|
||||
if patient_id <= 0:
|
||||
raise ValueError("patient_id must be positive")
|
||||
clean_model = str(model).strip().lower()
|
||||
if clean_model not in {"qwen", "openai"}:
|
||||
raise ValueError("model must be qwen or openai")
|
||||
with self._lock:
|
||||
consultation = self._find_patient_consultation(patient_id)
|
||||
current = self._patient_ai_reports.setdefault(patient_id, [])
|
||||
version = 1 + sum(
|
||||
str(row.get("model_key") or "").strip().lower() == clean_model
|
||||
for row in current
|
||||
)
|
||||
generated = self._make_patient_ai_report(
|
||||
patient_id,
|
||||
consultation,
|
||||
model=clean_model,
|
||||
generated_at=datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
version=version,
|
||||
)
|
||||
current.append(generated)
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"generated_report": deepcopy(generated),
|
||||
"report": deepcopy(generated),
|
||||
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
|
||||
"source_summary": deepcopy(generated.get("source_summary", {})),
|
||||
}
|
||||
|
||||
def list_medicines(
|
||||
self,
|
||||
*,
|
||||
name: str = "",
|
||||
@@ -3138,23 +3138,23 @@ class DemoDoctorRepository:
|
||||
return patient
|
||||
raise RepositoryNotFoundError(f"patient diagnosis {diagnosis_id} not found")
|
||||
|
||||
def _find_consultation(self, diagnosis_id: int) -> Consultation:
|
||||
for consultation in self._consultations:
|
||||
if consultation.id == diagnosis_id:
|
||||
return consultation
|
||||
raise RepositoryNotFoundError(f"consultation {diagnosis_id} not found")
|
||||
|
||||
def _find_patient_consultation(self, patient_id: int) -> Consultation:
|
||||
"""Return the most recent demo consultation for a source patient id."""
|
||||
|
||||
matches = [
|
||||
consultation
|
||||
for consultation in self._consultations
|
||||
if consultation.patient_id == patient_id
|
||||
]
|
||||
if matches:
|
||||
return max(matches, key=lambda consultation: consultation.id)
|
||||
raise RepositoryNotFoundError(f"patient {patient_id} not found")
|
||||
def _find_consultation(self, diagnosis_id: int) -> Consultation:
|
||||
for consultation in self._consultations:
|
||||
if consultation.id == diagnosis_id:
|
||||
return consultation
|
||||
raise RepositoryNotFoundError(f"consultation {diagnosis_id} not found")
|
||||
|
||||
def _find_patient_consultation(self, patient_id: int) -> Consultation:
|
||||
"""Return the most recent demo consultation for a source patient id."""
|
||||
|
||||
matches = [
|
||||
consultation
|
||||
for consultation in self._consultations
|
||||
if consultation.patient_id == patient_id
|
||||
]
|
||||
if matches:
|
||||
return max(matches, key=lambda consultation: consultation.id)
|
||||
raise RepositoryNotFoundError(f"patient {patient_id} not found")
|
||||
|
||||
def _find_order(self, order_id: int) -> dict[str, Any]:
|
||||
for order in self._patient_orders:
|
||||
@@ -3296,6 +3296,9 @@ class DemoDoctorRepository:
|
||||
"period": "上午",
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"clinical_diagnosis": "肝郁脾虚证",
|
||||
"disease_course_text": "病程 3 年",
|
||||
"fasting_blood_sugar": 6.8,
|
||||
"diagnosis_confirmed": 1,
|
||||
"has_prescription": 0,
|
||||
"remark": "复诊,关注睡眠与口干。",
|
||||
@@ -3320,6 +3323,9 @@ class DemoDoctorRepository:
|
||||
"period": "上午",
|
||||
"status": 4,
|
||||
"status_desc": "已过号",
|
||||
"clinical_diagnosis": "痰湿中阻证",
|
||||
"disease_course_text": "病程 8 年",
|
||||
"fasting_blood_sugar": 8.6,
|
||||
"diagnosis_confirmed": 0,
|
||||
"has_prescription": 1,
|
||||
"prescription_audit_status": 1,
|
||||
@@ -3370,6 +3376,9 @@ class DemoDoctorRepository:
|
||||
"period": "上午",
|
||||
"status": 3,
|
||||
"status_desc": "已完成",
|
||||
"clinical_diagnosis": "气阴两虚证",
|
||||
"disease_course_text": "病程 5 年",
|
||||
"fasting_blood_sugar": 7.2,
|
||||
"diagnosis_confirmed": 1,
|
||||
"has_prescription": 1,
|
||||
"prescription_audit_status": 1,
|
||||
@@ -3872,10 +3881,10 @@ class DemoDoctorRepository:
|
||||
"is_edited": False,
|
||||
}
|
||||
|
||||
def _build_diagnosis_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
|
||||
consultation = next((row for row in self._consultations if row.id == 501), None)
|
||||
if consultation is None:
|
||||
return {}
|
||||
def _build_diagnosis_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
|
||||
consultation = next((row for row in self._consultations if row.id == 501), None)
|
||||
if consultation is None:
|
||||
return {}
|
||||
fingerprint = _consultation_fingerprint(consultation)
|
||||
return {
|
||||
501: [
|
||||
@@ -3886,158 +3895,158 @@ class DemoDoctorRepository:
|
||||
name="qwen3.6-35b",
|
||||
fingerprint=fingerprint,
|
||||
generated_at="2026-08-12 09:18:00",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
def _patient_ai_reports_payload(
|
||||
self,
|
||||
patient_id: int,
|
||||
consultation: Consultation,
|
||||
) -> dict[str, Any]:
|
||||
reports = sorted(
|
||||
(deepcopy(row) for row in self._patient_ai_reports.get(patient_id, [])),
|
||||
key=lambda row: (
|
||||
str(row.get("generated_at") or row.get("created_at") or ""),
|
||||
int(row.get("id") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
latest_by_model: dict[str, dict[str, Any] | None] = {
|
||||
"qwen": None,
|
||||
"openai": None,
|
||||
}
|
||||
for row in reports:
|
||||
model = str(row.get("model_key") or "").strip().lower()
|
||||
if model in latest_by_model and latest_by_model[model] is None:
|
||||
latest_by_model[model] = deepcopy(row)
|
||||
source_summary = (
|
||||
deepcopy(reports[0].get("source_summary"))
|
||||
if reports and isinstance(reports[0].get("source_summary"), Mapping)
|
||||
else {}
|
||||
)
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"reports": reports,
|
||||
"latest_by_model": latest_by_model,
|
||||
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
|
||||
"source_summary": source_summary,
|
||||
}
|
||||
|
||||
def _make_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
consultation: Consultation,
|
||||
*,
|
||||
model: str,
|
||||
generated_at: str,
|
||||
version: int,
|
||||
) -> dict[str, Any]:
|
||||
report_id = self._next_ai_report_id
|
||||
self._next_ai_report_id += 1
|
||||
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
|
||||
base_diagnosis = str(
|
||||
raw.get("clinical_diagnosis")
|
||||
or consultation.clinical_diagnosis
|
||||
or "待进一步辨证"
|
||||
).strip()
|
||||
complaint = str(
|
||||
raw.get("chief_complaint")
|
||||
or consultation.chief_complaint
|
||||
or "主诉信息待完善"
|
||||
).strip()
|
||||
is_openai = model == "openai"
|
||||
diagnosis = (
|
||||
f"{base_diagnosis};建议结合客观检查复核:{complaint}"
|
||||
if is_openai
|
||||
else f"{base_diagnosis};重点结合四诊复核:{complaint}"
|
||||
)
|
||||
risks = [
|
||||
{"label": "血糖波动风险", "level": "medium"},
|
||||
{"label": "睡眠质量风险", "level": "low"},
|
||||
]
|
||||
treatment = (
|
||||
"复核近期检查趋势、并用药物与肝肾功能,再由接诊医生决定后续方案。"
|
||||
if is_openai
|
||||
else "继续完善四诊与血糖记录,由接诊医生结合复诊结果确定后续方案。"
|
||||
)
|
||||
disclaimer = PATIENT_AI_MEDICAL_DISCLAIMER
|
||||
source_summary = {
|
||||
"diagnosis_count": 1,
|
||||
"doctor_note_count": 0,
|
||||
"tracking_note_count": 0,
|
||||
"blood_record_count": 0,
|
||||
"diet_record_count": 0,
|
||||
"exercise_record_count": 0,
|
||||
"im_message_count": 0,
|
||||
"wechat_message_count": 0,
|
||||
"call_record_count": 0,
|
||||
"transcript_segment_count": 0,
|
||||
"recording_asset_count": 0,
|
||||
"source_record_count": 1,
|
||||
"snapshot_complete": True,
|
||||
"may_be_truncated": False,
|
||||
"analysis_chunk_count": 1,
|
||||
"analysis_reduction_rounds": 0,
|
||||
"analyzed_source_bytes": 0,
|
||||
"analysis_complete": True,
|
||||
}
|
||||
structured = {
|
||||
"diagnosis": diagnosis,
|
||||
"risk_assessment": risks,
|
||||
"treatment_advice": treatment,
|
||||
"disclaimer": disclaimer,
|
||||
}
|
||||
return {
|
||||
"id": report_id,
|
||||
"report_id": report_id,
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": consultation.id,
|
||||
"model_key": model,
|
||||
"model_name": "gpt-5.6-sol-demo" if is_openai else "qwen3.6-35b-demo",
|
||||
"model_label": "OpenAI" if is_openai else "千问",
|
||||
"version": version,
|
||||
"report": structured,
|
||||
"content": json.dumps(structured, ensure_ascii=False),
|
||||
"diagnosis": diagnosis,
|
||||
"diagnosis_advice": diagnosis,
|
||||
"risk_assessment": deepcopy(risks),
|
||||
"treatment_advice": treatment,
|
||||
"disclaimer": disclaimer,
|
||||
"source_hash": _consultation_fingerprint(consultation),
|
||||
"source_summary": source_summary,
|
||||
"generated_at": generated_at,
|
||||
"created_at": generated_at,
|
||||
"admin_id": 1001,
|
||||
"department_id": 10,
|
||||
"department_name": "演示中医院",
|
||||
}
|
||||
|
||||
def _build_patient_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
|
||||
consultation = next(
|
||||
(row for row in self._consultations if row.patient_id == 301),
|
||||
None,
|
||||
)
|
||||
if consultation is None:
|
||||
return {}
|
||||
reports: list[dict[str, Any]] = []
|
||||
for version, generated_at in (
|
||||
(1, "2026-08-12 09:18:00"),
|
||||
(2, "2026-08-13 15:42:00"),
|
||||
):
|
||||
for model in ("qwen", "openai"):
|
||||
reports.append(
|
||||
self._make_patient_ai_report(
|
||||
301,
|
||||
consultation,
|
||||
model=model,
|
||||
generated_at=generated_at,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
return {301: reports}
|
||||
|
||||
def _build_templates(self) -> list[PrescriptionTemplate]:
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
def _patient_ai_reports_payload(
|
||||
self,
|
||||
patient_id: int,
|
||||
consultation: Consultation,
|
||||
) -> dict[str, Any]:
|
||||
reports = sorted(
|
||||
(deepcopy(row) for row in self._patient_ai_reports.get(patient_id, [])),
|
||||
key=lambda row: (
|
||||
str(row.get("generated_at") or row.get("created_at") or ""),
|
||||
int(row.get("id") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
latest_by_model: dict[str, dict[str, Any] | None] = {
|
||||
"qwen": None,
|
||||
"openai": None,
|
||||
}
|
||||
for row in reports:
|
||||
model = str(row.get("model_key") or "").strip().lower()
|
||||
if model in latest_by_model and latest_by_model[model] is None:
|
||||
latest_by_model[model] = deepcopy(row)
|
||||
source_summary = (
|
||||
deepcopy(reports[0].get("source_summary"))
|
||||
if reports and isinstance(reports[0].get("source_summary"), Mapping)
|
||||
else {}
|
||||
)
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"reports": reports,
|
||||
"latest_by_model": latest_by_model,
|
||||
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
|
||||
"source_summary": source_summary,
|
||||
}
|
||||
|
||||
def _make_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
consultation: Consultation,
|
||||
*,
|
||||
model: str,
|
||||
generated_at: str,
|
||||
version: int,
|
||||
) -> dict[str, Any]:
|
||||
report_id = self._next_ai_report_id
|
||||
self._next_ai_report_id += 1
|
||||
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
|
||||
base_diagnosis = str(
|
||||
raw.get("clinical_diagnosis")
|
||||
or consultation.clinical_diagnosis
|
||||
or "待进一步辨证"
|
||||
).strip()
|
||||
complaint = str(
|
||||
raw.get("chief_complaint")
|
||||
or consultation.chief_complaint
|
||||
or "主诉信息待完善"
|
||||
).strip()
|
||||
is_openai = model == "openai"
|
||||
diagnosis = (
|
||||
f"{base_diagnosis};建议结合客观检查复核:{complaint}"
|
||||
if is_openai
|
||||
else f"{base_diagnosis};重点结合四诊复核:{complaint}"
|
||||
)
|
||||
risks = [
|
||||
{"label": "血糖波动风险", "level": "medium"},
|
||||
{"label": "睡眠质量风险", "level": "low"},
|
||||
]
|
||||
treatment = (
|
||||
"复核近期检查趋势、并用药物与肝肾功能,再由接诊医生决定后续方案。"
|
||||
if is_openai
|
||||
else "继续完善四诊与血糖记录,由接诊医生结合复诊结果确定后续方案。"
|
||||
)
|
||||
disclaimer = PATIENT_AI_MEDICAL_DISCLAIMER
|
||||
source_summary = {
|
||||
"diagnosis_count": 1,
|
||||
"doctor_note_count": 0,
|
||||
"tracking_note_count": 0,
|
||||
"blood_record_count": 0,
|
||||
"diet_record_count": 0,
|
||||
"exercise_record_count": 0,
|
||||
"im_message_count": 0,
|
||||
"wechat_message_count": 0,
|
||||
"call_record_count": 0,
|
||||
"transcript_segment_count": 0,
|
||||
"recording_asset_count": 0,
|
||||
"source_record_count": 1,
|
||||
"snapshot_complete": True,
|
||||
"may_be_truncated": False,
|
||||
"analysis_chunk_count": 1,
|
||||
"analysis_reduction_rounds": 0,
|
||||
"analyzed_source_bytes": 0,
|
||||
"analysis_complete": True,
|
||||
}
|
||||
structured = {
|
||||
"diagnosis": diagnosis,
|
||||
"risk_assessment": risks,
|
||||
"treatment_advice": treatment,
|
||||
"disclaimer": disclaimer,
|
||||
}
|
||||
return {
|
||||
"id": report_id,
|
||||
"report_id": report_id,
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": consultation.id,
|
||||
"model_key": model,
|
||||
"model_name": "gpt-5.6-sol-demo" if is_openai else "qwen3.6-35b-demo",
|
||||
"model_label": "OpenAI" if is_openai else "千问",
|
||||
"version": version,
|
||||
"report": structured,
|
||||
"content": json.dumps(structured, ensure_ascii=False),
|
||||
"diagnosis": diagnosis,
|
||||
"diagnosis_advice": diagnosis,
|
||||
"risk_assessment": deepcopy(risks),
|
||||
"treatment_advice": treatment,
|
||||
"disclaimer": disclaimer,
|
||||
"source_hash": _consultation_fingerprint(consultation),
|
||||
"source_summary": source_summary,
|
||||
"generated_at": generated_at,
|
||||
"created_at": generated_at,
|
||||
"admin_id": 1001,
|
||||
"department_id": 10,
|
||||
"department_name": "演示中医院",
|
||||
}
|
||||
|
||||
def _build_patient_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
|
||||
consultation = next(
|
||||
(row for row in self._consultations if row.patient_id == 301),
|
||||
None,
|
||||
)
|
||||
if consultation is None:
|
||||
return {}
|
||||
reports: list[dict[str, Any]] = []
|
||||
for version, generated_at in (
|
||||
(1, "2026-08-12 09:18:00"),
|
||||
(2, "2026-08-13 15:42:00"),
|
||||
):
|
||||
for model in ("qwen", "openai"):
|
||||
reports.append(
|
||||
self._make_patient_ai_report(
|
||||
301,
|
||||
consultation,
|
||||
model=model,
|
||||
generated_at=generated_at,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
return {301: reports}
|
||||
|
||||
def _build_templates(self) -> list[PrescriptionTemplate]:
|
||||
return [
|
||||
PrescriptionTemplate.from_dict(
|
||||
{
|
||||
|
||||
@@ -648,7 +648,7 @@ class AppointmentsPage(QWidget):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(22, 7, 6, 8)
|
||||
root.setSpacing(4)
|
||||
header = PageHeader("患者列表")
|
||||
header = PageHeader("问诊列表")
|
||||
header.title_label.hide()
|
||||
header.subtitle_label.hide()
|
||||
root.addWidget(header)
|
||||
|
||||
@@ -86,7 +86,7 @@ class NavigationItem:
|
||||
NAVIGATION = (
|
||||
NavigationItem(
|
||||
"appointments",
|
||||
"接诊台",
|
||||
"问诊列表",
|
||||
"号",
|
||||
AppointmentsPage,
|
||||
("doctor.appointment/lists",),
|
||||
@@ -301,8 +301,8 @@ def _resolve_navigation(
|
||||
continue
|
||||
if not _canonical_allowed(permissions, item.permissions[0]):
|
||||
continue
|
||||
if item.key in {"appointments", "patients"}:
|
||||
# Keep the two product-facing navigation titles stable even when
|
||||
if item.key in {"appointments", "reception", "patients"}:
|
||||
# Keep the product-facing navigation titles stable even when
|
||||
# the server still carries an older menu label.
|
||||
title = item.title
|
||||
else:
|
||||
@@ -549,7 +549,7 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
|
||||
|
||||
|
||||
class _ShellBrandMark(QWidget):
|
||||
"""Paint the supplied indigo pulse mark without a font glyph dependency."""
|
||||
"""Paint the supplied indigo M mark without a font-glyph asset."""
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
del event
|
||||
@@ -562,26 +562,12 @@ class _ShellBrandMark(QWidget):
|
||||
painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0))
|
||||
painter.setBrush(gradient)
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
|
||||
pen = QPen(QColor("#FFFFFF"), 1.8)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
y = self.height() / 2
|
||||
painter.drawPolyline(
|
||||
QPolygonF(
|
||||
[
|
||||
QPointF(8, y + 2),
|
||||
QPointF(11, y + 2),
|
||||
QPointF(14, y - 6),
|
||||
QPointF(18, y + 7),
|
||||
QPointF(22, y - 4),
|
||||
QPointF(25, y + 2),
|
||||
QPointF(self.width() - 8, y + 2),
|
||||
]
|
||||
)
|
||||
)
|
||||
painter.setPen(QColor("#FFFFFF"))
|
||||
font = QFont(painter.font())
|
||||
font.setPixelSize(18)
|
||||
font.setWeight(QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "M")
|
||||
|
||||
|
||||
class _AssistantRobot(QWidget):
|
||||
@@ -837,7 +823,7 @@ class ShellWindow(QMainWindow):
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("甄养堂 · AI 问诊助手")
|
||||
self.setWindowTitle("甄养堂 · 问诊中心")
|
||||
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
self.repository = repository
|
||||
@@ -1032,7 +1018,7 @@ class ShellWindow(QMainWindow):
|
||||
brand_copy_layout = QVBoxLayout(self.brand_copy)
|
||||
brand_copy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
brand_copy_layout.setSpacing(1)
|
||||
self.brand_name = QLabel("AI问诊助手", self.brand_copy)
|
||||
self.brand_name = QLabel("问诊中心", self.brand_copy)
|
||||
self.brand_name.setObjectName("ShellBrandName")
|
||||
brand_copy_layout.addWidget(self.brand_name)
|
||||
self.brand_subtitle = QLabel("糖尿病专科版", self.brand_copy)
|
||||
@@ -1083,7 +1069,7 @@ class ShellWindow(QMainWindow):
|
||||
outer_assistant.addWidget(self.assistant_card)
|
||||
layout.addLayout(outer_assistant)
|
||||
|
||||
self.model_label = QLabel("模型:服务端自动匹配 ›", sidebar)
|
||||
self.model_label = QLabel("模型:GPT-4o 医疗版 ›", sidebar)
|
||||
self.model_label.setObjectName("ShellModelLabel")
|
||||
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.model_label.setFixedHeight(76)
|
||||
@@ -1130,11 +1116,15 @@ class ShellWindow(QMainWindow):
|
||||
font-size: 10px;
|
||||
}
|
||||
QPushButton#ShellAiEntry {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
min-width: 38px;
|
||||
max-width: 38px;
|
||||
min-height: 38px;
|
||||
max-height: 38px;
|
||||
padding: 0;
|
||||
color: #5265F6;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QPushButton#ShellAiEntry:hover { background-color: #EEF1FF; }
|
||||
@@ -1220,11 +1210,13 @@ class ShellWindow(QMainWindow):
|
||||
self.context_label.hide()
|
||||
layout.addStretch(1)
|
||||
|
||||
self.ai_top_button = QPushButton("AI 助手", topbar)
|
||||
self.ai_top_button = QPushButton("", topbar)
|
||||
self.ai_top_button.setObjectName("ShellAiEntry")
|
||||
self.ai_top_button.setIcon(_painted_shell_icon("ai", 18))
|
||||
self.ai_top_button.setIconSize(QSize(18, 18))
|
||||
self.ai_top_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.ai_top_button.setToolTip("AI 助手")
|
||||
self.ai_top_button.setAccessibleName("AI 助手")
|
||||
self.ai_top_button.clicked.connect(self._open_ai_assistant)
|
||||
layout.addWidget(self.ai_top_button)
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ def test_saved_history_is_rendered_without_automatic_generation(
|
||||
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
|
||||
assert "第 2 版" in page.ai_analysis_snapshot_meta.text()
|
||||
assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER
|
||||
assert not page.ai_analysis_disclaimer.isVisibleTo(page)
|
||||
assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton"
|
||||
assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_queue_status_badge_is_not_clipped_in_narrow_panel(
|
||||
{
|
||||
"patient_name": "张蒙",
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"status_desc": "问诊中",
|
||||
"appointment_time": "12:45:00",
|
||||
"gender": 1,
|
||||
"age": 36,
|
||||
@@ -296,6 +296,33 @@ def test_queue_uses_admin_same_day_contract(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_queue_date_picker_filters_the_selected_day(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class Repository:
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(kwargs)
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.queue_date_button.click()
|
||||
calendar = page.queue_date_button.calendarWidget()
|
||||
assert calendar.isVisible()
|
||||
page.queue_date_button._pick_date(QDate(2026, 8, 1))
|
||||
assert not calendar.isVisible()
|
||||
assert page.queue_date_button.text() == "2026-08-01"
|
||||
assert page._queue_date == "2026-08-01"
|
||||
assert calls[-1]["start_date"] == "2026-08-01"
|
||||
assert calls[-1]["end_date"] == "2026-08-01"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
@@ -630,14 +657,164 @@ def test_queue_load_more_accumulates_to_total_boundary(
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.refresh()
|
||||
assert page.queue_list.count() == 15
|
||||
assert page.load_more_button.isVisibleTo(page)
|
||||
assert page._queue_has_more()
|
||||
assert not hasattr(page, "load_more_button")
|
||||
assert not hasattr(page, "add_patient_button")
|
||||
|
||||
page._load_more()
|
||||
assert [call["page_no"] for call in calls] == [1, 2]
|
||||
assert all(call["page_size"] == 15 for call in calls)
|
||||
assert page.queue_list.count() == 22
|
||||
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
|
||||
assert page.load_more_button.isHidden()
|
||||
assert not page._queue_has_more()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_silent_poll_keeps_already_loaded_queue_pages(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
all_rows = [
|
||||
{
|
||||
"id": index,
|
||||
"patient_id": 1000 + index,
|
||||
"diagnosis_id": 2000 + index,
|
||||
"patient_name": f"患者{index:02d}",
|
||||
"status": 1,
|
||||
}
|
||||
for index in range(1, 28)
|
||||
]
|
||||
|
||||
class Repository:
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(dict(kwargs))
|
||||
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
|
||||
return {
|
||||
"lists": all_rows[start : start + kwargs["page_size"]],
|
||||
"count": len(all_rows),
|
||||
}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
row = all_rows[appointment_id - 1]
|
||||
return {
|
||||
"appointment": row,
|
||||
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
|
||||
}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.refresh()
|
||||
page._load_more()
|
||||
assert page.queue_list.count() == 27
|
||||
assert page._queue_page == 2
|
||||
calls.clear()
|
||||
page.refresh(silent=True)
|
||||
assert page.queue_list.count() == 27
|
||||
assert page._queue_page == 2
|
||||
assert calls[-1]["page_no"] == 1
|
||||
assert calls[-1]["page_size"] >= 27
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_queue_shows_loading_indicator_while_page_request_is_open(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
) -> None:
|
||||
class Repository:
|
||||
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [
|
||||
{"id": 1, "patient_name": "加载患者", "status": 1},
|
||||
],
|
||||
"count": 16,
|
||||
}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"appointment": {
|
||||
"id": appointment_id,
|
||||
"patient_id": 1001,
|
||||
"status": 1,
|
||||
},
|
||||
"diagnosis": {"id": 2001, "patient_id": 1001},
|
||||
}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.refresh()
|
||||
assert page.queue_loading_indicator.isVisibleTo(page)
|
||||
assert page.queue_loading_indicator.label.text() == "正在加载…"
|
||||
assert page.queue_loading_indicator.spinner._timer.isActive()
|
||||
|
||||
queued_async[0]["on_success"](
|
||||
{
|
||||
"lists": [{"id": 1, "patient_name": "加载患者", "status": 1}],
|
||||
"count": 16,
|
||||
}
|
||||
)
|
||||
queued_async[0]["on_finished"]()
|
||||
assert not page.queue_loading_indicator.isVisibleTo(page)
|
||||
assert not page.queue_loading_indicator.spinner._timer.isActive()
|
||||
|
||||
page._load_more()
|
||||
assert page.queue_loading_indicator.isVisibleTo(page)
|
||||
load_more_job = queued_async[-1]
|
||||
load_more_job["on_success"](
|
||||
{
|
||||
"lists": [{"id": 2, "patient_name": "第二页患者", "status": 1}],
|
||||
"count": 16,
|
||||
}
|
||||
)
|
||||
load_more_job["on_finished"]()
|
||||
assert not page.queue_loading_indicator.isVisibleTo(page)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_queue_scrolls_to_bottom_loads_next_page(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
all_rows = [
|
||||
{
|
||||
"id": index,
|
||||
"patient_id": 1000 + index,
|
||||
"diagnosis_id": 2000 + index,
|
||||
"patient_name": f"患者{index:02d}",
|
||||
"status": 1,
|
||||
}
|
||||
for index in range(1, 23)
|
||||
]
|
||||
|
||||
class Repository:
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(kwargs)
|
||||
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
|
||||
return {
|
||||
"lists": all_rows[start : start + kwargs["page_size"]],
|
||||
"count": len(all_rows),
|
||||
}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
row = all_rows[appointment_id - 1]
|
||||
return {
|
||||
"appointment": row,
|
||||
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
|
||||
}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.resize(1280, 640)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert page.queue_list.count() == 15
|
||||
scrollbar = page.queue_list.verticalScrollBar()
|
||||
assert scrollbar.maximum() > 0
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
application.processEvents()
|
||||
assert [call["page_no"] for call in calls] == [1, 2]
|
||||
assert page.queue_list.count() == 22
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -871,6 +1048,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
] == [("千问", "qwen"), ("OpenAI", "openai")]
|
||||
assert page.ai_analysis_model_selector.currentData() == "qwen"
|
||||
assert page._ai_analysis_state == "success"
|
||||
assert page.ai_analysis_stack.currentWidget() is page.ai_analysis_content_page
|
||||
assert page.ai_summary_label.isVisible()
|
||||
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
|
||||
assert page.ai_treatment_label.text().startswith("建议调整降糖方案")
|
||||
assert page.ai_summary_label.textFormat() == Qt.TextFormat.PlainText
|
||||
@@ -887,7 +1066,7 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
assert [button.text() for button in page.ai_prompt_buttons] == [
|
||||
"基于当前病史,下一步检查建议?",
|
||||
"该患者的用药调整建议?",
|
||||
"糖尿病教育要点?",
|
||||
"糖尿病教育要点有哪些?",
|
||||
"并发症筛查建议?",
|
||||
]
|
||||
assert page.ai_send_button.text() == ""
|
||||
@@ -915,10 +1094,12 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
|
||||
left = page.ai_analysis_card.geometry()
|
||||
right = page.ai_assistant_card.geometry()
|
||||
assert 235 <= left.height() <= 260
|
||||
assert 470 <= left.height() <= 520
|
||||
assert left.height() == right.height()
|
||||
assert 0 <= right.left() - left.right() - 1 <= 2
|
||||
assert abs(left.width() * 5 - right.width() * 4) <= 10
|
||||
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
|
||||
assert page.ai_summary_label.minimumSizeHint().width() <= 48
|
||||
|
||||
calls_before_switch = list(analysis_calls)
|
||||
page.ai_analysis_model_selector.setCurrentIndex(
|
||||
@@ -931,6 +1112,14 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
)
|
||||
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
|
||||
assert analysis_calls == calls_before_switch
|
||||
assert page.detail_scroll.objectName() == "ReceptionDetailScroll"
|
||||
assert (
|
||||
page.detail_scroll.verticalScrollBarPolicy()
|
||||
== Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
page.resize(1494, 620)
|
||||
application.processEvents()
|
||||
assert page.detail_scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -73,7 +73,7 @@ def test_appointments_navigation_is_named_reception_and_always_first() -> None:
|
||||
)
|
||||
|
||||
assert [(item.key, title) for item, title in resolved] == [
|
||||
("appointments", "接诊台"),
|
||||
("appointments", "问诊列表"),
|
||||
("patients", "我的患者"),
|
||||
]
|
||||
|
||||
@@ -86,7 +86,7 @@ def shell_window(
|
||||
navigation = [
|
||||
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
|
||||
for key, title, glyph, permission in (
|
||||
("appointments", "接诊台", "号", "doctor.appointment/lists"),
|
||||
("appointments", "问诊列表", "号", "doctor.appointment/lists"),
|
||||
("reception", "接诊台", "◎", "doctor.appointment/lists"),
|
||||
(
|
||||
"prescription_library",
|
||||
@@ -164,7 +164,7 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
||||
)
|
||||
assert shell_window.assistant_card.isVisible()
|
||||
assert shell_window.assistant_button.text() == "开始对话"
|
||||
assert "服务端自动匹配" in shell_window.model_label.text()
|
||||
assert "GPT-4o 医疗版" in shell_window.model_label.text()
|
||||
assert shell_window.minimize_button.text() == ""
|
||||
assert shell_window.close_button.text() == ""
|
||||
|
||||
|
||||
@@ -164,10 +164,8 @@ class AdminLogic extends BaseLogic
|
||||
|
||||
// 禁用或更换角色后.设置token过期
|
||||
$roleId = AdminRole::where('admin_id', $params['id'])->column('role_id');
|
||||
$editRole = false;
|
||||
if (!empty(array_diff_assoc($roleId, $params['role_id']))) {
|
||||
$editRole = true;
|
||||
}
|
||||
$submittedRoleIds = self::normalizeRoleIds($params['role_id'] ?? []);
|
||||
$editRole = self::roleIdsChanged($roleId, $submittedRoleIds);
|
||||
|
||||
if ($params['disable'] == 1 || $editRole) {
|
||||
$tokenArr = AdminSession::where('admin_id', $params['id'])->select()->toArray();
|
||||
@@ -177,14 +175,13 @@ class AdminLogic extends BaseLogic
|
||||
}
|
||||
|
||||
Admin::update($data);
|
||||
(new AdminAuthCache($params['id']))->clearAuthCache();
|
||||
|
||||
// 删除旧的关联信息
|
||||
AdminRole::delByUserId($params['id']);
|
||||
AdminDept::delByUserId($params['id']);
|
||||
AdminJobs::delByUserId($params['id']);
|
||||
// 角色
|
||||
self::insertRole($params['id'], $params['role_id']);
|
||||
self::insertRole($params['id'], $submittedRoleIds);
|
||||
// 部门
|
||||
self::insertDept($params['id'], $params['dept_id'] ?? []);
|
||||
// 岗位
|
||||
@@ -194,6 +191,13 @@ class AdminLogic extends BaseLogic
|
||||
self::importDoctorAccountToIm($params['id'], $params['name']);
|
||||
|
||||
Db::commit();
|
||||
// 必须在角色关联提交后删除该账号的 URI 权限缓存,避免并发请求重新写入旧权限。
|
||||
try {
|
||||
(new AdminAuthCache($params['id']))->clearAuthCache();
|
||||
} catch (\Throwable $cacheError) {
|
||||
// 数据已经提交,缓存清理失败不能把成功的编辑伪装成失败;记录后等待缓存自然过期。
|
||||
Log::warning('管理员角色权限缓存清理失败:admin_id=' . $params['id'] . ',error=' . $cacheError->getMessage());
|
||||
}
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
@@ -363,7 +367,8 @@ class AdminLogic extends BaseLogic
|
||||
*/
|
||||
public static function insertRole($adminId, $roleIds)
|
||||
{
|
||||
if (!empty($roleIds)) {
|
||||
$roleIds = self::normalizeRoleIds($roleIds);
|
||||
if ($roleIds !== []) {
|
||||
// 角色
|
||||
$roleData = [];
|
||||
foreach ($roleIds as $roleId) {
|
||||
@@ -376,6 +381,33 @@ class AdminLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private static function normalizeRoleIds(mixed $roleIds): array
|
||||
{
|
||||
if (!is_array($roleIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique(array_filter(
|
||||
array_map('intval', $roleIds),
|
||||
static fn (int $roleId): bool => $roleId > 0
|
||||
)));
|
||||
sort($normalized, SORT_NUMERIC);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色是无序集合;新增、移除或替换都必须使现有 Token 失效,仅顺序变化不算修改。
|
||||
*
|
||||
* @param array<int|string,mixed> $currentRoleIds
|
||||
* @param array<int|string,mixed> $submittedRoleIds
|
||||
*/
|
||||
private static function roleIdsChanged(array $currentRoleIds, array $submittedRoleIds): bool
|
||||
{
|
||||
return self::normalizeRoleIds($currentRoleIds) !== self::normalizeRoleIds($submittedRoleIds);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 新增部门
|
||||
@@ -453,4 +485,4 @@ class AdminLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,11 @@ class FirstVisitConversionLogic
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
@@ -136,19 +137,20 @@ class FirstVisitConversionLogic
|
||||
(int) $summary['total_open_count']
|
||||
);
|
||||
|
||||
$rankingRows = self::rankingRows($rows);
|
||||
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
|
||||
$rankingRows = self::rankingRows($rows, $rankingKind);
|
||||
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
|
||||
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
|
||||
? []
|
||||
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
|
||||
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
|
||||
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
|
||||
: '';
|
||||
@@ -159,8 +161,9 @@ class FirstVisitConversionLogic
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'ranking_kind' => $rankingKind,
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||||
@@ -529,10 +532,33 @@ class FirstVisitConversionLogic
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows): array
|
||||
{
|
||||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||||
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
|
||||
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
|
||||
{
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows, string $rankingKind): array
|
||||
{
|
||||
if ($rankingKind === 'hidden') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
|
||||
// 直属下级,避免父子汇总同时参与占比。
|
||||
if ($rankingKind === 'member') {
|
||||
$members = [];
|
||||
self::collectRankingMembers($rows, $members);
|
||||
|
||||
return array_values($members);
|
||||
}
|
||||
|
||||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||||
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
|
||||
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
|
||||
@@ -557,23 +583,59 @@ class FirstVisitConversionLogic
|
||||
$chartRows[] = $row;
|
||||
}
|
||||
|
||||
return $chartRows;
|
||||
}
|
||||
return $chartRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @param array<int,array<string,mixed>> $members
|
||||
*/
|
||||
private static function collectRankingMembers(array $rows, array &$members): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||
if ($adminId > 0) {
|
||||
$members[$adminId] = $row;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self::collectRankingMembers(
|
||||
is_array($row['children'] ?? null) ? $row['children'] : [],
|
||||
$members
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int) ($row['id'] ?? 0) > 0));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], array_slice($rows, 0, 6));
|
||||
}
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
return (int) ($row['admin_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
return (int) ($row['id'] ?? 0) > 0;
|
||||
}));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
if ($valueCompare !== 0) {
|
||||
return $valueCompare;
|
||||
}
|
||||
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
if ($nameCompare !== 0) {
|
||||
return $nameCompare;
|
||||
}
|
||||
|
||||
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'] ?? 0,
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemRole;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
|
||||
/**
|
||||
* 首页 KPI 数据范围:按角色而不是单纯按 data_scope。
|
||||
*
|
||||
* 医助=仅本人;组长=本小组全部成员;经理=本部门及下级;管理员/超管=全部。
|
||||
* 本人业绩卡片始终按登录账号单独统计,不走这套范围。
|
||||
*/
|
||||
class PerformanceDashboardScope
|
||||
{
|
||||
public const KIND_ADMIN = 'admin';
|
||||
public const KIND_MANAGER = 'manager';
|
||||
public const KIND_GROUP_LEADER = 'group_leader';
|
||||
public const KIND_ASSISTANT = 'assistant';
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* kind: string,
|
||||
* label: string,
|
||||
* metric_admin_ids: array<int>|null
|
||||
* }
|
||||
*/
|
||||
public static function resolve(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$roleNames = self::roleNames($adminId);
|
||||
$kind = self::classify((int) ($adminInfo['root'] ?? 0) === 1, $roleNames, $adminInfo);
|
||||
$metricAdminIds = self::metricAdminIds($kind, $adminId, $adminInfo);
|
||||
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'label' => self::kindLabel($kind),
|
||||
'metric_admin_ids' => $metricAdminIds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function kindLabel(string $kind): string
|
||||
{
|
||||
return [
|
||||
self::KIND_ADMIN => '全部数据',
|
||||
self::KIND_MANAGER => '本部门',
|
||||
self::KIND_GROUP_LEADER => '本小组',
|
||||
self::KIND_ASSISTANT => '仅本人',
|
||||
][$kind] ?? '数据范围';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roleNames
|
||||
*/
|
||||
public static function classify(bool $isRoot, array $roleNames, array $adminInfo = []): string
|
||||
{
|
||||
if ($isRoot) {
|
||||
return self::KIND_ADMIN;
|
||||
}
|
||||
if (self::roleNamesMatch($roleNames, ['管理员'])) {
|
||||
return self::KIND_ADMIN;
|
||||
}
|
||||
if (self::roleNamesMatch($roleNames, ['经理'])) {
|
||||
return self::KIND_MANAGER;
|
||||
}
|
||||
if (self::roleNamesMatch($roleNames, ['诊室组长', '组长'])) {
|
||||
return self::KIND_GROUP_LEADER;
|
||||
}
|
||||
// 医助角色固定仅本人,不因部门负责人或 data_scope 放大到小组。
|
||||
if (self::roleNamesMatch($roleNames, ['医助'])) {
|
||||
return self::KIND_ASSISTANT;
|
||||
}
|
||||
|
||||
$scope = DataScopeService::getEffectiveScope($adminInfo);
|
||||
return match ($scope) {
|
||||
DataScopeService::SCOPE_ALL => self::KIND_ADMIN,
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => self::KIND_MANAGER,
|
||||
DataScopeService::SCOPE_DEPT => self::KIND_GROUP_LEADER,
|
||||
default => self::KIND_ASSISTANT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>|null
|
||||
*/
|
||||
private static function metricAdminIds(string $kind, int $adminId, array $adminInfo = []): ?array
|
||||
{
|
||||
if ($kind === self::KIND_ADMIN) {
|
||||
return null;
|
||||
}
|
||||
if ($kind === self::KIND_ASSISTANT || $adminId <= 0) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
if ($kind === self::KIND_GROUP_LEADER) {
|
||||
$ids = self::adminsInGroup($adminId, $adminInfo);
|
||||
return $ids !== [] ? $ids : ($adminId > 0 ? [$adminId] : []);
|
||||
}
|
||||
|
||||
$ids = self::adminsInOwnDeptTree($adminId);
|
||||
if ($ids === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组长小组:只取本人最深的部门(不含一中心/二中心整棵树),并并入其担任负责人的部门。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function adminsInGroup(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$ownDeptIds = self::ownDeptIds($adminId);
|
||||
$leafDeptIds = self::leafDeptIds($ownDeptIds);
|
||||
$ledDeptIds = self::ledDeptIds($adminId, $adminInfo, $ownDeptIds);
|
||||
$groupDeptIds = array_values(array_unique(array_merge($leafDeptIds, $ledDeptIds)));
|
||||
$groupDeptIds = self::dropCenterRootsIfHasDeeper($groupDeptIds);
|
||||
if ($groupDeptIds === []) {
|
||||
$groupDeptIds = $leafDeptIds !== [] ? $leafDeptIds : $ownDeptIds;
|
||||
}
|
||||
|
||||
$deptIds = [];
|
||||
foreach ($groupDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds((int) $deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$deptIds[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
$deptIds = array_values(array_unique($deptIds));
|
||||
if ($deptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
|
||||
$adminIds[] = $adminId;
|
||||
}
|
||||
|
||||
return $adminIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private static function ownDeptIds(int $adminId): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在本人所属部门里只留最深的节点,避免挂在「一中心」上就把整个中心当成小组。
|
||||
*
|
||||
* @param int[] $ownDeptIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function leafDeptIds(array $ownDeptIds): array
|
||||
{
|
||||
if ($ownDeptIds === []) {
|
||||
return [];
|
||||
}
|
||||
$deptById = [];
|
||||
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$deptById[$id] = [
|
||||
'pid' => (int) ($row['pid'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$ownSet = array_fill_keys($ownDeptIds, true);
|
||||
$leaves = [];
|
||||
foreach ($ownDeptIds as $id) {
|
||||
$hasOwnDescendant = false;
|
||||
foreach ($ownDeptIds as $other) {
|
||||
if ($other === $id) {
|
||||
continue;
|
||||
}
|
||||
if (self::isAncestorOf($id, $other, $deptById)) {
|
||||
$hasOwnDescendant = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasOwnDescendant && isset($ownSet[$id])) {
|
||||
$leaves[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($leaves));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{pid: int, name: string}> $deptById
|
||||
*/
|
||||
private static function isAncestorOf(int $ancestorId, int $nodeId, array $deptById): bool
|
||||
{
|
||||
$current = $nodeId;
|
||||
$seen = [];
|
||||
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
|
||||
$seen[$current] = true;
|
||||
$pid = $deptById[$current]['pid'];
|
||||
if ($pid === $ancestorId) {
|
||||
return true;
|
||||
}
|
||||
$current = $pid;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门负责人姓名匹配当前组长时,把该部门算进小组。仅用于已判定为组长的账号。
|
||||
*
|
||||
* @param int[] $ownDeptIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function ledDeptIds(int $adminId, array $adminInfo, array $ownDeptIds): array
|
||||
{
|
||||
$name = self::normalizePersonName((string) ($adminInfo['name'] ?? ''));
|
||||
if ($adminId <= 0 || $name === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'leader'])->select()->toArray();
|
||||
$ownSet = array_fill_keys($ownDeptIds, true);
|
||||
$led = [];
|
||||
foreach ($rows as $row) {
|
||||
$deptId = (int) ($row['id'] ?? 0);
|
||||
$leaderName = self::normalizePersonName((string) ($row['leader'] ?? ''));
|
||||
if ($deptId <= 0 || $leaderName === '' || $leaderName !== $name) {
|
||||
continue;
|
||||
}
|
||||
if ($ownSet === [] || isset($ownSet[$deptId]) || self::deptUnderOwnTree($deptId, $ownDeptIds)) {
|
||||
$led[] = $deptId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($led));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ownDeptIds
|
||||
*/
|
||||
private static function deptUnderOwnTree(int $deptId, array $ownDeptIds): bool
|
||||
{
|
||||
foreach ($ownDeptIds as $rootId) {
|
||||
$ids = DeptLogic::getSelfAndDescendantIds((int) $rootId);
|
||||
foreach ($ids as $id) {
|
||||
if ((int) $id === $deptId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $deptIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function dropCenterRootsIfHasDeeper(array $deptIds): array
|
||||
{
|
||||
$names = [];
|
||||
if ($deptIds !== []) {
|
||||
$names = Dept::whereIn('id', $deptIds)->whereNull('delete_time')->column('name', 'id');
|
||||
}
|
||||
$hasDeeper = false;
|
||||
foreach ($deptIds as $id) {
|
||||
$name = (string) ($names[$id] ?? '');
|
||||
if ($name !== '' && mb_strpos($name, '一中心') === false && mb_strpos($name, '二中心') === false) {
|
||||
$hasDeeper = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasDeeper) {
|
||||
return $deptIds;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($deptIds as $id) {
|
||||
$name = (string) ($names[$id] ?? '');
|
||||
if ($name !== '' && (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false)) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $id;
|
||||
}
|
||||
|
||||
return $kept;
|
||||
}
|
||||
|
||||
private static function normalizePersonName(string $raw): string
|
||||
{
|
||||
$value = trim($raw);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$value = preg_replace('/[((][^))]*[))]/u', '', $value) ?? $value;
|
||||
$value = preg_replace('/[\s\x{3000}]+/u', '', $value) ?? $value;
|
||||
$suffixes = ['组长', '负责人', '主管', '主任', '医师', '医生', '医助', '老师'];
|
||||
foreach ($suffixes as $suffix) {
|
||||
$len = mb_strlen($suffix);
|
||||
while (mb_strlen($value) > $len && mb_substr($value, -$len) === $suffix) {
|
||||
$value = mb_substr($value, 0, mb_strlen($value) - $len);
|
||||
}
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private static function adminsInOwnDeptTree(int $adminId): array
|
||||
{
|
||||
$ownDeptIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($ownDeptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$deptIds = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$deptIds[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
$deptIds = array_values(array_unique($deptIds));
|
||||
if ($deptIds === []) {
|
||||
return [$adminId];
|
||||
}
|
||||
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
|
||||
$adminIds[] = $adminId;
|
||||
}
|
||||
|
||||
return $adminIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private static function roleNames(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$roleIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($roleIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->column('name');
|
||||
|
||||
return array_values(array_filter(array_map('strval', $names)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roleNames
|
||||
* @param string[] $needles
|
||||
*/
|
||||
private static function roleNamesMatch(array $roleNames, array $needles): bool
|
||||
{
|
||||
foreach ($roleNames as $name) {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($needles as $needle) {
|
||||
if ($name === $needle || mb_strpos($name, $needle) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,10 @@ class AdminAuthCache extends BaseCache
|
||||
*/
|
||||
public function clearAuthCache()
|
||||
{
|
||||
$this->tag($this->cacheUrlKey)->clear();
|
||||
return true;
|
||||
// BaseCache::set() 使用类名作为标签,并没有使用 cacheUrlKey 作为标签;
|
||||
// 这里必须直接删除单账号缓存键,否则角色变更后旧权限会继续保留一小时。
|
||||
return $this->delete($this->cacheUrlKey);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ use think\facade\Config;
|
||||
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
|
||||
* - SELF (4) = 仅本人
|
||||
*
|
||||
* 多角色时取「最严格」可见范围 = data_scope 最大值(1=全部 … 4=仅本人),
|
||||
* 与常见「数据权限取交集」一致,避免挂了一个「全部」角色就把其它角色的部门范围冲掉。
|
||||
* 多角色时,2/3/4 范围按授权并集取最宽范围;迁移期遗留的普通 scope=1 角色不会
|
||||
* 自动冲掉有限范围。只有明确的管理员角色、root 或 exempt_roles 才能在组合角色中放开全部。
|
||||
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
|
||||
*
|
||||
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 null(ALL = 不过滤)。
|
||||
@@ -32,6 +32,9 @@ class DataScopeService
|
||||
public const SCOPE_DEPT = 3;
|
||||
public const SCOPE_SELF = 4;
|
||||
|
||||
/** @var string[] 明确允许在多角色组合中放开全部数据的内置角色名。 */
|
||||
private const ALL_SCOPE_ROLE_NAMES = ['管理员', '系统管理员'];
|
||||
|
||||
/**
|
||||
* 计算当前 admin 的有效数据范围。
|
||||
*/
|
||||
@@ -51,18 +54,59 @@ class DataScopeService
|
||||
if ($roleIds === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
$scopes = SystemRole::whereIn('id', $roleIds)
|
||||
$roleRows = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->column('data_scope');
|
||||
$scopes = array_values(array_filter(array_map('intval', $scopes), static function (int $v): bool {
|
||||
return $v >= self::SCOPE_ALL && $v <= self::SCOPE_SELF;
|
||||
}));
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到「仅本人」,避免误放开到全站
|
||||
if ($scopes === []) {
|
||||
->field('id,name,data_scope')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return self::mergeRoleScopes($roleRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个角色的数据范围。
|
||||
*
|
||||
* 历史迁移曾把全部旧角色默认成 scope=1;如果账号同时具有有限范围角色,普通的
|
||||
* scope=1 视为功能角色而不参与放大,防止“医生 + 医助”等组合意外获得全站数据。
|
||||
* 2/3/4 是嵌套授权,取最小值即可表达多个有效数据角色的可见范围并集。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $roleRows
|
||||
*/
|
||||
private static function mergeRoleScopes(array $roleRows): int
|
||||
{
|
||||
$validRows = [];
|
||||
foreach ($roleRows as $roleRow) {
|
||||
$scope = (int) ($roleRow['data_scope'] ?? 0);
|
||||
if ($scope < self::SCOPE_ALL || $scope > self::SCOPE_SELF) {
|
||||
continue;
|
||||
}
|
||||
$validRows[] = [
|
||||
'name' => trim((string) ($roleRow['name'] ?? '')),
|
||||
'scope' => $scope,
|
||||
];
|
||||
}
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到本人。
|
||||
if ($validRows === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
|
||||
return (int) max($scopes);
|
||||
foreach ($validRows as $roleRow) {
|
||||
if ($roleRow['scope'] === self::SCOPE_ALL
|
||||
&& in_array($roleRow['name'], self::ALL_SCOPE_ROLE_NAMES, true)) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
}
|
||||
|
||||
$boundedScopes = array_column(array_values(array_filter(
|
||||
$validRows,
|
||||
static fn (array $roleRow): bool => $roleRow['scope'] > self::SCOPE_ALL
|
||||
)), 'scope');
|
||||
if ($boundedScopes !== []) {
|
||||
return (int) min($boundedScopes);
|
||||
}
|
||||
|
||||
// 只有普通 scope=1 角色时保持原有“全部数据”行为。
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,7 +140,8 @@ return [
|
||||
/*
|
||||
* 数据隔离(按部门):全局可用,角色上的 data_scope 控制范围
|
||||
* 1=全部 2=本部门及下级 3=仅本部门 4=仅本人
|
||||
* root 管理员永远视为「全部」;多角色取最严格范围(数值最大,与 DataScopeService 一致)
|
||||
* root/管理员/豁免角色永远视为「全部」;多角色的有限范围取授权并集(数值最小)。
|
||||
* 历史默认 scope=1 的普通功能角色与有限范围角色组合时不参与放大,避免意外越权。
|
||||
*/
|
||||
'data_scope' => [
|
||||
// 顶层总开关:false 时所有列表不应用按部门的数据隔离(仍保留老的白名单规则)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\auth\AdminLogic;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function adminMultiRoleExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$adminReflection = new ReflectionClass(AdminLogic::class);
|
||||
$normalizeRoleIds = $adminReflection->getMethod('normalizeRoleIds');
|
||||
$roleIdsChanged = $adminReflection->getMethod('roleIdsChanged');
|
||||
$normalizeRoleIds->setAccessible(true);
|
||||
$roleIdsChanged->setAccessible(true);
|
||||
|
||||
adminMultiRoleExpect(
|
||||
$normalizeRoleIds->invoke(null, ['7', 2, 7, 0, -1]) === [2, 7],
|
||||
'Role ids must be normalized, deduplicated and sorted'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
$roleIdsChanged->invoke(null, [2], [2, 7]) === true,
|
||||
'Adding a role must invalidate the existing token'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
$roleIdsChanged->invoke(null, [2, 7], [2]) === true,
|
||||
'Removing a role must invalidate the existing token'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
$roleIdsChanged->invoke(null, [2, 7], [7, 2]) === false,
|
||||
'Changing only role order must not invalidate the token'
|
||||
);
|
||||
|
||||
$cacheMethod = new ReflectionMethod(AdminAuthCache::class, 'clearAuthCache');
|
||||
$sourceLines = file($cacheMethod->getFileName());
|
||||
$methodSource = implode('', array_slice(
|
||||
$sourceLines,
|
||||
$cacheMethod->getStartLine() - 1,
|
||||
$cacheMethod->getEndLine() - $cacheMethod->getStartLine() + 1
|
||||
));
|
||||
adminMultiRoleExpect(
|
||||
str_contains($methodSource, 'delete($this->cacheUrlKey)'),
|
||||
'Single-admin permission cache must delete its concrete cache key'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
!str_contains($methodSource, 'tag($this->cacheUrlKey)'),
|
||||
'Single-admin permission cache must not clear a tag that was never assigned'
|
||||
);
|
||||
|
||||
echo "AdminMultiRoleRegressionTest passed\n";
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function dataScopeMultiRoleExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$mergeRoleScopes = (new ReflectionClass(DataScopeService::class))->getMethod('mergeRoleScopes');
|
||||
$mergeRoleScopes->setAccessible(true);
|
||||
$role = static fn (string $name, int $scope): array => ['name' => $name, 'data_scope' => $scope];
|
||||
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('医助', 4)]) === DataScopeService::SCOPE_SELF,
|
||||
'Single assistant role must remain self-only'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('诊室组长', 3), $role('医助', 4)]) === DataScopeService::SCOPE_DEPT,
|
||||
'Group leader plus assistant must use the group scope'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('经理', 2), $role('诊室组长', 3), $role('医助', 4)])
|
||||
=== DataScopeService::SCOPE_DEPT_AND_CHILD,
|
||||
'Manager plus narrower roles must use department-and-child scope'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('医生', 1), $role('医助', 4)]) === DataScopeService::SCOPE_SELF,
|
||||
'Legacy all-scope functional role must not widen a bounded role'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('管理员', 1), $role('医助', 4)]) === DataScopeService::SCOPE_ALL,
|
||||
'Explicit administrator role must retain all-data scope'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('下单', 1)]) === DataScopeService::SCOPE_ALL,
|
||||
'A standalone legacy all-scope role must keep its existing behavior'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('脏数据', 0)]) === DataScopeService::SCOPE_SELF,
|
||||
'Invalid role scopes must fail closed to self-only'
|
||||
);
|
||||
|
||||
echo "DataScopeMultiRoleTest passed\n";
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function conversionRankingExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass(FirstVisitConversionLogic::class);
|
||||
$rankingKind = $reflection->getMethod('rankingKind');
|
||||
$rankingRows = $reflection->getMethod('rankingRows');
|
||||
$topRows = $reflection->getMethod('topRows');
|
||||
$rankingKind->setAccessible(true);
|
||||
$rankingRows->setAccessible(true);
|
||||
$topRows->setAccessible(true);
|
||||
|
||||
$member = static fn (int $id, string $name, int $orders, float $amount): array => [
|
||||
'id' => "M{$id}_11",
|
||||
'admin_id' => $id,
|
||||
'name' => $name,
|
||||
'type' => 'member',
|
||||
'completed_order_count' => $orders,
|
||||
'completed_order_amount' => $amount,
|
||||
'children' => [],
|
||||
];
|
||||
|
||||
$groupRows = [[
|
||||
'id' => 10,
|
||||
'name' => '一诊中心',
|
||||
'children' => [
|
||||
[
|
||||
'id' => 11,
|
||||
'name' => '一组',
|
||||
'completed_order_count' => 8,
|
||||
'completed_order_amount' => 800,
|
||||
'children' => [$member(101, '甲', 5, 500), $member(102, '乙', 3, 300)],
|
||||
],
|
||||
[
|
||||
'id' => 12,
|
||||
'name' => '二组',
|
||||
'completed_order_count' => 6,
|
||||
'completed_order_amount' => 600,
|
||||
'children' => [$member(103, '丙', 6, 600)],
|
||||
],
|
||||
],
|
||||
]];
|
||||
|
||||
conversionRankingExpect(
|
||||
$rankingKind->invoke(null, DataScopeService::SCOPE_SELF, 0) === 'hidden',
|
||||
'Self-only data range must hide rankings'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankingRows->invoke(null, $groupRows, 'hidden') === [],
|
||||
'Hidden ranking mode must not return ranking rows'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankingKind->invoke(null, DataScopeService::SCOPE_DEPT_AND_CHILD, 101) === 'hidden',
|
||||
'Selecting one employee must switch the ranking to personal mode'
|
||||
);
|
||||
|
||||
$memberRows = $rankingRows->invoke(null, $groupRows, 'member');
|
||||
conversionRankingExpect(count($memberRows) === 3, 'Member ranking mode must include visible group members');
|
||||
$rankedMembers = $topRows->invoke(null, $memberRows, 'completed_order_count');
|
||||
conversionRankingExpect(
|
||||
array_column($rankedMembers, 'name') === ['丙', '甲', '乙'],
|
||||
'Member ranking must be ordered by the selected metric'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankedMembers[0]['id'] === 'M103_11',
|
||||
'Member ranking must preserve its string row key'
|
||||
);
|
||||
|
||||
$teamRows = $rankingRows->invoke(null, $groupRows, 'group');
|
||||
conversionRankingExpect(
|
||||
array_column($teamRows, 'name') === ['一组', '二组'],
|
||||
'Group ranking mode must use direct child departments'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankingKind->invoke(null, DataScopeService::SCOPE_ALL, 0) === 'group',
|
||||
'All-data range must use the group ranking dimension'
|
||||
);
|
||||
|
||||
echo "FirstVisitConversionRankingTest passed\n";
|
||||