This commit is contained in:
Your Name
2026-05-22 11:15:59 +08:00
parent 49c8c95966
commit 22ec53c070
6 changed files with 331 additions and 64 deletions
+112 -30
View File
@@ -127,10 +127,10 @@
<span class="yeji-table-tabs-head__title">表格统计</span> <span class="yeji-table-tabs-head__title">表格统计</span>
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览部门 / 排名列在滚动时保持固定</span> <span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览部门 / 排名列在滚动时保持固定</span>
</div> </div>
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs"> <el-tabs v-if="hasAnyYejiTableTab" v-model="yejiDisplayTab" class="yeji-view-tabs">
<el-tab-pane label="医生统计" name="doctor" lazy> <el-tab-pane v-if="canViewDoctorTab" label="医生统计" name="doctor" lazy>
<el-card <el-card
v-if="canViewDoctorDailyStats" v-if="canViewDoctorTab"
class="yeji-panel doctor-daily-card doctor-daily-card--nested" class="yeji-panel doctor-daily-card doctor-daily-card--nested"
shadow="never" shadow="never"
> >
@@ -189,7 +189,7 @@
<el-empty v-else description="当前账号无医生统计权限" /> <el-empty v-else description="当前账号无医生统计权限" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="医助排行榜" name="leaderboard" lazy> <el-tab-pane v-if="canViewLeaderboardTab" label="医助排行榜" name="leaderboard" lazy>
<div <div
v-if="leaderboardBlock || leaderboardsLoading" v-if="leaderboardBlock || leaderboardsLoading"
class="leaderboards-wrap" class="leaderboards-wrap"
@@ -316,7 +316,7 @@
/> />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy> <el-tab-pane v-if="canViewZyytTab" label="甄养堂互联网医院诊金" name="zyyt" lazy>
<!-- 业绩表4 /N 目标看板见目标看板卡片Tab --> <!-- 业绩表4 /N 目标看板见目标看板卡片Tab -->
<div v-loading="loading" class="tables-wrap"> <div v-loading="loading" class="tables-wrap">
<div v-if="!loading && tables.length === 0" class="empty-tip"> <div v-if="!loading && tables.length === 0" class="empty-tip">
@@ -445,12 +445,8 @@
<el-tooltip placement="top" effect="dark" :show-after="200"> <el-tooltip placement="top" effect="dark" :show-after="200">
<template #content> <template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px"> <div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<<<<<<< HEAD
<b>业务订单条数</b>计业绩订单 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b><b>NULL 计入</b><b>订单创建人</b>的人事部门落在该部门子树即计入表格多行命中时取最深的展示部门<b>合计业绩 / 医助排行榜接诊诊单</b>同口径选定渠道时本列仍为全量
=======
<b>业务订单条数</b>计业绩订单 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b>与列表筛选 <b>业务订单条数</b>计业绩订单 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b>与列表筛选
<b>assistant_dept_id</b> 时一致<b>创建人</b>人事部门优先无创建人则诊单 <b>医助</b>落在该部门子树即计入表格多行命中时取最深的展示部门与侧栏勾选与表格业绩对齐时的集合可能略有差异选定渠道时本列仍为全量 <b>assistant_dept_id</b> 时一致<b>创建人</b>人事部门优先无创建人则诊单 <b>医助</b>落在该部门子树即计入表格多行命中时取最深的展示部门与侧栏勾选与表格业绩对齐时的集合可能略有差异选定渠道时本列仍为全量
>>>>>>> master
</div> </div>
</template> </template>
<el-icon class="col-info"><InfoFilled /></el-icon> <el-icon class="col-info"><InfoFilled /></el-icon>
@@ -636,7 +632,7 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy> <el-tab-pane v-if="canViewTargetMatrixTab" label="目标看板(卡片)" name="zyyt_matrix" lazy>
<div v-loading="targetProgressLoading" class="tp-matrix-wrap"> <div v-loading="targetProgressLoading" class="tp-matrix-wrap">
<template v-if="targetProgressCard"> <template v-if="targetProgressCard">
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2> <h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
@@ -702,18 +698,19 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-empty v-else description="当前账号无表格统计 Tab 权限" />
</el-card> </el-card>
<el-card class="yeji-panel yeji-charts-only-card" shadow="never"> <el-card class="yeji-panel yeji-charts-only-card" shadow="never">
<div class="yeji-charts-only-head">统计图</div> <div class="yeji-charts-only-head">统计图</div>
<div <div
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading" v-loading="loading || (canViewDoctorTab && doctorDailyLoading) || (canViewLeaderboardTab && leaderboardsLoading)"
class="yeji-charts-stack" class="yeji-charts-stack"
> >
<template v-if="chartsHasAnyData"> <template v-if="chartsHasAnyData">
<section <section
v-if=" v-if="
canViewDoctorDailyStats && canViewDoctorTab &&
(doctorDealBarHasData || (doctorDealBarHasData ||
doctorRxStackHasData || doctorRxStackHasData ||
doctorAppointmentPieHasData || doctorAppointmentPieHasData ||
@@ -855,7 +852,7 @@
</section> </section>
</template> </template>
<template v-if="leaderboardBlock"> <template v-if="canViewLeaderboardTab && leaderboardBlock">
<section <section
v-for="lb in leaderboardChartBlocks" v-for="lb in leaderboardChartBlocks"
:key="'lb-chart-' + lb.dept_id" :key="'lb-chart-' + lb.dept_id"
@@ -898,8 +895,8 @@
<el-empty <el-empty
v-else-if=" v-else-if="
!loading && !loading &&
(!canViewDoctorDailyStats || !doctorDailyLoading) && (!canViewDoctorTab || !doctorDailyLoading) &&
!leaderboardsLoading (!canViewLeaderboardTab || !leaderboardsLoading)
" "
class="yeji-charts-empty" class="yeji-charts-empty"
description="当前筛选下暂无图表数据,请调整条件后查询" description="当前筛选下暂无图表数据,请调整条件后查询"
@@ -1318,7 +1315,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue' import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -1337,7 +1334,14 @@ import {
} from '@/api/stats' } from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance' import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm' import { prescriptionOrderLists } from '@/api/tcm'
import useUserStore from '@/stores/modules/user' import { hasPermission } from '@/utils/perm'
type YejiDisplayTab = 'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'
/** 任一权限命中即可(兼容原有接口按钮权限 + 新增 Tab 独立权限) */
function hasAnyPermission(perms: string[]): boolean {
return perms.some(p => hasPermission([p]))
}
interface DeptOption { interface DeptOption {
id: number id: number
@@ -1664,14 +1668,60 @@ const appointmentLinesShowChannelColumn = computed(() =>
) )
/** 图表 / 数据表切换 */ /** 图表 / 数据表切换 */
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt') const yejiDisplayTab = ref<YejiDisplayTab>('zyyt')
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */ /** Tab 可见性:优先认 Tab 独立权限;未配置时兼容原有接口按钮权限,避免破坏已有角色 */
const userStore = useUserStore() const YEJI_TAB_DOCTOR_PERM = 'stats.yejiStats/tabDoctor'
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview' const YEJI_TAB_LEADERBOARD_PERM = 'stats.yejiStats/tabLeaderboard'
const canViewDoctorDailyStats = computed(() => { const YEJI_TAB_ZYYT_PERM = 'stats.yejiStats/tabZyyt'
const perms = userStore.perms || [] const YEJI_TAB_TARGET_MATRIX_PERM = 'stats.yejiStats/tabTargetMatrix'
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
const canViewDoctorTab = computed(() =>
hasAnyPermission([YEJI_TAB_DOCTOR_PERM, 'stats.doctorDailyStats/overview'])
)
const canViewLeaderboardTab = computed(() =>
hasAnyPermission([YEJI_TAB_LEADERBOARD_PERM, 'stats.yejiStats/leaderboard'])
)
const canViewZyytTab = computed(() =>
hasAnyPermission([
YEJI_TAB_ZYYT_PERM,
'stats.yejiStats/multi',
'stats.yejiStats/overview',
])
)
const canViewTargetMatrixTab = computed(() =>
hasAnyPermission([
YEJI_TAB_TARGET_MATRIX_PERM,
'stats.yejiStats/targetMatrix',
'stats.yejiStats/multi',
])
)
const hasAnyYejiTableTab = computed(
() =>
canViewDoctorTab.value ||
canViewLeaderboardTab.value ||
canViewZyytTab.value ||
canViewTargetMatrixTab.value
)
function pickInitialYejiTab(): YejiDisplayTab {
if (canViewZyytTab.value) return 'zyyt'
if (canViewDoctorTab.value) return 'doctor'
if (canViewLeaderboardTab.value) return 'leaderboard'
if (canViewTargetMatrixTab.value) return 'zyyt_matrix'
return 'zyyt'
}
watch(yejiDisplayTab, tab => {
if (tab === 'doctor' && !canViewDoctorTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
} else if (tab === 'leaderboard' && !canViewLeaderboardTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
} else if (tab === 'zyyt' && !canViewZyytTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
} else if (tab === 'zyyt_matrix' && !canViewTargetMatrixTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
}
}) })
const leaderboardChartBlocks = computed(() => const leaderboardChartBlocks = computed(() =>
@@ -1715,7 +1765,7 @@ const doctorRxStackHasData = computed(() =>
) )
const doctorChartsHasData = computed(() => { const doctorChartsHasData = computed(() => {
if (!canViewDoctorDailyStats.value) { if (!canViewDoctorTab.value) {
return false return false
} }
return ( return (
@@ -1731,12 +1781,14 @@ const chartsHasAnyData = computed(() => {
if (doctorChartsHasData.value) { if (doctorChartsHasData.value) {
return true return true
} }
for (const tb of tables.value) { if (canViewZyytTab.value) {
if (yejiDeptChartHasData(tb)) { for (const tb of tables.value) {
return true if (yejiDeptChartHasData(tb)) {
return true
}
} }
} }
if (leaderboardChartBlocks.value.length > 0) { if (canViewLeaderboardTab.value && leaderboardChartBlocks.value.length > 0) {
return true return true
} }
return false return false
@@ -2073,6 +2125,12 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
} }
async function loadDoctorDailyStats() { async function loadDoctorDailyStats() {
if (!canViewDoctorTab.value) {
doctorDailyRows.value = []
doctorDailyTotal.value = {}
doctorDailyRange.value = null
return
}
doctorDailyLoading.value = true doctorDailyLoading.value = true
try { try {
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams()) const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
@@ -2211,6 +2269,10 @@ function daysInMonth(ym: string): number {
} }
async function loadTargetProgress() { async function loadTargetProgress() {
if (!canViewTargetMatrixTab.value) {
targetProgressCard.value = null
return
}
targetProgressLoading.value = true targetProgressLoading.value = true
targetProgressCard.value = null targetProgressCard.value = null
try { try {
@@ -2458,6 +2520,10 @@ async function loadChannelOptions() {
} }
async function loadLeaderboard(range: { start: string; end: string }) { async function loadLeaderboard(range: { start: string; end: string }) {
if (!canViewLeaderboardTab.value) {
leaderboardBlock.value = null
return
}
leaderboardsLoading.value = true leaderboardsLoading.value = true
try { try {
const p: Record<string, any> = { const p: Record<string, any> = {
@@ -2491,6 +2557,18 @@ async function loadLeaderboard(range: { start: string; end: string }) {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
leaderboardBlock.value = null leaderboardBlock.value = null
if (!canViewZyytTab.value) {
tables.value = []
loading.value = false
if (canViewDoctorTab.value) {
void loadDoctorDailyStats()
} else {
doctorDailyRows.value = []
doctorDailyTotal.value = {}
doctorDailyRange.value = null
}
return
}
try { try {
const baseParams: Record<string, any> = {} const baseParams: Record<string, any> = {}
if (selectedDeptIds.value.length > 0) { if (selectedDeptIds.value.length > 0) {
@@ -2542,7 +2620,7 @@ async function loadData() {
ElMessage.error(any?.msg || any?.message || '加载失败') ElMessage.error(any?.msg || any?.message || '加载失败')
} finally { } finally {
loading.value = false loading.value = false
if (canViewDoctorDailyStats.value) { if (canViewDoctorTab.value) {
void loadDoctorDailyStats() void loadDoctorDailyStats()
} else { } else {
doctorDailyRows.value = [] doctorDailyRows.value = []
@@ -2661,6 +2739,9 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
if (selectedDeptIds.value.length > 0) { if (selectedDeptIds.value.length > 0) {
params.dept_ids = selectedDeptIds.value.join(',') params.dept_ids = selectedDeptIds.value.join(',')
} }
if (f.yejiTableRowDeptIds && f.yejiTableRowDeptIds.length > 0) {
params.yeji_table_row_dept_ids = f.yejiTableRowDeptIds.join(',')
}
} else { } else {
params.assistant_id = f.assistantId params.assistant_id = f.assistantId
if (f.erCenterRevisitSlot !== undefined) { if (f.erCenterRevisitSlot !== undefined) {
@@ -3561,6 +3642,7 @@ function formatLocalYmd(d: Date): string {
} }
onMounted(async () => { onMounted(async () => {
yejiDisplayTab.value = pickInitialYejiTab()
await Promise.all([loadDeptOptions(), loadChannelOptions()]) await Promise.all([loadDeptOptions(), loadChannelOptions()])
loadData() loadData()
loadTargetProgress() loadTargetProgress()
@@ -107,7 +107,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyServiceChannelFilter($query); $this->applyServiceChannelFilter($query);
$this->applySupplyModeFilter($query); $this->applySupplyModeFilter($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) { if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query); // 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
if (!$this->shouldSkipCreatorOnlyForYejiDrawer()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
}
$this->applyDataScopeForPrescriptionOrder($query); $this->applyDataScopeForPrescriptionOrder($query);
} }
// 业绩看板侧栏等:不展示履约 4/9/10,与 stats 业绩口径一致 // 业绩看板侧栏等:不展示履约 4/9/10,与 stats 业绩口径一致
@@ -323,6 +326,50 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId; return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId;
} }
/**
* 业绩看板侧栏:按部门行点合计业绩/接诊诊单(assistant_dept_id),组长等应看到组内全员订单。
*/
private function isYejiDrawerDeptPerformanceScope(): bool
{
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
return false;
}
if ((int) ($this->params['yeji_drawer_match_table_performance'] ?? 0) === 1) {
return true;
}
return isset($this->params['assistant_dept_id']) && (int) $this->params['assistant_dept_id'] > 0;
}
/**
* 业绩看板侧栏:部门业绩或复诊下钻查看数据域内其他医助订单时,跳过「仅本人可见」。
*/
private function shouldSkipCreatorOnlyForYejiDrawer(): bool
{
if ($this->isYejiDrawerDeptPerformanceScope()) {
return true;
}
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
return false;
}
if ((int) ($this->params['yeji_er_center_revisit_only'] ?? 0) !== 1) {
return false;
}
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
if ($assistantId <= 0 || $assistantId === (int) $this->adminId) {
return false;
}
if (!$this->dataScopeShouldApply()) {
return true;
}
$visibleIds = $this->getDataScopeVisibleAdminIds();
if ($visibleIds === null) {
return true;
}
return in_array($assistantId, $visibleIds, true);
}
/** /**
* 医助角色统计:仅「订单创建人=本人」计入 * 医助角色统计:仅「订单创建人=本人」计入
*/ */
@@ -341,7 +388,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time'); $query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time');
$this->applyDoctorAssistantFilters($query); $this->applyDoctorAssistantFilters($query);
$this->applyPatientKeywordFilter($query); $this->applyPatientKeywordFilter($query);
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) { if ($this->shouldSkipCreatorOnlyForYejiDrawer()) {
$this->applyDataScopeForPrescriptionOrder($query);
} elseif (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
$this->applyDataScopeForPrescriptionOrder($query); $this->applyDataScopeForPrescriptionOrder($query);
} else { } else {
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2); $assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
+37 -8
View File
@@ -243,7 +243,8 @@ class DeptLogic extends BaseLogic
private static function computeErCenterRevisitFullPack( private static function computeErCenterRevisitFullPack(
array $subtreeDeptIds, array $subtreeDeptIds,
?int $orderStartTs = null, ?int $orderStartTs = null,
?int $orderEndTs = null ?int $orderEndTs = null,
?array $visibleCreatorIds = null
): array { ): array {
$emptySlots = []; $emptySlots = [];
$emptyTotals = []; $emptyTotals = [];
@@ -284,6 +285,14 @@ class DeptLogic extends BaseLogic
/** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */ /** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet); $canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
$creatorFlip = null;
if ($visibleCreatorIds !== null && $visibleCreatorIds !== []) {
$creatorFlip = array_flip(array_values(array_unique(array_filter(
array_map('intval', $visibleCreatorIds),
static fn (int $id): bool => $id > 0
))));
}
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */ /** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = []; $leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */ /** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
@@ -293,6 +302,9 @@ class DeptLogic extends BaseLogic
for ($i = 1; $i < $n; $i++) { for ($i = 1; $i < $n; $i++) {
$slot = $i + 1; $slot = $i + 1;
$cid = (int) ($orders[$i]['creator_id'] ?? 0); $cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($creatorFlip !== null && ($cid <= 0 || !isset($creatorFlip[$cid]))) {
continue;
}
$deptId = $canonicalDept[$cid] ?? null; $deptId = $canonicalDept[$cid] ?? null;
if ($deptId === null || !isset($subtreeSet[$deptId])) { if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue; continue;
@@ -337,14 +349,17 @@ class DeptLogic extends BaseLogic
* *
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>} * @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/ */
public static function getErCenterRevisitRollupIndexedByDept(?int $orderStartTs = null, ?int $orderEndTs = null): array public static function getErCenterRevisitRollupIndexedByDept(
{ ?int $orderStartTs = null,
?int $orderEndTs = null,
?array $visibleCreatorIds = null
): array {
$erRoots = self::findErCenterRootDeptIds(); $erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots); $subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) { if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []]; return ['totals' => [], 'slots' => []];
} }
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs); $pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs, $visibleCreatorIds);
return ['totals' => $pack['totals'], 'slots' => $pack['slots']]; return ['totals' => $pack['totals'], 'slots' => $pack['slots']];
} }
@@ -354,14 +369,17 @@ class DeptLogic extends BaseLogic
* *
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>} * @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/ */
public static function getErCenterRevisitCountsByCreator(?int $orderStartTs = null, ?int $orderEndTs = null): array public static function getErCenterRevisitCountsByCreator(
{ ?int $orderStartTs = null,
?int $orderEndTs = null,
?array $visibleCreatorIds = null
): array {
$erRoots = self::findErCenterRootDeptIds(); $erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots); $subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) { if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []]; return ['totals' => [], 'slots' => []];
} }
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs); $pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs, $visibleCreatorIds);
$by = $pack['by_creator_slots']; $by = $pack['by_creator_slots'];
$totals = []; $totals = [];
$slots = []; $slots = [];
@@ -436,7 +454,8 @@ class DeptLogic extends BaseLogic
int $rootDeptId, int $rootDeptId,
int $revisitSlot, int $revisitSlot,
int $orderStartTs, int $orderStartTs,
int $orderEndTs int $orderEndTs,
?array $visibleCreatorIds = null
): array { ): array {
if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) { if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return ['rows' => []]; return ['rows' => []];
@@ -479,6 +498,13 @@ class DeptLogic extends BaseLogic
} }
} }
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet); $canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
$creatorFlip = null;
if ($visibleCreatorIds !== null && $visibleCreatorIds !== []) {
$creatorFlip = array_flip(array_values(array_unique(array_filter(
array_map('intval', $visibleCreatorIds),
static fn (int $id): bool => $id > 0
))));
}
/** @var array<int, int> $counts */ /** @var array<int, int> $counts */
$counts = []; $counts = [];
foreach ($byPatient as $orders) { foreach ($byPatient as $orders) {
@@ -492,6 +518,9 @@ class DeptLogic extends BaseLogic
if ($cid <= 0) { if ($cid <= 0) {
continue; continue;
} }
if ($creatorFlip !== null && !isset($creatorFlip[$cid])) {
continue;
}
$canon = $canonicalDept[$cid] ?? null; $canon = $canonicalDept[$cid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) { if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue; continue;
@@ -157,6 +157,8 @@ class YejiStatsLogic
/** /**
* 按角色「数据范围」收窄业绩看板上下文(adminToPrimary、tableRowDeptIds)。 * 按角色「数据范围」收窄业绩看板上下文(adminToPrimary、tableRowDeptIds)。
* DataScopeService / 列表 HasDataScopeFilter 一致:ALL 不处理;SELF/本部门/本部门及下级 仅保留可见 admin_id。 * DataScopeService / 列表 HasDataScopeFilter 一致:ALL 不处理;SELF/本部门/本部门及下级 仅保留可见 admin_id。
*
* 表格仍保持默认「中心」层级(图1);合计业绩等数值在聚合层按 dataScopeVisibleAdminIds 收窄。
*/ */
private static function applyYejiDataScope(array &$c, int $adminId, array $adminInfo): void private static function applyYejiDataScope(array &$c, int $adminId, array $adminInfo): void
{ {
@@ -178,6 +180,17 @@ class YejiStatsLogic
return; return;
} }
self::narrowYejiContextTableRowsByVisibleAdmins($c, $visibleIds);
}
/**
* 在现有展示行(默认中心层级)上按可见 admin 收窄表格行与台账归属。
*
* @param int[] $visibleIds
*/
private static function narrowYejiContextTableRowsByVisibleAdmins(array &$c, array $visibleIds): void
{
$flip = array_flip($visibleIds); $flip = array_flip($visibleIds);
$filtered = []; $filtered = [];
foreach ($c['adminToPrimary'] as $aid => $pid) { foreach ($c['adminToPrimary'] as $aid => $pid) {
@@ -402,7 +415,10 @@ class YejiStatsLogic
null, null,
null, null,
$tableRowDeptIds, $tableRowDeptIds,
$deptById $deptById,
null,
[],
$dataScopeAdminIds
) )
: null; : null;
// 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致) // 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致)
@@ -417,7 +433,8 @@ class YejiStatsLogic
$tableRowDeptIds, $tableRowDeptIds,
$deptById, $deptById,
$listAlignedPack !== null ? $listAlignedPack['amount'] : null, $listAlignedPack !== null ? $listAlignedPack['amount'] : null,
$channelInfo $channelInfo,
$dataScopeAdminIds
); );
$performance = $perfBoth['all']; $performance = $perfBoth['all'];
$channelPerformance = $perfBoth['scoped']; $channelPerformance = $perfBoth['scoped'];
@@ -470,7 +487,8 @@ class YejiStatsLogic
} }
@set_time_limit(120); @set_time_limit(120);
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs); $revisitCreatorFilter = self::resolveYejiRevisitCreatorFilter($c);
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs, $revisitCreatorFilter);
$revisitTotals = $revisitPack['totals']; $revisitTotals = $revisitPack['totals'];
$revisitSlotsByDept = $revisitPack['slots']; $revisitSlotsByDept = $revisitPack['slots'];
@@ -807,7 +825,10 @@ class YejiStatsLogic
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
/** 医助排行榜复诊:按订单创建人归属(与诊金 / 接诊诊单同口径),不再用 dg.assistant_id */ /** 医助排行榜复诊:按订单创建人归属(与诊金 / 接诊诊单同口径),不再用 dg.assistant_id */
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs); $revisitCreatorFilter = ($dataScopeRestricted && $dataScopeAdminIds !== null && $dataScopeAdminIds !== [])
? $dataScopeAdminIds
: null;
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs, $revisitCreatorFilter);
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id'); $assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
$assistantSet = []; $assistantSet = [];
@@ -1938,7 +1959,8 @@ class YejiStatsLogic
$tableRowDeptIds, $tableRowDeptIds,
$deptById, $deptById,
$pack[0], $pack[0],
$pack[1] $pack[1],
$dataScopeAdminIds
); );
$scopedByDept = $scopedAligned['count']; $scopedByDept = $scopedAligned['count'];
} else { } else {
@@ -3011,25 +3033,16 @@ class YejiStatsLogic
return $out; return $out;
} }
$revisitCreatorFilter = self::resolveYejiRevisitCreatorFilter($c);
$pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept( $pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept(
$deptId, $deptId,
$revisitSlot, $revisitSlot,
(int) $c['startTs'], (int) $c['startTs'],
(int) $c['endTs'] (int) $c['endTs'],
$revisitCreatorFilter
); );
$rows = $pack['rows']; $rows = $pack['rows'];
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
$flip = array_flip($c['dataScopeVisibleAdminIds']);
$filtered = [];
foreach ($rows as $rw) {
if (isset($flip[(int) ($rw['admin_id'] ?? 0)])) {
$filtered[] = $rw;
}
}
$rows = $filtered;
}
$out['rows'] = $rows; $out['rows'] = $rows;
if ($rows === []) { if ($rows === []) {
$out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。'; $out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。';
@@ -3038,6 +3051,28 @@ class YejiStatsLogic
return $out; return $out;
} }
/**
* 二中心复诊统计:数据范围下仅统计可见创建人的复诊笔数(与合计业绩一致)。
*
* @param array<string, mixed> $c
*
* @return int[]|null null=不限制;[]=无可见创建人
*/
private static function resolveYejiRevisitCreatorFilter(array $c): ?array
{
if (empty($c['dataScopeRestricted']) || !isset($c['dataScopeVisibleAdminIds'])) {
return null;
}
if (!\is_array($c['dataScopeVisibleAdminIds']) || $c['dataScopeVisibleAdminIds'] === []) {
return [];
}
return array_values(array_unique(array_filter(
array_map('intval', $c['dataScopeVisibleAdminIds']),
static fn (int $v): bool => $v > 0
)));
}
/** /**
* sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。 * sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
* 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。 * 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。
@@ -3711,7 +3746,8 @@ class YejiStatsLogic
array $tableRowDeptIds = [], array $tableRowDeptIds = [],
array $deptById = [], array $deptById = [],
?array $precomputedListAlignedAmountByDept = null, ?array $precomputedListAlignedAmountByDept = null,
?array $channelInfo = null ?array $channelInfo = null,
?array $dataScopeAdminIds = null
): array { ): array {
if ($adminToPrimary === []) { if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []]; return ['all' => [], 'scoped' => []];
@@ -3730,7 +3766,10 @@ class YejiStatsLogic
null, null,
null, null,
$tableRowDeptIds, $tableRowDeptIds,
$deptById $deptById,
null,
[],
$dataScopeAdminIds
); );
$allByDept = []; $allByDept = [];
foreach ($aligned['amount'] as $deptId => $v) { foreach ($aligned['amount'] as $deptId => $v) {
@@ -3770,7 +3809,8 @@ class YejiStatsLogic
$tableRowDeptIds, $tableRowDeptIds,
$deptById, $deptById,
$pack[0], $pack[0],
$pack[1] $pack[1],
$dataScopeAdminIds
); );
$scopedByDept = []; $scopedByDept = [];
foreach ($scopedAligned['amount'] as $deptId => $v) { foreach ($scopedAligned['amount'] as $deptId => $v) {
@@ -4085,7 +4125,8 @@ class YejiStatsLogic
array $tableRowDeptIds, array $tableRowDeptIds,
array $deptById, array $deptById,
?string $channelScopedExistsSql = null, ?string $channelScopedExistsSql = null,
array $channelScopedExistsBindings = [] array $channelScopedExistsBindings = [],
?array $dataScopeAdminIds = null
): array { ): array {
if ($tagDiagIds !== null && $tagDiagIds === []) { if ($tagDiagIds !== null && $tagDiagIds === []) {
return ['count' => [], 'amount' => []]; return ['count' => [], 'amount' => []];
@@ -4115,6 +4156,9 @@ class YejiStatsLogic
$in = implode(',', $ids); $in = implode(',', $ids);
$query->whereRaw("o.creator_id IN ({$in})"); $query->whereRaw("o.creator_id IN ({$in})");
} }
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
$query->whereIn('o.creator_id', $dataScopeAdminIds);
}
if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') { if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') {
$query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings); $query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings);
} }
@@ -651,7 +651,7 @@ class PrescriptionOrderLogic
$rows = Order::whereIn('id', $ids)->whereNull('delete_time') $rows = Order::whereIn('id', $ids)->whereNull('delete_time')
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt']) ->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
->order('id', 'asc') ->order('id', 'asc')
->whereIn('status', [2,5]) ->whereIn('status', [2, 4, 5])
->select() ->select()
->toArray(); ->toArray();
@@ -709,7 +709,10 @@ class PrescriptionOrderLogic
$arr['linked_pay_orders'] = self::linkedPayOrdersPayload($ids); $arr['linked_pay_orders'] = self::linkedPayOrdersPayload($ids);
$sum = 0.0; $sum = 0.0;
foreach ($arr['linked_pay_orders'] as $o) { foreach ($arr['linked_pay_orders'] as $o) {
$sum += (float) ($o['amount'] ?? 0); $st = (int) ($o['status'] ?? 0);
if ($st === 2 || $st === 5) {
$sum += (float) ($o['amount'] ?? 0);
}
} }
$arr['linked_pay_paid_total'] = round($sum, 2); $arr['linked_pay_paid_total'] = round($sum, 2);
$arr['deposit_min_amount'] = self::depositMinAmount(); $arr['deposit_min_amount'] = self::depositMinAmount();
@@ -2457,7 +2460,10 @@ class PrescriptionOrderLogic
$linkedPayOrders = self::linkedPayOrdersPayload($linkedPayOrderIds); $linkedPayOrders = self::linkedPayOrdersPayload($linkedPayOrderIds);
$linkedPayPaidTotal = 0.0; $linkedPayPaidTotal = 0.0;
foreach ($linkedPayOrders as $o) { foreach ($linkedPayOrders as $o) {
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0); $st = (int) ($o['status'] ?? 0);
if ($st === 2 || $st === 5) {
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0);
}
} }
$linkedPayPaidTotal = round($linkedPayPaidTotal, 2); $linkedPayPaidTotal = round($linkedPayPaidTotal, 2);
@@ -2468,7 +2474,13 @@ class PrescriptionOrderLogic
$order->fulfillment_status = $targetFulfillmentStatus; $order->fulfillment_status = $targetFulfillmentStatus;
} }
$refundedPayCount = 0;
try { try {
if ($targetFulfillmentStatus === 10 && $linkedPayOrderIds !== []) {
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
->whereNull('delete_time')
->update(['status' => 4]);
}
$order->save(); $order->save();
} catch (\Throwable $e) { } catch (\Throwable $e) {
self::$error = $e->getMessage(); self::$error = $e->getMessage();
@@ -2484,6 +2496,9 @@ class PrescriptionOrderLogic
$logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary; $logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary;
} else { } else {
$logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary; $logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary;
if ($targetFulfillmentStatus === 10 && $refundedPayCount > 0) {
$logLine .= ',关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
}
} }
self::writeLog( self::writeLog(
$id, $id,
@@ -0,0 +1,48 @@
-- 业绩看板四个 Tab 独立按钮权限(fans/yeji · pid=293
-- Tab 权限用于单独控制 Tab 可见性;前端同时兼容下方已有接口按钮权限,旧角色无需改配即可继续使用。
-- 若仅勾选 Tab 权限、未勾选对应接口,Tab 可见但请求会 403:
-- tabDoctor → stats.doctorDailyStats/overview
-- tabLeaderboard → stats.yejiStats/leaderboard
-- tabZyyt → stats.yejiStats/multi、stats.yejiStats/overview
-- tabTargetMatrix → stats.yejiStats/multi
-- 幂等:按 perms 判重
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
293, 'A', '业绩看板-医生统计', '', 8,
'stats.yejiStats/tabDoctor', '', '', '', '',
1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE EXISTS (SELECT 1 FROM `zyt_system_menu` p WHERE p.id = 293)
AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` m WHERE m.perms = 'stats.yejiStats/tabDoctor');
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
293, 'A', '业绩看板-医助排行榜', '', 9,
'stats.yejiStats/tabLeaderboard', '', '', '', '',
1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE EXISTS (SELECT 1 FROM `zyt_system_menu` p WHERE p.id = 293)
AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` m WHERE m.perms = 'stats.yejiStats/tabLeaderboard');
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
293, 'A', '业绩看板-甄养堂诊金', '', 10,
'stats.yejiStats/tabZyyt', '', '', '', '',
1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE EXISTS (SELECT 1 FROM `zyt_system_menu` p WHERE p.id = 293)
AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` m WHERE m.perms = 'stats.yejiStats/tabZyyt');
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
293, 'A', '业绩看板-目标看板卡片', '', 11,
'stats.yejiStats/tabTargetMatrix', '', '', '', '',
1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE EXISTS (SELECT 1 FROM `zyt_system_menu` p WHERE p.id = 293)
AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` m WHERE m.perms = 'stats.yejiStats/tabTargetMatrix');