From 3ef10846b42497e691253f68f49839a0d408f155 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 5 May 2026 14:51:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/api/org/department.ts | 6 +- admin/src/api/stats.ts | 12 +- admin/src/views/consumer/assistant/edit.vue | 47 ++-- admin/src/views/consumer/doctor/edit.vue | 47 ++-- admin/src/views/consumer/doctor/index.vue | 5 +- admin/src/views/fans/yeji.vue | 209 +++++++++++++++++- .../controller/dept/DeptController.php | 6 +- .../controller/stats/YejiStatsController.php | 10 + server/app/adminapi/logic/dept/DeptLogic.php | 55 +++++ .../adminapi/logic/stats/YejiStatsLogic.php | 152 +++++++++++++ 10 files changed, 492 insertions(+), 57 deletions(-) diff --git a/admin/src/api/org/department.ts b/admin/src/api/org/department.ts index 3673ed5b..8f7abe5b 100644 --- a/admin/src/api/org/department.ts +++ b/admin/src/api/org/department.ts @@ -25,7 +25,7 @@ export function deptDetail(params: any) { return request.get({ url: '/dept.dept/detail', params }) } -// 部门列表全部 -export function deptAll() { - return request.get({ url: '/dept.dept/all' }) +// 部门列表全部;apply_data_scope=1 时按当前账号角色数据权限收窄树(与 DataScopeService 一致) +export function deptAll(params?: { apply_data_scope?: number }) { + return request.get({ url: '/dept.dept/all', params }) } diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index 3873ef8e..0ff68cb4 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -43,7 +43,17 @@ export function yejiStatsLeaderboard(params: { return request.get({ url: '/stats.yejiStats/leaderboard', params }) } -/** 医生统计(start/end、channel_code;含数据范围;不按展示部门 dept_ids 筛选) */ +/** 「未归属中心」行:按订单创建人拆解合计业绩(与看板「合计业绩」未归属部分同口径) */ +export function yejiStatsUnassignedBreakdown(params: { + start_date: string + end_date: string + dept_ids?: string + channel_code?: string + tag_id?: string +}) { + return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params }) +} + export function doctorDailyStatsOverview(params: { start_date?: string end_date?: string diff --git a/admin/src/views/consumer/assistant/edit.vue b/admin/src/views/consumer/assistant/edit.vue index 3d11b33a..5387f5f7 100644 --- a/admin/src/views/consumer/assistant/edit.vue +++ b/admin/src/views/consumer/assistant/edit.vue @@ -60,25 +60,26 @@ 组织信息 - + - +
+ +
仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。
+
@@ -215,6 +216,13 @@ import { getDictData } from '@/api/app' import { useDictOptions } from '@/hooks/useDictOptions' import feedback from '@/utils/feedback' +/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */ +function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) { + if (data.status !== 1) return true + const ch = data.children + return Array.isArray(ch) && ch.length > 0 +} + const emit = defineEmits(['success', 'close']) const formRef = shallowRef() const visible = ref(false) @@ -264,7 +272,8 @@ const { optionsData } = useDictOptions<{ api: jobsAll }, dept: { - api: deptAll + api: deptAll, + params: { apply_data_scope: 1 } } }) diff --git a/admin/src/views/consumer/doctor/edit.vue b/admin/src/views/consumer/doctor/edit.vue index 2da2dea9..53395d66 100644 --- a/admin/src/views/consumer/doctor/edit.vue +++ b/admin/src/views/consumer/doctor/edit.vue @@ -54,25 +54,26 @@ 组织信息 - + - +
+ +
仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。
+
@@ -254,6 +255,13 @@ import { getDictData } from '@/api/app' import { useDictOptions } from '@/hooks/useDictOptions' import feedback from '@/utils/feedback' +/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */ +function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) { + if (data.status !== 1) return true + const ch = data.children + return Array.isArray(ch) && ch.length > 0 +} + const emit = defineEmits(['success', 'close']) const formRef = shallowRef() const visible = ref(false) @@ -307,7 +315,8 @@ const { optionsData } = useDictOptions<{ api: jobsAll }, dept: { - api: deptAll + api: deptAll, + params: { apply_data_scope: 1 } } }) diff --git a/admin/src/views/consumer/doctor/index.vue b/admin/src/views/consumer/doctor/index.vue index 90b967ee..8c454ac5 100644 --- a/admin/src/views/consumer/doctor/index.vue +++ b/admin/src/views/consumer/doctor/index.vue @@ -99,11 +99,12 @@ import EditPopup from './edit.vue' const editRef = shallowRef>() -// 表单数据 - 固定role_id=1查询医生 +// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员 const formData = reactive({ account: '', name: '', - role_id: 1 // 固定为医生角色 + role_id: 1, + apply_data_scope: 1 }) const showEdit = ref(false) diff --git a/admin/src/views/fans/yeji.vue b/admin/src/views/fans/yeji.vue index b68a8c82..b2da47d6 100644 --- a/admin/src/views/fans/yeji.vue +++ b/admin/src/views/fans/yeji.vue @@ -594,9 +594,12 @@ {{ r.dept_name }} @@ -811,6 +814,50 @@ + + + +

