更新bug

This commit is contained in:
Your Name
2026-08-17 09:06:49 +08:00
parent 2fc8048844
commit c1eee2d28a
48 changed files with 3646 additions and 1016 deletions
@@ -103,20 +103,20 @@
</article>
</section>
<section class="ranking-grid">
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>订单创建人归属排除取消拒收及退款</p>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</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 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="当前范围暂无订单数据" />
@@ -125,16 +125,16 @@
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>仅统计未取消未拒收且未退款的有效金额</p>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</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 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="当前范围暂无金额数据" />
@@ -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>
@@ -293,7 +293,7 @@ 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: ''
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'],
@@ -451,6 +456,11 @@ 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,9 +474,10 @@ 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) {
@@ -480,8 +491,13 @@ onMounted(loadDashboard)
.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;
}
@@ -490,6 +506,8 @@ onMounted(loadDashboard)
.filter-strip,
.panel,
.metric-card {
min-width: 0;
box-sizing: border-box;
border: 1px solid #dfe5ec;
background: #fff;
}
@@ -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,17 +598,25 @@ 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-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-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.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-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; }
@@ -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;
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 188 KiB

@@ -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,
@@ -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)
File diff suppressed because it is too large Load Diff
+22 -30
View File
@@ -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"
+195 -6
View File
@@ -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()
+3 -3
View File
@@ -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() == ""
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+39 -7
View File
@@ -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 新增部门
@@ -64,10 +64,11 @@ class FirstVisitConversionLogic
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
}
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$assistantValid = self::isActiveAssistant($selectedAssistantId)
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
}
$costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds,
@@ -136,17 +137,18 @@ 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
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
@@ -161,6 +163,7 @@ class FirstVisitConversionLogic
'generated_at' => date('Y-m-d H:i:s'),
'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,9 +532,32 @@ 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
/** 根据生效数据范围返回排行榜展示维度,不能把 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 {
@@ -560,19 +586,55 @@ class FirstVisitConversionLogic
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));
$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 {
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
$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' => (int) ($row['id'] ?? 0),
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], array_slice($rows, 0, 6));
], $rows);
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
@@ -20,7 +20,9 @@ use think\facade\Db;
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
* - 今日预约、面诊沿用 ConversionLogic;挂号按已支付且实收低于 10 元的订单统计。
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄
* - 首页 KPI(业绩/加粉/预约/挂号/接诊/面诊)按角色收窄:医助=本人,组长=本小组,经理=本部门及下级,管理员=全部
* - 本人本月业绩始终只统计当前登录账号。
* - 所有查询都使用当前视角解析出的可见管理员集合收窄;排行榜另按一中心/二中心规则。
*/
class PerformanceDashboardLogic
{
@@ -45,18 +47,44 @@ class PerformanceDashboardLogic
$trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
$scope = self::buildScopeContext($adminId, $adminInfo);
/** @var array<int>|null $visibleAdminIds */
$visibleAdminIds = $scope['_visible_admin_ids'];
$roleScope = PerformanceDashboardScope::resolve($adminId, $adminInfo);
$scope['kind'] = $roleScope['kind'];
$scope['label'] = $roleScope['label'];
$scope['viewer_id'] = $adminId;
/** @var array<int>|null $rankingVisibleAdminIds */
$rankingVisibleAdminIds = $scope['_visible_admin_ids'];
unset($scope['_visible_admin_ids']);
/** @var array<int>|null $metricAdminIds */
$metricAdminIds = $roleScope['metric_admin_ids'];
$isGroupLeader = ($roleScope['kind'] ?? '') === PerformanceDashboardScope::KIND_GROUP_LEADER;
$scope['group_admin_ids'] = $isGroupLeader ? $metricAdminIds : null;
$scope['is_limited'] = $metricAdminIds !== null;
if ($metricAdminIds === null) {
$scope['visible_member_count'] = (int) Db::name('admin')->whereNull('delete_time')->count();
} else {
$scope['visible_member_count'] = count($metricAdminIds);
}
$centerRanking = self::resolveViewerCenterRankingScope($adminId);
$centerDeptIds = $centerRanking['dept_ids'];
$centerLocked = $centerDeptIds !== [];
$isErCenter = (bool) ($centerRanking['is_er'] ?? false);
$requestedRankingDeptId = max(0, (int) ($params['ranking_dept_id'] ?? 0));
$hasRankingDeptParam = array_key_exists('ranking_dept_id', $params)
&& $params['ranking_dept_id'] !== ''
&& $params['ranking_dept_id'] !== null;
if ($centerLocked && $isErCenter && !$hasRankingDeptParam && !$isGroupLeader) {
$requestedRankingDeptId = (int) ($centerRanking['own_dept_id'] ?? 0);
}
$rankingDeptId = self::resolveRankingDeptId(
max(0, (int) ($params['ranking_dept_id'] ?? 0)),
$requestedRankingDeptId,
$adminId,
$adminInfo
$adminInfo,
$centerDeptIds
);
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $metricAdminIds);
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
$registrationDaily = self::loadRegistrationDaily($trendStart, $today, $visibleAdminIds);
$registrationDaily = self::loadRegistrationDaily($trendStart, $today, $metricAdminIds);
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
$previousMonthAmount = self::sumDailyMetric(
@@ -77,7 +105,7 @@ class PerformanceDashboardLogic
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
], $adminId, $adminInfo, $metricAdminIds);
$todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : [];
$yesterdayOverview = ConversionLogic::overview([
'dimension' => 'dept',
@@ -87,7 +115,7 @@ class PerformanceDashboardLogic
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
], $adminId, $adminInfo, $metricAdminIds);
$yesterdaySummary = is_array($yesterdayOverview['summary'] ?? null)
? $yesterdayOverview['summary']
: [];
@@ -99,22 +127,61 @@ class PerformanceDashboardLogic
], $adminId, $adminInfo);
$appointmentRanking = self::buildRegistrationRanking(
$adminId,
$adminInfo,
$scope,
$visibleAdminIds,
$rankingDeptId
$rankingVisibleAdminIds,
$rankingDeptId,
$centerRanking
);
$centerPeopleIds = $centerLocked ? self::adminsInDeptIds($centerDeptIds) : null;
$rankingPeopleIds = $centerPeopleIds;
$rankingPeopleLabel = (string) ($centerRanking['label'] ?? $scope['label'] ?? '');
if (!$isGroupLeader && $rankingDeptId > 0) {
$deptAdminIds = self::departmentAdminIds($rankingDeptId);
$rankingPeopleIds = $rankingPeopleIds === null
? $deptAdminIds
: array_values(array_intersect($rankingPeopleIds, $deptAdminIds));
$rankingPeopleLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingPeopleLabel);
}
if ($isGroupLeader) {
$performanceRanking = self::loadPersonalPerformanceRanking(
$metricAdminIds,
$adminId,
(string) ($scope['viewer_name'] ?? ''),
$today,
true
);
$performanceTitle = '今日组员业绩排行';
$performanceKind = 'person';
$performanceScopeLabel = (string) ($scope['label'] ?? '本小组');
} elseif ($centerLocked) {
$performanceRanking = self::loadPersonalPerformanceRanking(
$rankingPeopleIds ?? [],
$adminId,
(string) ($scope['viewer_name'] ?? ''),
$today
);
$performanceTitle = $rankingPeopleLabel !== ''
? ('今日' . $rankingPeopleLabel . '业绩排行')
: '今日部门业绩排行';
$performanceKind = 'person';
$performanceScopeLabel = $rankingPeopleLabel;
} else {
$performanceRanking = self::buildPerformanceRanking(
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
);
$performanceTitle = '今日部门业绩排行';
$performanceKind = 'dept';
$performanceScopeLabel = (string) ($scope['label'] ?? '');
}
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
'start_date' => $trendStart,
'end_date' => $today,
], $adminId, $adminInfo);
$trendContext = self::narrowTrendContext($trendContext, $metricAdminIds);
$trend = self::buildTrend(
$trendStart,
$today,
$visibleAdminIds,
$metricAdminIds,
$orderDaily,
$registrationDaily,
$trendContext
@@ -144,7 +211,7 @@ class PerformanceDashboardLogic
$target = self::buildTargetProgress(
$adminId,
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF),
(string) ($roleScope['kind'] ?? PerformanceDashboardScope::KIND_ASSISTANT),
date('Y-m'),
$monthAmount,
$personalMonthAmount
@@ -201,14 +268,18 @@ class PerformanceDashboardLogic
'rankings' => [
'appointments' => $appointmentRanking,
'performance' => [
'title' => '今日部门业绩排行',
'scope_label' => (string) ($scope['label'] ?? ''),
'title' => $performanceTitle,
'kind' => $performanceKind,
'scope_label' => $performanceScopeLabel,
'items' => $performanceRanking,
],
],
'filters' => [
'ranking_departments' => self::rankingDepartmentOptions($adminId, $adminInfo),
'ranking_dept_id' => $rankingDeptId,
'ranking_departments' => $isGroupLeader
? []
: self::rankingDepartmentOptions($adminId, $adminInfo, $centerDeptIds),
'ranking_dept_id' => $isGroupLeader ? 0 : $rankingDeptId,
'ranking_selectable' => !$isGroupLeader,
],
'trend' => $trend,
'target' => $target,
@@ -280,8 +351,12 @@ class PerformanceDashboardLogic
];
}
private static function resolveRankingDeptId(int $requestedDeptId, int $adminId, array $adminInfo): int
{
private static function resolveRankingDeptId(
int $requestedDeptId,
int $adminId,
array $adminInfo,
array $centerDeptIds = []
): int {
if ($requestedDeptId <= 0) {
return 0;
}
@@ -289,6 +364,11 @@ class PerformanceDashboardLogic
if (!$exists) {
return 0;
}
if ($centerDeptIds !== []) {
$allowedInCenter = array_fill_keys($centerDeptIds, true);
return isset($allowedInCenter[$requestedDeptId]) ? $requestedDeptId : 0;
}
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
if ($allowedDeptSet !== null && !isset($allowedDeptSet[$requestedDeptId])) {
@@ -298,12 +378,54 @@ class PerformanceDashboardLogic
return $requestedDeptId;
}
/** @return array<int, array<string, mixed>> */
private static function rankingDepartmentOptions(int $adminId, array $adminInfo): array
/**
* @param int[] $centerDeptIds
* @return array<int, array<string, mixed>>
*/
private static function rankingDepartmentOptions(int $adminId, array $adminInfo, array $centerDeptIds = []): array
{
if ($centerDeptIds !== []) {
$tree = DeptLogic::getAllData();
if (!is_array($tree) || $tree === []) {
return [];
}
return self::filterDeptTreeByAllowedIds($tree, array_fill_keys($centerDeptIds, true));
}
return DeptLogic::getAllDataScoped($adminId, $adminInfo);
}
/**
* 只保留允许的部门节点;不允许的祖先只用来托举子树,不会出现在可选项里。
*
* @param array<int, array<string, mixed>> $nodes
* @param array<int, true> $allowedIdMap
* @return array<int, array<string, mixed>>
*/
private static function filterDeptTreeByAllowedIds(array $nodes, array $allowedIdMap): array
{
$out = [];
foreach ($nodes as $node) {
$id = (int) ($node['id'] ?? 0);
$rawChildren = $node['children'] ?? [];
$children = is_array($rawChildren) && $rawChildren !== []
? self::filterDeptTreeByAllowedIds($rawChildren, $allowedIdMap)
: [];
if (isset($allowedIdMap[$id])) {
$row = $node;
$row['children'] = $children;
$out[] = $row;
} else {
foreach ($children as $child) {
$out[] = $child;
}
}
}
return $out;
}
/**
* @param array<int>|null $visibleAdminIds
* @return array<string, array{amount: float, count: int}>
@@ -455,36 +577,207 @@ class PerformanceDashboardLogic
];
}
/**
* 登录人若挂在一中心 / 二中心子树内,实时排行默认看该中心成员。
* 例如挂在洛阳二中心一组,默认看该组,筛选可切换到二中心其它部门,不会混入一中心。
*
* @return array{dept_ids: int[], label: string, is_er: bool, own_dept_id: int}
*/
private static function resolveViewerCenterRankingScope(int $adminId): array
{
$empty = [
'dept_ids' => [],
'label' => '',
'is_er' => false,
'own_dept_id' => 0,
];
if ($adminId <= 0) {
return $empty;
}
$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 $empty;
}
$deptById = [];
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray();
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$deptById[$id] = [
'pid' => (int) ($row['pid'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
];
}
$centerRoots = [];
foreach ($ownDeptIds as $deptId) {
$rootId = self::highestCenterAncestorId($deptId, $deptById);
if ($rootId > 0 && isset($deptById[$rootId])) {
$centerRoots[$rootId] = $deptById[$rootId]['name'];
}
}
if ($centerRoots === []) {
return $empty;
}
$deptIds = [];
$labels = [];
$isEr = false;
foreach ($centerRoots as $rootId => $name) {
foreach (DeptLogic::getSelfAndDescendantIds((int) $rootId) as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
$label = self::centerRankingLabel($name);
if ($label !== '' && !in_array($label, $labels, true)) {
$labels[] = $label;
}
if (mb_strpos($name, '二中心') !== false) {
$isEr = true;
}
}
$deptIds = array_values(array_unique($deptIds));
return [
'dept_ids' => $deptIds,
'label' => implode('、', $labels),
'is_er' => $isEr,
'own_dept_id' => self::pickOwnDeptInCenter($ownDeptIds, $deptIds, $deptById),
];
}
/**
* @param int[] $ownDeptIds
* @param int[] $centerDeptIds
* @param array<int, array{pid: int, name: string}> $deptById
*/
private static function pickOwnDeptInCenter(array $ownDeptIds, array $centerDeptIds, array $deptById): int
{
$centerFlip = array_fill_keys($centerDeptIds, true);
$candidates = [];
foreach ($ownDeptIds as $id) {
if (isset($centerFlip[$id])) {
$candidates[] = $id;
}
}
if ($candidates === []) {
return 0;
}
$best = $candidates[0];
$bestDepth = -1;
foreach ($candidates as $id) {
$depth = 0;
$current = $id;
$seen = [];
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
$seen[$current] = true;
$depth++;
$current = $deptById[$current]['pid'];
}
if ($depth > $bestDepth || ($depth === $bestDepth && $id < $best)) {
$bestDepth = $depth;
$best = $id;
}
}
return $best;
}
/**
* @param array<int, array{pid: int, name: string}> $deptById
*/
private static function highestCenterAncestorId(int $deptId, array $deptById): int
{
$current = $deptId;
$seen = [];
$highest = 0;
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
$seen[$current] = true;
if (self::isCenterDeptName($deptById[$current]['name'])) {
$highest = $current;
}
$current = $deptById[$current]['pid'];
}
return $highest;
}
private static function isCenterDeptName(string $name): bool
{
return $name !== ''
&& (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false);
}
private static function deptBelongsToErCenter(int $deptId): bool
{
if ($deptId <= 0) {
return false;
}
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
return isset($erSet[$deptId]);
}
private static function centerRankingLabel(string $name): string
{
if (mb_strpos($name, '二中心') !== false) {
return '二中心';
}
if (mb_strpos($name, '一中心') !== false) {
return '一中心';
}
return $name;
}
/**
* @param array<string, mixed> $scope
* @param array<int>|null $baseVisibleAdminIds
* @param array{dept_ids: int[], label: string, is_er?: bool, own_dept_id?: int} $centerRanking
* @return array<string, mixed>
*/
private static function buildRegistrationRanking(
int $adminId,
array $adminInfo,
array $scope,
?array $baseVisibleAdminIds,
int $rankingDeptId
int $rankingDeptId,
array $centerRanking
): array
{
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
$isGroupLeader = ($scope['kind'] ?? '') === PerformanceDashboardScope::KIND_GROUP_LEADER;
$centerDeptIds = $centerRanking['dept_ids'] ?? [];
$centerLocked = $centerDeptIds !== [];
$isDoctorSelf = !$centerLocked
&& !$isGroupLeader
&& ($scope['key'] ?? '') === 'self'
&& in_array(1, $roleIds, true)
&& !in_array(2, $roleIds, true);
$rankingVisibleAdminIds = $baseVisibleAdminIds;
$rankingScopeLabel = (string) ($scope['label'] ?? '');
if (
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
&& in_array(2, $roleIds, true)
) {
$departmentAssistantIds = self::directDepartmentAssistantIds($adminId);
if ($departmentAssistantIds !== []) {
$rankingVisibleAdminIds = $departmentAssistantIds;
$rankingScopeLabel = '本人所属部门';
if ($isGroupLeader) {
$groupIds = $scope['group_admin_ids'] ?? null;
$rankingVisibleAdminIds = is_array($groupIds) ? array_values(array_filter(
array_map('intval', $groupIds),
static fn (int $id): bool => $id > 0
)) : [];
$rankingScopeLabel = '本小组';
} elseif ($centerLocked) {
$rankingVisibleAdminIds = self::adminsInDeptIds($centerDeptIds);
$rankingScopeLabel = (string) ($centerRanking['label'] ?? '本中心');
}
}
if ($rankingDeptId > 0) {
if (!$isGroupLeader && $rankingDeptId > 0) {
$deptAdminIds = self::departmentAdminIds($rankingDeptId);
$rankingVisibleAdminIds = $rankingVisibleAdminIds === null
? $deptAdminIds
@@ -492,8 +785,41 @@ class PerformanceDashboardLogic
$rankingScopeLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingScopeLabel);
}
$isErRanking = $rankingDeptId > 0
? self::deptBelongsToErCenter($rankingDeptId)
: (bool) ($centerRanking['is_er'] ?? false);
$items = $isErRanking
? self::loadAppointmentRankingItems($rankingVisibleAdminIds)
: self::loadRegistrationRankingItems($rankingVisibleAdminIds);
$items = self::finalizePersonRanking(
$items,
$rankingVisibleAdminIds,
$adminId,
(string) ($scope['viewer_name'] ?? ''),
$isGroupLeader
);
return [
'title' => $isErRanking ? '实时预约排行' : '实时挂号排行',
'kind' => $isDoctorSelf ? 'doctor' : 'member',
'metric' => $isErRanking ? 'appointment' : 'registration',
'scope_label' => $rankingScopeLabel,
'items' => $items,
];
}
/**
* @param array<int>|null $rankingVisibleAdminIds
* @return array<int, array<string, mixed>>
*/
private static function loadRegistrationRankingItems(?array $rankingVisibleAdminIds): array
{
$items = [];
if ($rankingVisibleAdminIds !== []) {
if ($rankingVisibleAdminIds === []) {
return $items;
}
$query = Db::name('order')
->alias('o')
->join('admin a', 'a.id = o.creator_id AND a.delete_time IS NULL', 'INNER')
@@ -501,7 +827,6 @@ class PerformanceDashboardLogic
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('o.payment_time')
->whereBetweenTime('o.payment_time', date('Y-m-d 00:00:00'), date('Y-m-d 23:59:59'));
if ($rankingVisibleAdminIds !== null) {
@@ -512,7 +837,6 @@ class PerformanceDashboardLogic
->fieldRaw('o.creator_id AS id, a.name, COUNT(*) AS item_count, SUM(o.amount) AS amount_sum')
->group(['o.creator_id', 'a.name'])
->orderRaw('item_count DESC, amount_sum DESC, o.creator_id ASC')
->limit(5)
->select()
->toArray();
foreach ($rows as $row) {
@@ -523,57 +847,300 @@ class PerformanceDashboardLogic
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
];
}
return $items;
}
return [
'title' => '实时挂号排行',
'kind' => $isDoctorSelf ? 'doctor' : 'member',
'scope_label' => $rankingScopeLabel,
'items' => $items,
/**
* 二中心实时预约排行:与驾驶舱「今日预约」同口径(预约日、状态已预约/已完成/改期)。
* 归属优先挂号医助、再诊单医助,缺失时回退医生。
*
* @param array<int>|null $rankingVisibleAdminIds
* @return array<int, array<string, mixed>>
*/
private static function loadAppointmentRankingItems(?array $rankingVisibleAdminIds): array
{
if ($rankingVisibleAdminIds === []) {
return [];
}
$today = date('Y-m-d');
$effectiveAssistantSql = 'COALESCE(NULLIF(da.assistant_id, 0), NULLIF(dg.assistant_id, 0))';
$rows = Db::name('doctor_appointment')
->alias('da')
->leftJoin('tcm_diagnosis dg', 'da.patient_id = dg.id')
->where('da.appointment_date', $today)
->whereIn('da.status', [1, 3, 4])
->whereRaw('(dg.id IS NULL OR dg.delete_time IS NULL)')
->field([
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
'da.doctor_id',
Db::raw('COUNT(*) AS item_count'),
])
->group([$effectiveAssistantSql, 'da.doctor_id'])
->select()
->toArray();
$visibleFlip = $rankingVisibleAdminIds !== null ? array_flip($rankingVisibleAdminIds) : null;
$counts = [];
foreach ($rows as $row) {
$assistantId = (int) ($row['effective_assistant_id'] ?? 0);
$doctorId = (int) ($row['doctor_id'] ?? 0);
$ownerId = $assistantId > 0 ? $assistantId : $doctorId;
if ($ownerId <= 0) {
continue;
}
if ($visibleFlip !== null && !isset($visibleFlip[$ownerId])) {
continue;
}
$counts[$ownerId] = ($counts[$ownerId] ?? 0) + (int) ($row['item_count'] ?? 0);
}
if ($counts === []) {
return [];
}
arsort($counts, SORT_NUMERIC);
$ownerIds = array_keys($counts);
$nameMap = [];
if ($ownerIds !== []) {
$nameRows = Db::name('admin')
->whereIn('id', $ownerIds)
->whereNull('delete_time')
->field(['id', 'name'])
->select()
->toArray();
foreach ($nameRows as $nameRow) {
$nameMap[(int) ($nameRow['id'] ?? 0)] = (string) ($nameRow['name'] ?? '');
}
}
$items = [];
foreach ($ownerIds as $ownerId) {
$items[] = [
'id' => (int) $ownerId,
'name' => $nameMap[(int) $ownerId] ?? '',
'count' => (int) ($counts[$ownerId] ?? 0),
'amount' => 0.0,
];
}
return $items;
}
/**
* 把范围内所有成员补进排行(没单的记 0)。组长看全组;其他人仍只留前 5 + 本人。
*
* @param array<int, array<string, mixed>> $items
* @param array<int>|null $memberIds
* @return array<int, array<string, mixed>>
*/
private static function finalizePersonRanking(
array $items,
?array $memberIds,
int $viewerId,
string $viewerName,
bool $showAllMembers = false
): array {
if ($memberIds !== null) {
$memberIds = array_values(array_unique(array_filter(
array_map('intval', $memberIds),
static fn (int $id): bool => $id > 0
)));
$byId = [];
foreach ($items as $item) {
$id = (int) ($item['id'] ?? 0);
if ($id > 0) {
$byId[$id] = $item;
}
}
$missing = [];
foreach ($memberIds as $id) {
if (!isset($byId[$id])) {
$missing[] = $id;
}
}
$nameMap = [];
$lookupIds = $missing;
if ($viewerId > 0 && ($viewerName === '' || isset($byId[$viewerId]) === false)) {
$lookupIds[] = $viewerId;
}
$lookupIds = array_values(array_unique($lookupIds));
if ($lookupIds !== []) {
$nameMap = Db::name('admin')
->whereIn('id', $lookupIds)
->whereNull('delete_time')
->column('name', 'id');
}
$activeFlip = [];
if ($memberIds !== []) {
$activeIds = Db::name('admin')
->whereIn('id', $memberIds)
->whereNull('delete_time')
->column('id');
$activeFlip = array_fill_keys(array_map('intval', $activeIds), true);
}
foreach ($memberIds as $id) {
if (!isset($activeFlip[$id]) && $id !== $viewerId) {
continue;
}
if (!isset($byId[$id])) {
$byId[$id] = [
'id' => $id,
'name' => (string) ($nameMap[$id] ?? ''),
'count' => 0,
'amount' => 0.0,
];
}
}
$items = array_values($byId);
usort($items, static function (array $a, array $b): int {
$byAmount = (float) ($b['amount'] ?? 0) <=> (float) ($a['amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
$byCount = (int) ($b['count'] ?? 0) <=> (int) ($a['count'] ?? 0);
if ($byCount !== 0) {
return $byCount;
}
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
});
}
if ($showAllMembers) {
foreach ($items as &$item) {
$item['is_self'] = (int) ($item['id'] ?? 0) === $viewerId;
if ($item['is_self'] && $viewerName !== '' && (string) ($item['name'] ?? '') === '') {
$item['name'] = $viewerName;
}
}
unset($item);
if ($viewerId > 0) {
$hasSelf = false;
foreach ($items as $item) {
if (!empty($item['is_self'])) {
$hasSelf = true;
break;
}
}
if (!$hasSelf) {
$items[] = [
'id' => $viewerId,
'name' => $viewerName,
'count' => 0,
'amount' => 0.0,
'is_self' => true,
];
}
}
return $items;
}
return self::ensureViewerInRanking($items, $viewerId, $viewerName);
}
/**
* 排行保留前 5 名,若当前登录人不在榜内则追加到底部,并用 is_self 标识。
*
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private static function ensureViewerInRanking(array $items, int $viewerId, string $viewerName, int $limit = 5): array
{
if ($viewerId <= 0) {
return array_slice($items, 0, $limit);
}
$selfItem = null;
foreach ($items as &$item) {
$isSelf = (int) ($item['id'] ?? 0) === $viewerId;
$item['is_self'] = $isSelf;
if ($isSelf) {
$selfItem = $item;
}
}
unset($item);
$top = array_slice($items, 0, $limit);
foreach ($top as $item) {
if (!empty($item['is_self'])) {
return $top;
}
}
$top[] = $selfItem ?? [
'id' => $viewerId,
'name' => $viewerName,
'count' => 0,
'amount' => 0.0,
'is_self' => true,
];
return $top;
}
/**
* 按个人今日业绩排行。组长会补齐组内全部成员(没单也列出)。
*
* @param array<int>|null $adminIds
* @return array<int, array<string, mixed>>
*/
private static function loadPersonalPerformanceRanking(
?array $adminIds,
int $viewerId,
string $viewerName,
string $today,
bool $showAllMembers = false
): array {
if ($adminIds === []) {
return self::finalizePersonRanking([], $adminIds, $viewerId, $viewerName, $showAllMembers);
}
$startTs = (int) strtotime($today . ' 00:00:00');
$endTs = (int) strtotime($today . ' 23:59:59');
$query = Db::name('tcm_prescription_order')
->alias('po')
->join('admin a', 'a.id = po.creator_id AND a.delete_time IS NULL', 'INNER')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
if ($adminIds !== null) {
$query->whereIn('po.creator_id', $adminIds);
}
$rows = $query
->fieldRaw('po.creator_id AS id, a.name, COUNT(*) AS order_count, SUM(po.amount) AS amount_sum')
->group(['po.creator_id', 'a.name'])
->orderRaw('amount_sum DESC, order_count DESC, po.creator_id ASC')
->select()
->toArray();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['order_count'] ?? 0),
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
];
}
return self::finalizePersonRanking($items, $adminIds, $viewerId, $viewerName, $showAllMembers);
}
/** @return int[] */
private static function departmentAdminIds(int $deptId): array
{
$deptIds = array_values(array_unique(array_filter(
array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)),
static fn (int $id): bool => $id > 0
)));
if ($deptIds === []) {
return [];
}
return array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
return self::adminsInDeptIds(DeptLogic::getSelfAndDescendantIds($deptId));
}
/**
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
*
* @param int[] $deptIds
* @return int[]
*/
private static function directDepartmentAssistantIds(int $adminId): array
private static function adminsInDeptIds(array $deptIds): array
{
if ($adminId <= 0) {
return [];
}
$activeAdmin = Db::name('admin')
->where('id', $adminId)
->whereNull('delete_time')
->value('id');
if ((int) $activeAdmin <= 0) {
return [];
}
$deptIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->where('ad.admin_id', $adminId)
->column('ad.dept_id');
$deptIds = array_values(array_unique(array_filter(
array_map('intval', $deptIds),
static fn (int $id): bool => $id > 0
@@ -582,18 +1149,8 @@ class PerformanceDashboardLogic
return [];
}
$assistantIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->join('admin a', 'a.id = ad.admin_id AND a.delete_time IS NULL', 'INNER')
->join('admin_role ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
->join('system_role sr', 'sr.id = ar.role_id AND sr.delete_time IS NULL', 'INNER')
->whereIn('ad.dept_id', $deptIds)
->distinct(true)
->column('a.id');
return array_values(array_unique(array_filter(
array_map('intval', $assistantIds),
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
}
@@ -631,6 +1188,48 @@ class PerformanceDashboardLogic
return $items;
}
/**
* @param array<string, mixed> $trendContext
* @param array<int>|null $visibleAdminIds
* @return array<string, mixed>
*/
private static function narrowTrendContext(array $trendContext, ?array $visibleAdminIds): array
{
if ($visibleAdminIds === null) {
return $trendContext;
}
$flip = array_flip($visibleAdminIds);
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
? $trendContext['adminToPrimary']
: [];
$filtered = [];
$extraDeptIds = [];
foreach ($adminToPrimary as $adminId => $deptId) {
$adminId = (int) $adminId;
$deptId = (int) $deptId;
if (!isset($flip[$adminId])) {
continue;
}
$filtered[$adminId] = $deptId;
if ($deptId > 0) {
$extraDeptIds[$deptId] = true;
}
}
$trendContext['adminToPrimary'] = $filtered;
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
? array_map('intval', $trendContext['tableRowDeptIds'])
: [];
foreach (array_keys($extraDeptIds) as $deptId) {
$tableRowDeptIds[] = (int) $deptId;
}
$trendContext['tableRowDeptIds'] = array_values(array_unique(array_filter(
$tableRowDeptIds,
static fn (int $id): bool => $id > 0
)));
return $trendContext;
}
/**
* @param array<int>|null $visibleAdminIds
* @param array<string, array{amount: float, count: int}> $orderDaily
@@ -824,12 +1423,12 @@ class PerformanceDashboardLogic
*/
private static function buildTargetProgress(
int $adminId,
int $scopeValue,
string $kind,
string $yearMonth,
float $completedAmount,
float $personalAmount
): array {
$deptIds = self::targetDeptIds($adminId, $scopeValue);
$deptIds = self::targetDeptIds($adminId, $kind);
$query = Db::name('dept_performance_target')->where('year_month', $yearMonth);
if ($deptIds !== null) {
if ($deptIds === []) {
@@ -859,15 +1458,18 @@ class PerformanceDashboardLogic
/**
* @return array<int>|null null 表示全部部门。
*/
private static function targetDeptIds(int $adminId, int $scopeValue): ?array
private static function targetDeptIds(int $adminId, string $kind): ?array
{
if ($scopeValue === DataScopeService::SCOPE_ALL) {
if ($kind === PerformanceDashboardScope::KIND_ADMIN) {
return null;
}
$ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
$ownDeptIds = array_values(array_unique(array_filter(array_map('intval', $ownDeptIds), static fn (int $id): bool => $id > 0)));
if ($ownDeptIds === [] || $scopeValue !== DataScopeService::SCOPE_DEPT_AND_CHILD) {
$ownDeptIds = array_values(array_unique(array_filter(
array_map('intval', $ownDeptIds),
static fn (int $id): bool => $id > 0
)));
if ($ownDeptIds === [] || $kind === PerformanceDashboardScope::KIND_ASSISTANT) {
return $ownDeptIds;
}
@@ -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;
}
}
+3 -2
View File
@@ -114,8 +114,9 @@ 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 集合)或 nullALL = 不过滤)。
@@ -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;
}
/**
+2 -1
View File
@@ -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";
+50
View File
@@ -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";