1
This commit is contained in:
@@ -105,24 +105,27 @@
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-col :span="24">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>转化排行</span>
|
||||
<span class="card-hint">{{ dateRangeText }}</span>
|
||||
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="rankingChartHasData"
|
||||
class="stats-chart"
|
||||
class="stats-chart stats-chart-wide"
|
||||
:option="rankingChartOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无图表数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -139,10 +142,7 @@
|
||||
<el-empty v-else description="暂无金额数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -159,35 +159,82 @@
|
||||
<el-empty v-else description="暂无加粉数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :span="24">
|
||||
<el-card class="!border-none stats-detail-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>明细列表</span>
|
||||
<span class="card-hint">{{ entityLabel }}维度</span>
|
||||
<div class="card-header-main">
|
||||
<span class="card-title">明细列表</span>
|
||||
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
|
||||
:row-key="rowKeyFn"
|
||||
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
|
||||
:default-expand-all="false"
|
||||
size="large"
|
||||
:indent="20"
|
||||
:row-class-name="rowClassName"
|
||||
size="default"
|
||||
style="width: 100%"
|
||||
max-height="560"
|
||||
max-height="640"
|
||||
border
|
||||
>
|
||||
<el-table-column
|
||||
:label="entityLabel"
|
||||
prop="name"
|
||||
fixed="left"
|
||||
min-width="280"
|
||||
min-width="300"
|
||||
class-name="dept-name-column"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="dept-name-cell" :title="row.name">
|
||||
{{ row.name }}
|
||||
</span>
|
||||
<div class="dept-name-cell" :title="row.name">
|
||||
<span
|
||||
v-if="isMemberRow(row)"
|
||||
class="member-icon"
|
||||
:class="{
|
||||
'is-leader': row.is_leader,
|
||||
'is-unbound': row.type === 'unbound'
|
||||
}"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</span>
|
||||
<span class="entity-name">{{ row.name }}</span>
|
||||
<el-tag
|
||||
v-if="row.type === 'member' && row.is_leader"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="light"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
组长
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.type === 'member' && row.role"
|
||||
size="small"
|
||||
:type="row.role === '医生' ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
{{ row.role }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.type === 'unbound'"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
未绑定
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
@@ -215,6 +262,7 @@
|
||||
<script setup lang="ts" name="conversionStatsPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
|
||||
import { getConversionStatsOverview } from '@/api/stats'
|
||||
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
|
||||
return String(value ?? 0)
|
||||
}
|
||||
|
||||
const rowKeyFn = (row: Record<string, any>) => {
|
||||
if (row?.id !== undefined && row?.id !== null) {
|
||||
return String(row.id)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const isMemberRow = (row: Record<string, any>) => {
|
||||
return row?.type === 'member' || row?.type === 'unbound'
|
||||
}
|
||||
|
||||
const rowClassName = ({ row }: { row: Record<string, any> }) => {
|
||||
if (row?.type === 'unbound') {
|
||||
return 'member-row member-row--unbound'
|
||||
}
|
||||
if (row?.type === 'member') {
|
||||
return row.is_leader ? 'member-row member-row--leader' : 'member-row'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOverview()
|
||||
})
|
||||
@@ -672,6 +741,27 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
@@ -680,19 +770,93 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.dept-name-cell {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.member-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #e6f0ff;
|
||||
color: #4a78ff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-icon.is-leader {
|
||||
background: #fff4e0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.member-icon.is-unbound {
|
||||
background: #fde8e8;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.dept-name-column .cell) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.member-row) td {
|
||||
background-color: #fafbff !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--leader) td {
|
||||
background-color: #fff8ec !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--leader .entity-name) {
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.member-row--unbound) td {
|
||||
background-color: #fff5f5 !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--unbound .entity-name) {
|
||||
color: #ef4444;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-detail-card :deep(.el-card__body) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.stats-chart-wide {
|
||||
height: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,6 +125,28 @@ class ConversionLogic
|
||||
$adminToDeptIds
|
||||
);
|
||||
|
||||
$includeMembers = (int)($params['include_members'] ?? 1) === 1;
|
||||
if ($includeMembers) {
|
||||
$validDeptIds = array_values(array_filter(
|
||||
array_map('intval', array_keys($entities)),
|
||||
static fn (int $id): bool => $id > 0
|
||||
));
|
||||
$memberRowsByDeptId = self::buildMemberRowsByDept(
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$startDate,
|
||||
$endDate,
|
||||
$mediaChannel,
|
||||
$mediaChannelCode,
|
||||
$restrictAccountCostByDept,
|
||||
$eligibleDeptIds,
|
||||
$adminToDeptIds,
|
||||
$validDeptIds,
|
||||
$globalAccountCost
|
||||
);
|
||||
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
@@ -356,6 +378,11 @@ class ConversionLogic
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* 字段说明:
|
||||
* - total_open_count / unreplied_count:预留字段,对应"总开口数 / 未回复数"。当前 IM
|
||||
* 会话归档系统未上线,没有数据源可以填充,前端按 placeholder='—' 显示。等接入后
|
||||
* 在 hydrateXxx 系列里追加 SQL 写入即可,无需调整下游 finalize / chart / summary。
|
||||
*/
|
||||
private static function newEntityRow(int $id, string $name): array
|
||||
{
|
||||
@@ -363,8 +390,8 @@ class ConversionLogic
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'add_fans_count' => 0,
|
||||
'total_open_count' => 0,
|
||||
'unreplied_count' => 0,
|
||||
'total_open_count' => 0, // TODO: 接入 IM 会话统计后回填
|
||||
'unreplied_count' => 0, // TODO: 接入 IM 会话统计后回填
|
||||
'paid_appointment_count' => 0,
|
||||
'free_appointment_count' => 0,
|
||||
'appointment_total_count' => 0,
|
||||
@@ -403,10 +430,20 @@ class ConversionLogic
|
||||
|
||||
/**
|
||||
* @return array<int, int[]>
|
||||
*
|
||||
* 返回 admin_id => [dept_id, ...],每个 admin 的 dept_id 列表按 dept_id 升序排列
|
||||
* (zyt_admin_dept 联合主键 admin_id+dept_id,无独立 id 列)。
|
||||
* 当 admin 跨部门时,下游 mapEntityIds 只取列表中第一个落在当前 entityIds 内的部门,
|
||||
* 避免同一笔加粉/挂号/接诊被多次累加到不同部门。
|
||||
*/
|
||||
private static function loadAdminDeptMap(): array
|
||||
{
|
||||
$rows = Db::name('admin_dept')->field('admin_id, dept_id')->select()->toArray();
|
||||
$rows = Db::name('admin_dept')
|
||||
->field('admin_id, dept_id')
|
||||
->order('admin_id', 'asc')
|
||||
->order('dept_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int)($row['admin_id'] ?? 0);
|
||||
@@ -485,6 +522,14 @@ class ConversionLogic
|
||||
$filtered = [];
|
||||
foreach ($entities as $key => $entity) {
|
||||
$entityId = (int)($entity['id'] ?? 0);
|
||||
// 虚拟桶(未绑定 admin / 未分配部门)不参与渠道-部门作用域过滤,
|
||||
// 否则会丢失"未绑定 admin 的加粉"和"已绑定 admin 但无部门的加粉"统计。
|
||||
if ($entityId === self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID
|
||||
|| $entityId === self::VIRTUAL_DEPT_UNASSIGNED_ID
|
||||
) {
|
||||
$filtered[$key] = $entity;
|
||||
continue;
|
||||
}
|
||||
if ($entityId <= 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -826,6 +871,9 @@ class ConversionLogic
|
||||
* @param int[] $entityIds
|
||||
* @param array<int, int[]> $adminToDeptIds
|
||||
* @return int[]
|
||||
*
|
||||
* dept 维度下 admin 跨部门时,仅返回 admin_dept 中第一个落在当前 entityIds 内的部门
|
||||
* (由 loadAdminDeptMap 排序决定),保证同一笔指标只累加到一个部门,避免重复计数。
|
||||
*/
|
||||
private static function mapEntityIds(string $dimension, int $adminId, array $entityIds, array $adminToDeptIds): array
|
||||
{
|
||||
@@ -842,7 +890,13 @@ class ConversionLogic
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($deptIds, static fn (int $deptId): bool => in_array($deptId, $entityIds, true)));
|
||||
foreach ($deptIds as $deptId) {
|
||||
if (in_array((int)$deptId, $entityIds, true)) {
|
||||
return [(int)$deptId];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1136,6 +1190,660 @@ class ConversionLogic
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在部门树节点的 children 末尾追加该部门下属的成员明细行(不参与部门指标累加)。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $deptRows
|
||||
* @param array<int, array<int, array<string, mixed>>> $memberRowsByDeptId
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function attachDeptMembers(array $deptRows, array $memberRowsByDeptId): array
|
||||
{
|
||||
foreach ($deptRows as &$node) {
|
||||
$deptId = (int)($node['id'] ?? 0);
|
||||
$children = $node['children'] ?? [];
|
||||
if (!is_array($children)) {
|
||||
$children = [];
|
||||
}
|
||||
if ($children !== []) {
|
||||
$children = self::attachDeptMembers($children, $memberRowsByDeptId);
|
||||
}
|
||||
$members = $memberRowsByDeptId[$deptId] ?? [];
|
||||
if ($members !== []) {
|
||||
$children = array_merge($children, $members);
|
||||
}
|
||||
$node['children'] = $children;
|
||||
}
|
||||
unset($node);
|
||||
|
||||
return $deptRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 dept 维度下每个部门下属成员(医助 / 医生)的明细行,并按 dept_id 分组返回。
|
||||
* 成员节点的 id 使用 "M{adminId}_{deptId}" 形式以保证 row-key 唯一。
|
||||
*
|
||||
* @param int[] $validDeptIds 当前有效的 dept_id 集合(来自 dept 树构建后的 entities),
|
||||
* 用于过滤 admin_dept 表中残留的已删除部门关联,避免把成员
|
||||
* 挂到不存在的部门上、错过"未分配部门"虚拟桶。
|
||||
* @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。
|
||||
* -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。
|
||||
* @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...]
|
||||
*/
|
||||
private static function buildMemberRowsByDept(
|
||||
int $startTimestamp,
|
||||
int $endTimestamp,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $mediaChannel,
|
||||
string $mediaChannelCode,
|
||||
bool $restrictAccountCostByDept,
|
||||
array $eligibleDeptIds,
|
||||
array $adminToDeptIds,
|
||||
array $validDeptIds = [],
|
||||
float $globalAccountCost = -1.0
|
||||
): array
|
||||
{
|
||||
$assistantEntities = self::loadAdminEntities(2);
|
||||
$doctorEntities = self::loadAdminEntities(1);
|
||||
|
||||
$assistantIds = array_keys($assistantEntities);
|
||||
$doctorIds = array_keys($doctorEntities);
|
||||
|
||||
if ($assistantIds !== []) {
|
||||
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
||||
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
|
||||
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
||||
}
|
||||
if ($doctorIds !== []) {
|
||||
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
||||
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
|
||||
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
||||
}
|
||||
|
||||
// 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。
|
||||
if ($globalAccountCost < 0) {
|
||||
// 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。
|
||||
if ($assistantIds !== []) {
|
||||
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCode);
|
||||
} elseif ($doctorIds !== []) {
|
||||
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCode);
|
||||
} else {
|
||||
$globalAccountCost = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
$combined = [];
|
||||
foreach ($assistantEntities as $adminId => $entity) {
|
||||
$entity['_role'] = '医助';
|
||||
$combined[$adminId] = $entity;
|
||||
}
|
||||
foreach ($doctorEntities as $adminId => $entity) {
|
||||
if (isset($combined[$adminId])) {
|
||||
foreach (['add_fans_count', 'total_open_count', 'unreplied_count', 'paid_appointment_count', 'free_appointment_count', 'appointment_total_count', 'interview_count', 'completed_order_count'] as $intKey) {
|
||||
$combined[$adminId][$intKey] = (int)($combined[$adminId][$intKey] ?? 0) + (int)($entity[$intKey] ?? 0);
|
||||
}
|
||||
foreach (['business_order_amount', 'completed_order_amount'] as $moneyKey) {
|
||||
$combined[$adminId][$moneyKey] = round((float)($combined[$adminId][$moneyKey] ?? 0) + (float)($entity[$moneyKey] ?? 0), 2);
|
||||
}
|
||||
$combined[$adminId]['_role'] = '医助/医生';
|
||||
} else {
|
||||
$entity['_role'] = '医生';
|
||||
$combined[$adminId] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
if ($combined === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// $globalAccountCost 已经由调用方传入(或上面兜底查得),不再从 entities 中重复提取。
|
||||
$globalAddFansCount = 0;
|
||||
foreach ($combined as $entity) {
|
||||
$globalAddFansCount += (int)($entity['add_fans_count'] ?? 0);
|
||||
}
|
||||
|
||||
$leaderAdminByDept = self::loadDeptLeaderAdminMap($adminToDeptIds);
|
||||
$validDeptIdSet = array_fill_keys(array_map('intval', $validDeptIds), true);
|
||||
|
||||
$memberRowsByDeptId = [];
|
||||
foreach ($combined as $adminId => $entity) {
|
||||
$adminIdInt = (int)$adminId;
|
||||
if ($adminIdInt <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!self::hasBusinessMetrics($entity)) {
|
||||
continue;
|
||||
}
|
||||
$rawDeptIds = $adminToDeptIds[$adminIdInt] ?? [];
|
||||
// 与 mapEntityIds 保持一致:admin 跨部门时只挂到 admin_dept 中第一个落在 valid 集合的部门,
|
||||
// 避免同一 admin 在多个部门下重复出现,导致 dept 指标累加到 summary 时双倍计数。
|
||||
$primaryDeptId = 0;
|
||||
if ($validDeptIdSet === []) {
|
||||
foreach ($rawDeptIds as $rawDeptId) {
|
||||
$candidate = (int)$rawDeptId;
|
||||
if ($candidate > 0) {
|
||||
$primaryDeptId = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($rawDeptIds as $rawDeptId) {
|
||||
$candidate = (int)$rawDeptId;
|
||||
if ($candidate > 0 && isset($validDeptIdSet[$candidate])) {
|
||||
$primaryDeptId = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($primaryDeptId <= 0) {
|
||||
$row = self::finalizeAdminMemberRow(
|
||||
$entity,
|
||||
$globalAccountCost,
|
||||
$globalAddFansCount,
|
||||
$restrictAccountCostByDept,
|
||||
$eligibleDeptIds,
|
||||
$adminToDeptIds,
|
||||
(string)($entity['_role'] ?? ''),
|
||||
false,
|
||||
self::VIRTUAL_DEPT_UNASSIGNED_ID
|
||||
);
|
||||
$memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID][] = $row;
|
||||
continue;
|
||||
}
|
||||
$isLeader = isset($leaderAdminByDept[$primaryDeptId]) && (int)$leaderAdminByDept[$primaryDeptId] === $adminIdInt;
|
||||
$row = self::finalizeAdminMemberRow(
|
||||
$entity,
|
||||
$globalAccountCost,
|
||||
$globalAddFansCount,
|
||||
$restrictAccountCostByDept,
|
||||
$eligibleDeptIds,
|
||||
$adminToDeptIds,
|
||||
(string)($entity['_role'] ?? ''),
|
||||
$isLeader,
|
||||
$primaryDeptId
|
||||
);
|
||||
$memberRowsByDeptId[$primaryDeptId][] = $row;
|
||||
}
|
||||
|
||||
$unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel);
|
||||
if ($unboundRows !== []) {
|
||||
$memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows;
|
||||
}
|
||||
|
||||
// 补充:dept 维度 hydrate 把任意角色的 admin 加粉都归到 -2 桶,
|
||||
// 但 combined 仅含医助/医生角色 admin。此处把"非医助/医生"或"无 admin_role"
|
||||
// 但有加粉事件的 admin 也挂到"未分配部门"虚拟桶下,避免 children 为空。
|
||||
$assignedAdminIds = array_fill_keys(array_map('intval', array_keys($combined)), true);
|
||||
$leftoverRows = self::buildLeftoverAdminRowsForUnassigned(
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
$assignedAdminIds
|
||||
);
|
||||
if ($leftoverRows !== []) {
|
||||
$existing = $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] ?? [];
|
||||
$memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge($existing, $leftoverRows);
|
||||
}
|
||||
|
||||
foreach ($memberRowsByDeptId as &$rows) {
|
||||
usort($rows, static function (array $left, array $right): int {
|
||||
$leaderCompare = ((int)($right['is_leader'] ?? 0)) <=> ((int)($left['is_leader'] ?? 0));
|
||||
if ($leaderCompare !== 0) {
|
||||
return $leaderCompare;
|
||||
}
|
||||
$amountCompare = $right['completed_order_amount'] <=> $left['completed_order_amount'];
|
||||
if ($amountCompare !== 0) {
|
||||
return $amountCompare;
|
||||
}
|
||||
$countCompare = $right['completed_order_count'] <=> $left['completed_order_count'];
|
||||
if ($countCompare !== 0) {
|
||||
return $countCompare;
|
||||
}
|
||||
|
||||
return $right['add_fans_count'] <=> $left['add_fans_count'];
|
||||
});
|
||||
}
|
||||
unset($rows);
|
||||
|
||||
return $memberRowsByDeptId;
|
||||
}
|
||||
|
||||
/**
|
||||
* "未绑定后台账号"虚拟桶的展开行:从加粉事件中按企微 user_id 聚合,过滤掉已绑定 admin 的部分。
|
||||
* 这些 user_id 没有对应的 admin 记录,因此只能展示加粉数。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function buildUnboundFansRows(int $startTimestamp, int $endTimestamp, ?array $mediaChannel): array
|
||||
{
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->whereNull('a.id')
|
||||
->where('e.user_id', '<>', '')
|
||||
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
|
||||
->group('e.user_id');
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
if ($userId !== '') {
|
||||
$userIds[$userId] = true;
|
||||
}
|
||||
}
|
||||
$nameByUserId = self::resolveQywxUserNames(array_keys($userIds));
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
if ($userId === '' || $addFansCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
$resolvedName = $nameByUserId[$userId] ?? '';
|
||||
$displayName = $resolvedName !== '' ? $resolvedName . '(' . $userId . ')' : $userId;
|
||||
$memberRow = self::buildEmptyMemberRow(
|
||||
'U_' . $userId,
|
||||
$displayName,
|
||||
'unbound',
|
||||
'未绑定 admin'
|
||||
);
|
||||
$memberRow['add_fans_count'] = $addFansCount;
|
||||
$result[] = $memberRow;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* "未分配部门"虚拟桶的补充行:把医助/医生主流程没覆盖到的 admin 也挂上。
|
||||
* 适用场景:admin 有加粉事件、能匹配到 admin(即不属于"未绑定 admin"),
|
||||
* 但因为他不是医助/医生(admin_role 不是 1/2,或没有 admin_role 记录)所以
|
||||
* 不在 buildMemberRowsByDept 的主循环里。
|
||||
*
|
||||
* 这些 admin 的业务指标无法从医助/医生口径得出,因此仅展示加粉数。
|
||||
*
|
||||
* @param array<int, true> $assignedAdminIds 已挂过部门或 -2 桶的 admin_id 集合
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function buildLeftoverAdminRowsForUnassigned(
|
||||
int $startTimestamp,
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
array $assignedAdminIds
|
||||
): array
|
||||
{
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count')
|
||||
->group('a.id, a.name');
|
||||
|
||||
if ($mediaChannel !== null) {
|
||||
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
|
||||
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int)($row['admin_id'] ?? 0);
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
if ($adminId <= 0 || $addFansCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (isset($assignedAdminIds[$adminId])) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($row['admin_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = 'admin#' . $adminId;
|
||||
}
|
||||
$memberRow = self::buildEmptyMemberRow(
|
||||
'M' . $adminId . '_' . self::VIRTUAL_DEPT_UNASSIGNED_ID,
|
||||
$name,
|
||||
'member',
|
||||
'其他角色'
|
||||
);
|
||||
$memberRow['admin_id'] = $adminId;
|
||||
$memberRow['add_fans_count'] = $addFansCount;
|
||||
$result[] = $memberRow;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反查企微员工 user_id(如 CaoTaDuo)对应的中文名。
|
||||
* 来源依次:admin 表(含已软删的,避免离职后丢失映射)→ qywx_external_contact.follow_users JSON 中的 remark 字段。
|
||||
*
|
||||
* @param string[] $userIds
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function resolveQywxUserNames(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, static fn (string $id): bool => $id !== '')));
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
// admin 表(包含已软删账户)反查;company-side ALL admin 即使删除也保留 work_wechat_userid 映射可用。
|
||||
$adminRows = Db::name('admin')
|
||||
->whereIn('work_wechat_userid', $userIds)
|
||||
->field('work_wechat_userid, name')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($adminRows as $row) {
|
||||
$userId = (string)($row['work_wechat_userid'] ?? '');
|
||||
$name = trim((string)($row['name'] ?? ''));
|
||||
if ($userId === '' || $name === '' || isset($result[$userId])) {
|
||||
continue;
|
||||
}
|
||||
$result[$userId] = $name;
|
||||
}
|
||||
|
||||
$remaining = array_values(array_diff($userIds, array_keys($result)));
|
||||
if ($remaining === []) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 从 qywx_external_contact.follow_users JSON 的 remark/description 字段尽力反查。
|
||||
$followRows = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->where('follow_users', 'like', '%' . $remaining[0] . '%')
|
||||
->limit(0)
|
||||
->field('follow_users')
|
||||
->select()
|
||||
->toArray();
|
||||
if ($followRows === []) {
|
||||
// 单条 LIKE 没命中再退化全表(量大时会慢,因此仅在极少数员工场景下兜底)。
|
||||
$followRows = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->whereRaw('follow_users IS NOT NULL AND follow_users <> ""')
|
||||
->limit(2000)
|
||||
->field('follow_users')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$remainingMap = array_fill_keys($remaining, true);
|
||||
foreach ($followRows as $row) {
|
||||
if ($remainingMap === []) {
|
||||
break;
|
||||
}
|
||||
$followUsers = json_decode((string)($row['follow_users'] ?? '[]'), true);
|
||||
if (!is_array($followUsers)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($followUsers as $fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$uid = trim((string)($fu['userid'] ?? ''));
|
||||
if ($uid === '' || !isset($remainingMap[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($fu['remark_corp_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = trim((string)($fu['remark'] ?? ''));
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = trim((string)($fu['description'] ?? ''));
|
||||
}
|
||||
if ($name !== '') {
|
||||
$result[$uid] = $name;
|
||||
unset($remainingMap[$uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个全字段为 0 的成员行模板(适用于"未绑定后台账号"等无业务关联的虚拟成员)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildEmptyMemberRow(string $id, string $name, string $type, string $role): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'admin_id' => 0,
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'role' => $role,
|
||||
'is_leader' => false,
|
||||
'add_fans_count' => 0,
|
||||
'total_open_count' => 0,
|
||||
'unreplied_count' => 0,
|
||||
'paid_appointment_count' => 0,
|
||||
'free_appointment_count' => 0,
|
||||
'appointment_total_count' => 0,
|
||||
'interview_count' => 0,
|
||||
'completed_order_count' => 0,
|
||||
'business_order_amount' => 0.0,
|
||||
'completed_order_amount' => 0.0,
|
||||
'account_cost' => 0.0,
|
||||
'paid_appointment_rate' => 0.0,
|
||||
'open_appointment_rate' => 0.0,
|
||||
'interview_rate' => 0.0,
|
||||
'receive_rate' => 0.0,
|
||||
'interview_receive_rate' => 0.0,
|
||||
'open_receive_rate' => 0.0,
|
||||
'avg_unit_price' => 0.0,
|
||||
'cash_cost' => 0.0,
|
||||
'roi' => 0.0,
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个成员行的指标 finalize(不进入 charts/summary,仅作为 dept 树展开后的展示节点)。
|
||||
*
|
||||
* @param array<string, mixed> $entity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function finalizeAdminMemberRow(
|
||||
array $entity,
|
||||
float $globalAccountCost,
|
||||
int $globalAddFansCount,
|
||||
bool $restrictAccountCostByDept,
|
||||
array $eligibleDeptIds,
|
||||
array $adminToDeptIds,
|
||||
string $role,
|
||||
bool $isLeader,
|
||||
int $deptId
|
||||
): array
|
||||
{
|
||||
$adminId = (int)($entity['id'] ?? 0);
|
||||
$paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0);
|
||||
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
|
||||
$interviewCount = (int)($entity['interview_count'] ?? 0);
|
||||
$addFansCount = (int)($entity['add_fans_count'] ?? 0);
|
||||
$totalOpenCount = (int)($entity['total_open_count'] ?? 0);
|
||||
$completedOrderCount = (int)($entity['completed_order_count'] ?? 0);
|
||||
$businessOrderAmount = round((float)($entity['business_order_amount'] ?? 0), 2);
|
||||
$completedOrderAmount = round((float)($entity['completed_order_amount'] ?? 0), 2);
|
||||
$isEligibleForAccountCost = self::isAccountCostEligible('member', $entity, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds);
|
||||
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
|
||||
|
||||
return [
|
||||
'id' => 'M' . $adminId . '_' . $deptId,
|
||||
'admin_id' => $adminId,
|
||||
'name' => (string)($entity['name'] ?? ''),
|
||||
'type' => 'member',
|
||||
'role' => $role,
|
||||
'is_leader' => $isLeader,
|
||||
'add_fans_count' => $addFansCount,
|
||||
'total_open_count' => $totalOpenCount,
|
||||
'unreplied_count' => (int)($entity['unreplied_count'] ?? 0),
|
||||
'paid_appointment_count' => $paidAppointmentCount,
|
||||
'free_appointment_count' => max($appointmentTotalCount - $paidAppointmentCount, 0),
|
||||
'appointment_total_count' => $appointmentTotalCount,
|
||||
'interview_count' => $interviewCount,
|
||||
'completed_order_count' => $completedOrderCount,
|
||||
'business_order_amount' => $businessOrderAmount,
|
||||
'completed_order_amount' => $completedOrderAmount,
|
||||
'account_cost' => $effectiveAccountCost,
|
||||
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
||||
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
||||
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
|
||||
'receive_rate' => self::percent($completedOrderCount, $addFansCount),
|
||||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||
'cash_cost' => self::safeDivideMoney($effectiveAccountCost, $addFansCount),
|
||||
'roi' => self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost),
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门 leader 字段(字符串 admin name)→ admin_id 映射,便于在成员列表中标记组长。
|
||||
* 匹配策略(按优先级):
|
||||
* 1. 仅在该部门下属成员(admin_dept 关联)中找候选,避免跨部门误命中;
|
||||
* 2. 归一化字符串(去空格、去括号注释、去常见职务后缀)后做完全相等;
|
||||
* 3. 退化为包含匹配(dept.leader 字符串包含 admin.name 或反向)。
|
||||
*
|
||||
* @param array<int, int[]> $adminToDeptIds admin_id => [dept_id, ...]
|
||||
* @return array<int, int> dept_id => admin_id
|
||||
*/
|
||||
private static function loadDeptLeaderAdminMap(array $adminToDeptIds): array
|
||||
{
|
||||
$deptRows = Db::name('dept')
|
||||
->whereNull('delete_time')
|
||||
->field('id, leader')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$leaderByDept = [];
|
||||
foreach ($deptRows as $row) {
|
||||
$deptId = (int)($row['id'] ?? 0);
|
||||
$leader = self::normalizeLeaderName((string)($row['leader'] ?? ''));
|
||||
if ($deptId <= 0 || $leader === '') {
|
||||
continue;
|
||||
}
|
||||
$leaderByDept[$deptId] = $leader;
|
||||
}
|
||||
if ($leaderByDept === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$deptToAdmins = [];
|
||||
foreach ($adminToDeptIds as $adminId => $deptIds) {
|
||||
$adminIdInt = (int)$adminId;
|
||||
if ($adminIdInt <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach ($deptIds as $deptId) {
|
||||
$deptToAdmins[(int)$deptId][] = $adminIdInt;
|
||||
}
|
||||
}
|
||||
|
||||
$candidateAdminIds = [];
|
||||
foreach (array_keys($leaderByDept) as $deptId) {
|
||||
foreach ($deptToAdmins[$deptId] ?? [] as $aid) {
|
||||
$candidateAdminIds[$aid] = $aid;
|
||||
}
|
||||
}
|
||||
if ($candidateAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$adminNames = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->whereIn('id', array_values($candidateAdminIds))
|
||||
->column('name', 'id');
|
||||
|
||||
$result = [];
|
||||
foreach ($leaderByDept as $deptId => $leaderName) {
|
||||
$candidates = $deptToAdmins[$deptId] ?? [];
|
||||
if ($candidates === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($candidates as $aid) {
|
||||
$rawName = (string)($adminNames[$aid] ?? '');
|
||||
$normName = self::normalizeLeaderName($rawName);
|
||||
if ($normName === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized[$aid] = $normName;
|
||||
}
|
||||
if ($normalized === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = null;
|
||||
foreach ($normalized as $aid => $nm) {
|
||||
if ($nm === $leaderName) {
|
||||
$matched = $aid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($matched === null) {
|
||||
foreach ($normalized as $aid => $nm) {
|
||||
if (mb_strlen($nm) >= 2 && (mb_strpos($leaderName, $nm) !== false || mb_strpos($nm, $leaderName) !== false)) {
|
||||
$matched = $aid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($matched !== null) {
|
||||
$result[$deptId] = (int)$matched;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化用于姓名匹配的字符串:去空格 / 全角空格 / 中英文括号注释 / 常见职务后缀。
|
||||
*/
|
||||
private static function normalizeLeaderName(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
|
||||
Reference in New Issue
Block a user