{{ unassignedDialogNote }}

+
+ + + + + + + + + + + + + +
+
@@ -820,12 +867,13 @@ import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import vCharts from 'vue-echarts' import { + doctorDailyStatsOverview, yejiStatsChannelOptions, yejiStatsDeptOptions, yejiStatsLeaderboard, yejiStatsMulti, yejiStatsOverview, - doctorDailyStatsOverview, + yejiStatsUnassignedBreakdown, } from '@/api/stats' import { deptPerformanceTargetMonthMatrix } from '@/api/finance' import { prescriptionOrderLists } from '@/api/tcm' @@ -907,6 +955,13 @@ interface ChannelGroup { channels: ChannelOption[] } +interface YejiUnassignedBreakdownRow { + admin_id: number + name: string + order_count: number + amount: number +} + interface YejiRow { dept_id: number dept_name: string @@ -984,6 +1039,12 @@ const tables = ref([]) const leaderboardBlock = ref(null) const leaderboardsLoading = ref(false) +const unassignedDialogVisible = ref(false) +const unassignedDialogLoading = ref(false) +const unassignedDialogSubtitle = ref('') +const unassignedDialogNote = ref('') +const unassignedDialogRows = ref([]) + /** 图表 / 数据表切换 */ const yejiDisplayTab = ref<'charts' | 'table'>('table') @@ -1934,10 +1995,58 @@ function onOrderDrawerPageSizeChange(size: number) { void fetchOrderDrawerPage() } +function isUnassignedYejiRow(row: YejiRow): boolean { + return row.dept_id <= 0 && row.dept_name === '未归属中心' +} + +function buildUnassignedBreakdownSubtitle(tb: YejiTable): string { + const parts = [`区间:${tb.start_date} ~ ${tb.end_date}`] + if (selectedChannel.value) { + parts.push(`渠道:${tb.channel_name || selectedChannel.value}`) + } + if (selectedDeptIds.value.length > 0) { + parts.push(`已选展示部门 ${selectedDeptIds.value.length} 个`) + } + return parts.join(' · ') +} + +async function openUnassignedBreakdown(tb: YejiTable) { + unassignedDialogSubtitle.value = buildUnassignedBreakdownSubtitle(tb) + unassignedDialogNote.value = '' + unassignedDialogRows.value = [] + unassignedDialogVisible.value = true + unassignedDialogLoading.value = true + try { + const p: Record = { + start_date: tb.start_date, + end_date: tb.end_date, + } + if (selectedDeptIds.value.length > 0) { + p.dept_ids = selectedDeptIds.value.join(',') + } + if (selectedChannel.value) { + p.channel_code = selectedChannel.value + } + const res: any = await yejiStatsUnassignedBreakdown(p as any) + unassignedDialogRows.value = Array.isArray(res?.rows) ? res.rows : [] + unassignedDialogNote.value = typeof res?.note === 'string' ? res.note : '' + } catch (e: any) { + ElMessage.error(e?.msg || e?.message || '加载未归属拆解失败') + unassignedDialogRows.value = [] + unassignedDialogNote.value = '' + } finally { + unassignedDialogLoading.value = false + } +} + /** - * 部门业绩行:有子部门 → 仅筛选该节点(后端展开子行);末级 → 侧栏业务订单 + * 部门业绩行:有子部门 → 仅筛选该节点(后端展开子行);末级 → 侧栏业务订单;「未归属中心」→ 按创建人拆解弹窗 */ function onYejiDeptRowClick(tb: YejiTable, row: YejiRow) { + if (isUnassignedYejiRow(row)) { + void openUnassignedBreakdown(tb) + return + } if (row.dept_id <= 0) return if (deptHasChildren(row.dept_id)) { selectedDeptIds.value = [row.dept_id] @@ -2312,6 +2421,13 @@ onMounted(async () => { --yj-radius: 16px; --yj-radius-sm: 10px; --yj-font: 'Inter', var(--el-font-family), system-ui, sans-serif; + /* 部门列竖条(表头略深、与指标列区分) */ + --yj-dept-col-bg: #eef2ff; + --yj-dept-col-bg-hover: #e0e7ff; + --yj-dept-col-head-start: #e0e7ff; + --yj-dept-col-head-end: #e8ecff; + --yj-dept-col-border: #c7d2fe; + --yj-dept-col-head-ink: #3730a3; min-height: 100%; padding: 24px; @@ -2911,7 +3027,7 @@ onMounted(async () => { } .col-abbr { - width: 48px; + min-width: 56px; } .col-name { @@ -2972,6 +3088,45 @@ onMounted(async () => { font-size: 13px; color: var(--yj-ink); + thead th.col-dept, + thead th.col-abbr { + min-width: 88px; + background: linear-gradient( + 180deg, + var(--yj-dept-col-head-start) 0%, + var(--yj-dept-col-head-end) 100% + ); + color: var(--yj-dept-col-head-ink); + font-weight: 700; + border-right: 2px solid var(--yj-dept-col-border); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); + } + + thead th.col-abbr { + min-width: 56px; + } + + tbody td.col-dept, + tbody td.col-abbr { + background: var(--yj-dept-col-bg); + color: var(--yj-ink); + font-weight: 600; + border-right: 2px solid var(--yj-dept-col-border); + text-align: center; + } + + tbody tr.yeji-row--action:not(.yeji-row--idle):hover td.col-dept, + tbody tr.yeji-row--action:not(.yeji-row--idle):hover td.col-abbr { + background: var(--yj-dept-col-bg-hover); + } + + .total-row td.col-dept { + background: color-mix(in srgb, var(--yj-accent-soft) 55%, var(--yj-dept-col-bg) 45%); + color: var(--yj-ink); + font-weight: 700; + border-right: 2px solid color-mix(in srgb, var(--yj-dept-col-border) 55%, var(--yj-line) 45%); + } + thead th { background: #f1f5f9; color: var(--yj-ink-secondary); @@ -2998,13 +3153,6 @@ onMounted(async () => { font-variant-numeric: tabular-nums; } - .col-dept { - width: 80px; - font-weight: 600; - background: var(--yj-surface-muted); - color: var(--yj-ink-secondary); - } - tbody tr.yeji-row--action { cursor: pointer; transition: background 0.18s ease; @@ -3013,6 +3161,10 @@ onMounted(async () => { background: var(--yj-brand-soft); } + &.yeji-row--unassigned { + cursor: pointer; + } + &.yeji-row--idle { cursor: default; @@ -3194,6 +3346,39 @@ onMounted(async () => { justify-content: flex-end; } +.yeji-unassigned-dialog__head { + line-height: 1.35; +} + +.yeji-unassigned-dialog__title { + display: block; + font-size: 16px; + font-weight: 700; + color: var(--yj-ink, #0f172a); +} + +.yeji-unassigned-dialog__sub { + margin: 6px 0 0; + font-size: 12px; + color: var(--yj-ink-secondary, #64748b); + font-weight: 400; +} + +.yeji-unassigned-dialog__note { + margin: 0 0 12px; + padding: 10px 12px; + font-size: 12px; + line-height: 1.65; + color: var(--yj-ink-secondary, #64748b); + background: var(--yj-accent-soft, #fff7ed); + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--yj-line, #e2e8f0) 80%, transparent); +} + +.yeji-unassigned-dialog__body { + min-height: 120px; +} + @media (prefers-reduced-motion: reduce) { .date-pill, .filter-btn, diff --git a/server/app/adminapi/controller/dept/DeptController.php b/server/app/adminapi/controller/dept/DeptController.php index e0dcf84f..330a1677 100644 --- a/server/app/adminapi/controller/dept/DeptController.php +++ b/server/app/adminapi/controller/dept/DeptController.php @@ -126,7 +126,11 @@ class DeptController extends BaseAdminController */ public function all() { - $result = DeptLogic::getAllData(); + $apply = (int) ($this->request->get('apply_data_scope', 0)); + $result = $apply === 1 + ? DeptLogic::getAllDataScoped($this->adminId, $this->adminInfo) + : DeptLogic::getAllData(); + return $this->data($result); } diff --git a/server/app/adminapi/controller/stats/YejiStatsController.php b/server/app/adminapi/controller/stats/YejiStatsController.php index 6001c80e..65797dac 100644 --- a/server/app/adminapi/controller/stats/YejiStatsController.php +++ b/server/app/adminapi/controller/stats/YejiStatsController.php @@ -14,6 +14,7 @@ use app\adminapi\logic\stats\YejiStatsLogic; * - GET stats.yeji-stats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表) * - GET stats.yeji-stats/dept-options 部门下拉 * - GET stats.yeji-stats/leaderboard 医助排行榜(按展示部门分表) + * - GET stats.yeji-stats/unassignedBreakdown 「未归属中心」合计业绩按订单创建人拆解 */ class YejiStatsController extends BaseAdminController { @@ -102,4 +103,13 @@ class YejiStatsController extends BaseAdminController return $this->data(YejiStatsLogic::assistantLeaderboards($params, $this->adminId, $this->adminInfo)); } + + /** 「未归属中心」行:按订单创建人拆解业绩(与「合计业绩」未归属部分同口径) */ + public function unassignedBreakdown() + { + @set_time_limit(120); + $params = $this->request->get(); + + return $this->data(YejiStatsLogic::unassignedCenterBreakdown($params, $this->adminId, $this->adminInfo)); + } } diff --git a/server/app/adminapi/logic/dept/DeptLogic.php b/server/app/adminapi/logic/dept/DeptLogic.php index 11bb9742..e37dc5f9 100644 --- a/server/app/adminapi/logic/dept/DeptLogic.php +++ b/server/app/adminapi/logic/dept/DeptLogic.php @@ -18,6 +18,7 @@ use app\common\logic\BaseLogic; use app\common\model\auth\Admin; use app\common\model\auth\AdminDept; use app\common\model\dept\Dept; +use app\common\service\DataScope\DataScopeService; /** @@ -284,6 +285,60 @@ class DeptLogic extends BaseLogic return self::getTree($data, $pid); } + /** + * 部门树(与 getAllData 同结构),按当前管理员角色数据权限收窄可选节点; + * 保留必选祖先节点以便树形展示(与业绩看板 deptOptions 的 allowed 集合一致)。 + * + * @param array $adminInfo BaseAdminController::$adminInfo 形态(须含 root、role_id 等) + * + * @return array> + */ + public static function getAllDataScoped(int $adminId, array $adminInfo): array + { + $full = self::getAllData(); + if ($full === []) { + return []; + } + if ($adminId <= 0 || !DataScopeService::isEnabled()) { + return $full; + } + $allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo); + if ($allowed === null) { + return $full; + } + if ($allowed === []) { + return []; + } + + return self::filterDeptTreeByAllowedIds($full, $allowed); + } + + /** + * @param array> $nodes + * @param array $allowedIdMap + * + * @return array> + */ + 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) + : []; + $inAllowed = isset($allowedIdMap[$id]); + if ($inAllowed || $children !== []) { + $row = $node; + $row['children'] = $children; + $out[] = $row; + } + } + + return $out; + } + /** * 指定部门及其全部下级部门 id(含自身)。筛选时选父级可匹配子级下成员(admin_dept.dept_id)。 * diff --git a/server/app/adminapi/logic/stats/YejiStatsLogic.php b/server/app/adminapi/logic/stats/YejiStatsLogic.php index befb57d2..344aad8e 100644 --- a/server/app/adminapi/logic/stats/YejiStatsLogic.php +++ b/server/app/adminapi/logic/stats/YejiStatsLogic.php @@ -2499,6 +2499,158 @@ class YejiStatsLogic return self::$tagScopeCache[$tagFilterId] = [array_keys($diagIds), $assistantIds]; } + /** + * 「未归属中心」合计业绩按订单创建人拆解(与 aggregatePerformance /「合计业绩」列归属一致:仅 null,null 标签口径,不按渠道收窄)。 + * + * @return array{ + * start_date: string, + * end_date: string, + * channel_code: string, + * rows: list, + * note: string + * } + */ + public static function unassignedCenterBreakdown(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array + { + $c = self::resolveYejiContext($params); + if ($viewerAdminId > 0) { + self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); + } + $startTs = (int) $c['startTs']; + $endTs = (int) $c['endTs']; + $adminToPrimary = $c['adminToPrimary']; + $visibleCreatorIds = null; + if (!empty($c['dataScopeRestricted'])) { + $visibleCreatorIds = $c['dataScopeVisibleAdminIds'] ?? []; + } + $rows = self::aggregateUnassignedByCreator( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $visibleCreatorIds + ); + $ids = []; + foreach ($rows as $row) { + if ($row['admin_id'] > 0) { + $ids[] = $row['admin_id']; + } + } + $names = self::batchAdminNames(array_values(array_unique($ids))); + foreach ($rows as &$row) { + $aid = (int) $row['admin_id']; + if ($aid <= 0) { + $row['name'] = '(无创建人/0)'; + } else { + $row['name'] = (string) ($names[$aid] ?? ('#' . $aid)); + } + } + unset($row); + + return [ + 'start_date' => (string) $c['startDate'], + 'end_date' => (string) $c['endDate'], + 'channel_code' => (string) $c['channelCode'], + 'rows' => $rows, + 'note' => '与看板「合计业绩」列口径一致:按订单创建时间、剔除履约已取消(4);创建人无法映射到任一展示「中心」部门的业绩计入未归属。账号数据权限下仅列出可见创建人相关订单。', + ]; + } + + /** + * @param int[]|null $visibleCreatorIds 数据权限:仅统计这些创建人(null=不额外限制);须含 creator_id=0 时用 OR 条件已并入查询 + * + * @return list + */ + private static function aggregateUnassignedByCreator( + int $startTs, + int $endTs, + array $adminToPrimary, + ?array $tagDiagIds, + ?array $tagAssistantIds, + ?array $visibleCreatorIds + ): array { + if ($tagDiagIds !== null && $tagDiagIds === []) { + return []; + } + if ($tagAssistantIds !== null && $tagAssistantIds === []) { + return []; + } + $query = Db::name('tcm_prescription_order') + ->alias('o') + ->whereNull('o.delete_time') + ->where('o.create_time', 'between', [$startTs, $endTs]); + self::applyPrescriptionOrderNotCancelledForPerformance($query); + if ($tagDiagIds !== null) { + $query->whereIn('o.diagnosis_id', $tagDiagIds); + } + if ($tagAssistantIds !== null) { + $query->whereIn('o.creator_id', array_keys($tagAssistantIds)); + } + if ($visibleCreatorIds !== null) { + $visibleCreatorIds = array_values(array_unique(array_filter(array_map('intval', $visibleCreatorIds), static fn (int $v): bool => $v > 0))); + if ($visibleCreatorIds === []) { + return []; + } + $ph = implode(',', array_fill(0, count($visibleCreatorIds), '?')); + $query->whereRaw("(o.creator_id = 0 OR o.creator_id IN ({$ph}))", $visibleCreatorIds); + } + $query->field(['o.creator_id', 'COUNT(*) AS order_count', 'SUM(o.amount) AS amount']) + ->group('o.creator_id'); + $dbRows = $query->select()->toArray(); + if ($dbRows === []) { + return []; + } + $out = []; + foreach ($dbRows as $r) { + $cid = (int) ($r['creator_id'] ?? 0); + $primary = (int) ($adminToPrimary[$cid] ?? 0); + if ($primary > 0) { + continue; + } + $out[] = [ + 'admin_id' => $cid, + 'name' => '', + 'order_count' => (int) ($r['order_count'] ?? 0), + 'amount' => round((float) ($r['amount'] ?? 0), 2), + ]; + } + usort($out, static function (array $a, array $b): int { + $c = $b['amount'] <=> $a['amount']; + if ($c !== 0) { + return $c; + } + + return $b['order_count'] <=> $a['order_count']; + }); + + return $out; + } + + /** + * @param int[] $ids + * + * @return array + */ + private static function batchAdminNames(array $ids): array + { + if ($ids === []) { + return []; + } + $rows = Db::name('admin') + ->whereIn('id', $ids) + ->whereNull('delete_time') + ->field(['id', 'name']) + ->select() + ->toArray(); + $map = []; + foreach ($rows as $r) { + $map[(int) $r['id']] = (string) ($r['name'] ?? ''); + } + + return $map; + } + /** * 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名) */