diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index ac7a4b02..b421914d 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -96,6 +96,21 @@ export function yejiStatsRevisitBreakdown(params: { return request.get({ url: '/stats.yejiStats/revisitBreakdown', params }) } +/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */ +export function yejiStatsAssignLines(params: { + start_date: string + end_date: string + dept_id?: number + assistant_id?: number + dept_ids?: string + channel_code?: string + tag_id?: string + page?: number + page_size?: number +}) { + return request.get({ url: '/stats.yejiStats/assignLines', params }) +} + export function doctorDailyStatsOverview(params: { start_date?: string end_date?: string diff --git a/admin/src/views/fans/yeji.vue b/admin/src/views/fans/yeji.vue index 5fa14b08..ef971032 100644 --- a/admin/src/views/fans/yeji.vue +++ b/admin/src/views/fans/yeji.vue @@ -283,7 +283,15 @@ {{ r.name }} {{ formatLeaderboardInt(r.consult_count) }} {{ formatLeaderboardInt(r.deal_order_count) }} - {{ formatLeaderboardInt(r.assign_count ?? 0) }} + + {{ formatLeaderboardInt(r.assign_count ?? 0) }} + + {{ formatInt(r.lead_count) }} - {{ formatInt(r.assign_count ?? 0) }} + + {{ formatInt(r.assign_count ?? 0) }} + + + + + +

{{ assignLinesApiNote }}

+
+ + + + + + + + + + +
+ +
+
+
@@ -1376,6 +1443,7 @@ import { yejiStatsLeadLines, yejiStatsAppointmentLines, yejiStatsRevisitBreakdown, + yejiStatsAssignLines, } from '@/api/stats' import { deptPerformanceTargetMonthMatrix } from '@/api/finance' import { prescriptionOrderLists } from '@/api/tcm' @@ -1491,6 +1559,17 @@ interface YejiLeadLineRow { external_contact_name: string } +interface YejiAssignLineRow { + diagnosis_id: number + patient_name: string + patient_phone: string + assistant_id: number + assistant_name: string + assign_count: number + last_assign_time: number + last_assign_time_text: string +} + interface YejiRow { dept_id: number dept_name: string @@ -1709,6 +1788,31 @@ type AppointmentLinesCtx = } const appointmentLinesContext = ref(null) +const assignLinesDialogVisible = ref(false) +const assignLinesLoading = ref(false) +const assignLinesSubtitle = ref('') +const assignLinesApiNote = ref('') +const assignLinesRows = ref([]) +const assignLinesCount = ref(0) +const assignLinesPage = ref(1) +const assignLinesPageSize = ref(20) +type AssignLinesCtx = + | { + mode: 'assistant' + assistant_id: number + name: string + start_date: string + end_date: string + } + | { + mode: 'dept' + dept_id: number + dept_name: string + start_date: string + end_date: string + } +const assignLinesContext = ref(null) + const appointmentLinesShowChannelColumn = computed(() => appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '') ) @@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) { void openLeadLinesDialog(tb, row) } +function yejiAssignCountCellClickable(r: YejiRow): boolean { + return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0 +} + +function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) { + if (!yejiAssignCountCellClickable(row)) { + return + } + ev.stopPropagation() + void openAssignLinesDialogForDept(tb, row) +} + +function assignLinesIndexMethod(index: number) { + return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1 +} + +function buildAssignLinesRequestParams(page: number, pageSize: number): Record | null { + const ctx = assignLinesContext.value + if (!ctx) { + return null + } + const p: Record = { + start_date: ctx.start_date, + end_date: ctx.end_date, + page, + page_size: pageSize, + } + if (ctx.mode === 'dept') { + p.dept_id = ctx.dept_id + } else { + p.assistant_id = ctx.assistant_id + } + if (selectedDeptIds.value.length > 0) { + p.dept_ids = selectedDeptIds.value.join(',') + } + if (selectedChannel.value) { + p.channel_code = selectedChannel.value + } + return p +} + +async function fetchAssignLinesPage() { + const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value) + if (!params) { + return + } + assignLinesLoading.value = true + try { + const res: any = await yejiStatsAssignLines(params as any) + assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : [] + assignLinesCount.value = Number(res?.count ?? 0) + assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : '' + } catch (e: unknown) { + if (axios.isCancel(e)) return + const any = e as any + ElMessage.error(any?.msg || any?.message || '加载被指派明细失败') + assignLinesRows.value = [] + assignLinesCount.value = 0 + assignLinesApiNote.value = '' + } finally { + assignLinesLoading.value = false + } +} + +async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) { + assignLinesContext.value = { + mode: 'dept', + dept_id: row.dept_id, + dept_name: row.dept_name, + start_date: tb.start_date, + end_date: tb.end_date, + } + assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ~ ${tb.end_date}` + assignLinesApiNote.value = '' + assignLinesRows.value = [] + assignLinesCount.value = 0 + assignLinesPage.value = 1 + assignLinesPageSize.value = 20 + assignLinesDialogVisible.value = true + await fetchAssignLinesPage() +} + +function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) { + if (!r || r.admin_id <= 0 || !leaderboardBlock.value) { + return + } + if (Number(r.assign_count ?? 0) <= 0) { + return + } + const lb = leaderboardBlock.value + assignLinesContext.value = { + mode: 'assistant', + assistant_id: r.admin_id, + name: r.name, + start_date: lb.start_date, + end_date: lb.end_date, + } + assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ~ ${lb.end_date}` + assignLinesApiNote.value = '' + assignLinesRows.value = [] + assignLinesCount.value = 0 + assignLinesPage.value = 1 + assignLinesPageSize.value = 20 + assignLinesDialogVisible.value = true + void fetchAssignLinesPage() +} + +function onAssignLinesPageChange(p: number) { + assignLinesPage.value = p + void fetchAssignLinesPage() +} + +function onAssignLinesPageSizeChange(size: number) { + assignLinesPageSize.value = size + assignLinesPage.value = 1 + void fetchAssignLinesPage() +} + const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [ { key: 'event_time_text', label: '进线时间' }, { key: 'reception_admin_name', label: '接待' }, diff --git a/server/app/adminapi/controller/stats/YejiStatsController.php b/server/app/adminapi/controller/stats/YejiStatsController.php index abf44a1d..dd0ca7a1 100755 --- a/server/app/adminapi/controller/stats/YejiStatsController.php +++ b/server/app/adminapi/controller/stats/YejiStatsController.php @@ -17,6 +17,7 @@ use app\adminapi\logic\stats\YejiStatsLogic; * - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条) * - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细 * - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数 + * - GET stats.yejiStats/assignLines 被指派数明细(医助×诊单去重,与看板同口径) */ class YejiStatsController extends BaseAdminController { @@ -141,4 +142,13 @@ class YejiStatsController extends BaseAdminController return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo)); } + + /** 被指派数明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重) */ + public function assignLines() + { + @set_time_limit(120); + $params = $this->request->get(); + + return $this->data(YejiStatsLogic::assignLines($params, $this->adminId, $this->adminInfo)); + } } diff --git a/server/app/adminapi/logic/stats/YejiStatsLogic.php b/server/app/adminapi/logic/stats/YejiStatsLogic.php index 1c034e07..aea34465 100755 --- a/server/app/adminapi/logic/stats/YejiStatsLogic.php +++ b/server/app/adminapi/logic/stats/YejiStatsLogic.php @@ -2616,6 +2616,420 @@ class YejiStatsLogic ]; } + /** + * 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。 + * + * @param array{ + * start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string, + * assistant_id?:int|string,admin_id?:int|string,dept_id?:int|string, + * page?:int|string,page_size?:int|string + * } $params + * + * @return array + */ + public static function assignLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array + { + $c = self::resolveYejiContext($params); + if ($viewerAdminId > 0) { + self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); + } + + $assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0); + + if ($assistantId <= 0 && array_key_exists('dept_id', $params)) { + return self::yejiDeptAssignLinesInner($params, $c); + } + + return self::yejiAssistantAssignLinesInner($params, $c, $assistantId); + } + + /** + * @param array $params + * @param array $c + * + * @return array + */ + private static function yejiDeptAssignLinesInner(array $params, array $c): array + { + $deptIdParam = (int) ($params['dept_id'] ?? 0); + $channelCode = (string) $c['channelCode']; + $deptName = $deptIdParam === 0 + ? '未归属中心' + : self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']); + + $empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array { + return self::yejiAssignLinesEmptyPayload($c, $note, 0, '', $deptIdParam, $deptName, $channelCode); + }; + + if ($deptIdParam <= 0) { + return $empty('未归属中心无被指派明细(看板该行为 0)。'); + } + if (!in_array($deptIdParam, $c['tableRowDeptIds'], true)) { + return $empty('该部门不在当前展示部门筛选或数据权限范围内。'); + } + + $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); + if ($channelCode !== '' && isset($erCenterDeptSet[$deptIdParam])) { + return $empty('与看板一致:二中心及其组织下级在选定渠道下被指派数计 0,无明细。'); + } + + $targetAssistantIds = []; + foreach ($c['adminToPrimary'] as $aid => $primary) { + if ((int) $primary === $deptIdParam) { + $targetAssistantIds[] = (int) $aid; + } + } + if ($targetAssistantIds === []) { + return $empty('当前部门下没有可映射的被指派医助,无明细。'); + } + + return self::yejiAssignLinesFetch($c, $params, $targetAssistantIds, 0, '', $deptIdParam, $deptName); + } + + /** + * @param array $params + * @param array $c + * + * @return array + */ + private static function yejiAssistantAssignLinesInner(array $params, array $c, int $assistantId): array + { + $channelCode = (string) $c['channelCode']; + + $assistantName = ''; + if ($assistantId > 0) { + $assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: ''); + } + + $empty = static function (string $note) use ($c, $assistantId, $assistantName, $channelCode): array { + return self::yejiAssignLinesEmptyPayload($c, $note, $assistantId, $assistantName, 0, '', $channelCode); + }; + + if ($assistantId <= 0) { + return $empty('请指定有效医助,或传入 dept_id 查看部门汇总明细。'); + } + + $dataScopeRestricted = !empty($c['dataScopeRestricted']); + $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) + ? $c['dataScopeVisibleAdminIds'] + : null; + if ($dataScopeRestricted && $dataScopeAdminIds !== null) { + $flip = array_flip($dataScopeAdminIds); + if (!isset($flip[$assistantId])) { + return $empty('当前账号数据权限下不可查看该医助被指派明细。'); + } + } + + if (!isset($c['adminToPrimary'][$assistantId])) { + return $empty('该医助不在当前业绩看板展示范围内。'); + } + + $tagDiagIds = $c['tagDiagIds']; + $tagAssistantIds = $c['tagAssistantIds']; + $tagFallback = $c['tagFallback']; + $scopedAssistants = $tagFallback ? $tagAssistantIds : null; + + if ($tagDiagIds !== null && $tagDiagIds === []) { + return $empty('当前渠道标签下无关联诊单,无被指派明细。'); + } + if ($scopedAssistants !== null && $scopedAssistants === []) { + return $empty('当前渠道标签下无关联医助,无被指派明细。'); + } + if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) { + return $empty('当前渠道标签口径下该医助无被指派记录(与排行榜「被指派数」一致)。'); + } + + $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); + $primaryDept = (int) ($c['adminToPrimary'][$assistantId] ?? 0); + if ($channelCode !== '' && $primaryDept > 0 && isset($erCenterDeptSet[$primaryDept])) { + return $empty('与看板一致:二中心及其组织下级医助在选定渠道下被指派数计 0,无明细。'); + } + + return self::yejiAssignLinesFetch( + $c, + $params, + [$assistantId], + $assistantId, + $assistantName, + 0, + '' + ); + } + + /** + * @param array $c + * @param array $params + * @param list $targetAssistantIds + * + * @return array + */ + private static function yejiAssignLinesFetch( + array $c, + array $params, + array $targetAssistantIds, + int $assistantIdOut, + string $assistantNameOut, + int $deptIdOut, + string $deptNameOut + ): array { + $channelCode = (string) $c['channelCode']; + $startTs = (int) $c['startTs']; + $endTs = (int) $c['endTs']; + $page = max(1, (int) ($params['page'] ?? 1)); + $limit = min(100, max(1, (int) ($params['page_size'] ?? 20))); + $offset = ($page - 1) * $limit; + + $dataScopeRestricted = !empty($c['dataScopeRestricted']); + $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) + ? $c['dataScopeVisibleAdminIds'] + : null; + if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) { + $visFlip = array_flip($dataScopeAdminIds); + $targetAssistantIds = array_values(array_filter( + $targetAssistantIds, + static fn (int $aid): bool => isset($visFlip[$aid]) + )); + } + + if ($targetAssistantIds === []) { + return self::yejiAssignLinesEmptyPayload( + $c, + '当前数据权限下无可查看的被指派医助。', + $assistantIdOut, + $assistantNameOut, + $deptIdOut, + $deptNameOut, + $channelCode + ); + } + + $tagDiagIds = $c['tagDiagIds']; + $tagAssistantIds = $c['tagAssistantIds']; + $tagFallback = $c['tagFallback']; + $scopedAssistants = $tagFallback ? $tagAssistantIds : null; + + if ($tagDiagIds !== null && $tagDiagIds === []) { + return self::yejiAssignLinesEmptyPayload( + $c, + '当前渠道标签下无关联诊单,无被指派明细。', + $assistantIdOut, + $assistantNameOut, + $deptIdOut, + $deptNameOut, + $channelCode + ); + } + if ($scopedAssistants !== null && $scopedAssistants === []) { + return self::yejiAssignLinesEmptyPayload( + $c, + '当前渠道标签下无关联医助,无被指派明细。', + $assistantIdOut, + $assistantNameOut, + $deptIdOut, + $deptNameOut, + $channelCode + ); + } + + $buildBaseQuery = static function () use ( + $c, + $targetAssistantIds, + $startTs, + $endTs, + $tagDiagIds, + $scopedAssistants + ) { + $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $q = Db::name('tcm_diagnosis_assign_log') + ->alias('lg') + ->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') + ->where('lg.create_time', 'between', [$startTs, $endTs]) + ->where('lg.to_assistant_id', '>', 0) + ->where('lg.is_inherit', 0) + ->whereIn('lg.to_assistant_id', $targetAssistantIds); + + self::applyYejiAssignLogChannelFilters( + $q, + $tagDiagIds, + $scopedAssistants, + $c['appointmentChannelValues'], + $c['channelFilterActive'], + $c['channelInfo'] + ); + + return $q; + }; + + $countRows = $buildBaseQuery() + ->field(['lg.to_assistant_id', 'lg.diagnosis_id']) + ->group('lg.to_assistant_id,lg.diagnosis_id') + ->select() + ->toArray(); + $count = \count($countRows); + + $rawList = $buildBaseQuery() + ->field([ + 'lg.to_assistant_id', + 'lg.diagnosis_id', + Db::raw('COUNT(*) AS assign_count'), + Db::raw('MAX(lg.create_time) AS last_assign_time'), + ]) + ->group('lg.to_assistant_id,lg.diagnosis_id') + ->order('last_assign_time', 'desc') + ->order('lg.diagnosis_id', 'desc') + ->limit($offset, $limit) + ->select() + ->toArray(); + + $diagIds = []; + $assistantIds = []; + foreach ($rawList as $rw) { + $diagIds[(int) ($rw['diagnosis_id'] ?? 0)] = true; + $assistantIds[(int) ($rw['to_assistant_id'] ?? 0)] = true; + } + $diagInfo = self::fetchYejiAssignDiagnosisInfo(array_keys($diagIds)); + $nameMap = $assistantIds !== [] + ? Db::name('admin')->whereIn('id', array_keys($assistantIds))->column('name', 'id') + : []; + + $lists = []; + foreach ($rawList as $rw) { + $did = (int) ($rw['diagnosis_id'] ?? 0); + $aid = (int) ($rw['to_assistant_id'] ?? 0); + $lastTs = (int) ($rw['last_assign_time'] ?? 0); + $lists[] = [ + 'diagnosis_id' => $did, + 'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''), + 'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''), + 'assistant_id' => $aid, + 'assistant_name' => $aid > 0 ? (string) ($nameMap[$aid] ?? ('#' . $aid)) : '—', + 'assign_count' => (int) ($rw['assign_count'] ?? 0), + 'last_assign_time' => $lastTs, + 'last_assign_time_text' => $lastTs > 0 ? date('Y-m-d H:i:s', $lastTs) : '', + ]; + } + + $note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),' + . '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。'; + + return [ + 'start_date' => (string) $c['startDate'], + 'end_date' => (string) $c['endDate'], + 'assistant_id' => $assistantIdOut, + 'assistant_name' => $assistantNameOut, + 'dept_id' => $deptIdOut, + 'dept_name' => $deptNameOut, + 'channel_code' => $channelCode, + 'count' => $count, + 'lists' => $lists, + 'note' => $note, + ]; + } + + /** + * @param array $c + * + * @return array + */ + private static function yejiAssignLinesEmptyPayload( + array $c, + string $note, + int $assistantId, + string $assistantName, + int $deptId, + string $deptName, + string $channelCode + ): array { + return [ + 'start_date' => (string) $c['startDate'], + 'end_date' => (string) $c['endDate'], + 'assistant_id' => $assistantId, + 'assistant_name' => $assistantName, + 'dept_id' => $deptId, + 'dept_name' => $deptName, + 'channel_code' => $channelCode, + 'count' => 0, + 'lists' => [], + 'note' => $note, + ]; + } + + /** + * @param \think\db\Query $query + */ + private static function applyYejiAssignLogChannelFilters( + $query, + ?array $tagDiagIds, + ?array $tagAssistantIds, + array $appointmentChannelValues, + bool $channelFilterActive, + ?array $channelInfo + ): void { + if (!$channelFilterActive) { + return; + } + if ($appointmentChannelValues !== []) { + $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); + if ($norm === []) { + $query->whereRaw('0 = 1'); + + return; + } + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo); + if ($pack === null) { + $query->whereRaw('0 = 1'); + + return; + } + $query->whereRaw($pack[0], $pack[1]); + } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { + if ($tagDiagIds !== null && $tagDiagIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + if ($tagAssistantIds !== null && $tagAssistantIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + if ($tagDiagIds !== null) { + $query->whereIn('lg.diagnosis_id', $tagDiagIds); + } + if ($tagAssistantIds !== null) { + $query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds))); + } + } else { + $query->whereRaw('0 = 1'); + } + } + + /** + * @param list $diagIds + * + * @return array + */ + private static function fetchYejiAssignDiagnosisInfo(array $diagIds): array + { + if ($diagIds === []) { + return []; + } + $rows = Db::name('tcm_diagnosis') + ->whereIn('id', $diagIds) + ->field(['id', 'patient_name', 'phone']) + ->select() + ->toArray(); + $out = []; + foreach ($rows as $r) { + $out[(int) $r['id']] = [ + 'patient_name' => trim((string) ($r['patient_name'] ?? '')), + 'phone' => trim((string) ($r['phone'] ?? '')), + ]; + } + + return $out; + } + /** * admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。 */ diff --git a/server/sql/1.9.20260616/add_yeji_assign_lines_menu.sql b/server/sql/1.9.20260616/add_yeji_assign_lines_menu.sql new file mode 100644 index 00000000..e9fc7b37 --- /dev/null +++ b/server/sql/1.9.20260616/add_yeji_assign_lines_menu.sql @@ -0,0 +1,15 @@ +-- 业绩看板「被指派数」明细接口(fans/yeji · pid=293) +-- 幂等:按 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', '业绩-被指派明细', '', 12, + 'stats.yejiStats/assignLines', '', '', '', '', + 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/assignLines' +);