Files
zyt/server/app/adminapi/logic/stats/ConversionLogic.php
T
2026-05-12 17:26:11 +08:00

2408 lines
96 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\finance\AccountCost;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\db\Query;
use think\facade\Db;
class ConversionLogic
{
private const VIRTUAL_DEPT_UNBOUND_ADMIN_ID = -1;
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
/**
* @param array $params
* @param int $adminId 当前操作 admin(来自 BaseAdminController
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
* @return array<string, mixed>
*
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。
* - nullSCOPE_ALL,全数据放行(与历史行为一致)
* - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据
* - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项
*/
public static function overview(array $params = [], int $adminId = 0, ?array $adminInfo = null): array
{
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
: null;
// 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。
if ($visibleAdminIds === []) {
$emptyResult = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
$emptyResult['extend']['filters'] = self::buildFilterOptions([], []);
}
return $emptyResult;
}
$entities = self::loadEntities($dimension, $params, $visibleAdminIds);
$entityIds = array_keys($entities);
if ($entityIds === []) {
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, [], $adminId, $adminInfo);
}
return $result;
}
$adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
$allocationEntities = $entities;
$allocationEntityIds = $entityIds;
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
$scopeDeptIds = $restrictStatsByDept
? self::loadChannelBoundDeptIds($mediaChannelCode)
: $accountCostDeptIds;
$eligibleDeptIds = $restrictAccountCostByDept ? self::expandDeptIdsWithDescendants($scopeDeptIds) : $scopeDeptIds;
if ($restrictStatsByDept) {
$entities = self::filterEntitiesByChannelDeptScope($entities, $dimension, $eligibleDeptIds, $adminToDeptIds);
$allocationEntities = self::filterEntitiesByChannelDeptScope($allocationEntities, $dimension, $eligibleDeptIds, $adminToDeptIds);
}
$entityIds = array_keys($entities);
$allocationEntityIds = array_keys($allocationEntities);
if ($entityIds === []) {
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
}
return $result;
}
$allocationAddFansCount = self::sumEntityAddFansForAccountCost(
array_values($allocationEntities),
$dimension,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds
);
if ($dimension === 'dept') {
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
$entities,
isset($params['dept_id']) ? (int)$params['dept_id'] : 0,
$pageNo,
$pageSize,
$filterEmptyEntities,
$allocationAddFansCount,
$restrictAccountCostByDept,
$eligibleDeptIds,
$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,
$visibleAdminIds
);
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
}
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows),
'charts' => self::buildCharts($chartRows),
'lists' => $pagedRows,
'count' => count($allRows),
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows),
'charts' => self::buildCharts($chartRows),
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
}
return $result;
}
$rows = self::finalizeRows(
$entities,
$filterEmptyEntities,
$allocationAddFansCount,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds,
$dimension
);
$count = count($rows);
$offset = ($pageNo - 1) * $pageSize;
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows),
'charts' => self::buildCharts($rows),
'lists' => array_slice($rows, $offset, $pageSize),
'count' => $count,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows),
'charts' => self::buildCharts($rows),
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
}
return $result;
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL
* @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域
* @return array<string, mixed>
*/
private static function buildFilterOptions(
?array $visibleAdminIds = null,
array $eligibleDeptIds = [],
int $adminId = 0,
?array $adminInfo = null
): array
{
$allowedDeptIds = self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds);
return [
'departments' => self::buildDepartmentOptions($visibleAdminIds, $eligibleDeptIds, $allowedDeptIds),
'assistants' => DiagnosisLogic::getAssistants($adminId, $adminInfo),
'doctors' => self::buildDoctorOptions($visibleAdminIds),
'media_channels' => self::buildMediaChannelOptions($visibleAdminIds, $eligibleDeptIds, $allowedDeptIds),
];
}
/**
* 部门下拉:
* - SCOPE_ALLvisibleAdminIds = null):全部启用部门树
* - 其他:仅展示"可见 admin 所属部门 + 媒体渠道作用域部门"的并集,并保留祖先链以维持树形结构
*
* @return array<int, array<string, mixed>>
*/
private static function buildDepartmentOptions(?array $visibleAdminIds, array $eligibleDeptIds, ?array $allowedDeptIds = null): array
{
$allTree = DeptLogic::getAllData();
if ($visibleAdminIds === null) {
return $allTree;
}
$allowedDeptIds ??= self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds);
if ($allowedDeptIds === []) {
return [];
}
return self::filterDeptTreeByIds($allTree, $allowedDeptIds);
}
/**
* 医生下拉:按 visibleAdminIds 收窄。
*
* @return array<int, array<string, mixed>>
*/
private static function buildDoctorOptions(?array $visibleAdminIds): array
{
$query = Db::name('admin')
->alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->where('ar.role_id', 1)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空,
// 避免和"未传 visibleAdminIds (=SCOPE_ALL)"在调用侧产生混淆。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
return $query
->field(['a.id', 'a.name', 'a.account'])
->order('a.id', 'asc')
->distinct(true)
->select()
->toArray();
}
/**
* 媒体渠道下拉:按可见部门绑定的渠道收窄。
*
* @return array<int, array<string, mixed>>
*/
private static function buildMediaChannelOptions(?array $visibleAdminIds, array $eligibleDeptIds, ?array $allowedDeptIds = null): array
{
$allOptions = MediaChannelService::getOptions();
if ($visibleAdminIds === null) {
return $allOptions;
}
if (!AccountCost::supportsDeptBinding()) {
return $allOptions;
}
$allowedDeptIds ??= self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds);
if ($allowedDeptIds === []) {
return [];
}
$allowedCodes = Db::name('account_cost')
->whereIn('dept_id', $allowedDeptIds)
->where('media_channel_code', '<>', '')
->distinct(true)
->column('media_channel_code');
$allowedCodeSet = array_fill_keys(array_filter(array_map('strval', $allowedCodes)), true);
if ($allowedCodeSet === []) {
return [];
}
return array_values(array_filter($allOptions, static function ($option) use ($allowedCodeSet): bool {
$code = is_array($option) ? (string)($option['code'] ?? '') : '';
return $code !== '' && isset($allowedCodeSet[$code]);
}));
}
/**
* 按 dept_id 集合裁剪部门树,自动保留命中节点的祖先链以保证树形完整。
*
* @param array<int, array<string, mixed>> $tree
* @param int[] $allowedIds
* @return array<int, array<string, mixed>>
*/
private static function filterDeptTreeByIds(array $tree, array $allowedIds): array
{
$allowedSet = array_fill_keys($allowedIds, true);
$walker = function (array $node) use (&$walker, $allowedSet): ?array {
$children = [];
foreach ($node['children'] ?? [] as $child) {
if (!is_array($child)) {
continue;
}
$kept = $walker($child);
if ($kept !== null) {
$children[] = $kept;
}
}
$selfHit = isset($allowedSet[(int)($node['id'] ?? 0)]);
if ($selfHit || $children !== []) {
$node['children'] = $children;
return $node;
}
return null;
};
$result = [];
foreach ($tree as $node) {
$kept = $walker($node);
if ($kept !== null) {
$result[] = $kept;
}
}
return $result;
}
private static function normalizeDimension(string $dimension): string
{
return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept';
}
/**
* @return array{0:int,1:int,2:string,3:string}
*/
private static function resolveTimeRange(array $params): array
{
$today = date('Y-m-d');
$timeType = (string)($params['time_type'] ?? 'today');
switch ($timeType) {
case 'yesterday':
$startDate = date('Y-m-d', strtotime('-1 day'));
$endDate = $startDate;
break;
case 'week':
$startDate = date('Y-m-d', strtotime('-6 days'));
$endDate = $today;
break;
case 'month':
$startDate = date('Y-m-d', strtotime('-29 days'));
$endDate = $today;
break;
case 'custom':
$startDate = trim((string)($params['start_date'] ?? ''));
$endDate = trim((string)($params['end_date'] ?? ''));
if ($startDate === '' || $endDate === '' || strtotime($startDate) === false || strtotime($endDate) === false) {
$startDate = $today;
$endDate = $today;
}
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
break;
case 'today':
default:
$startDate = $today;
$endDate = $today;
break;
}
return [
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
$startDate,
$endDate,
];
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于 entities 加载与虚拟桶可见性
* @return array<int, array<string, mixed>>
*/
private static function loadEntities(string $dimension, array $params, ?array $visibleAdminIds = null): array
{
$entities = match ($dimension) {
'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0, $visibleAdminIds),
'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0, $visibleAdminIds),
default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0),
};
if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) {
self::appendVirtualDeptEntities($entities, $visibleAdminIds);
}
return $entities;
}
/**
* @return array<int, array<string, mixed>>
*/
private static function loadDeptEntities(int $deptId = 0): array
{
$query = Db::name('dept')
->whereNull('delete_time')
->field('id, pid, name, sort');
$rows = $query->order('sort desc, id asc')->select()->toArray();
if ($deptId > 0) {
$allowedIds = self::collectDeptSubtreeIds($rows, $deptId);
$rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)$row['id'], $allowedIds, true)));
}
$entities = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? ''));
$entities[$id]['pid'] = (int)($row['pid'] ?? 0);
$entities[$id]['sort'] = (int)($row['sort'] ?? 0);
}
return $entities;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return int[]
*/
private static function collectDeptSubtreeIds(array $rows, int $rootId): array
{
$childrenByPid = [];
foreach ($rows as $row) {
$pid = (int)($row['pid'] ?? 0);
$id = (int)($row['id'] ?? 0);
$childrenByPid[$pid] ??= [];
$childrenByPid[$pid][] = $id;
}
$queue = [$rootId];
$result = [];
while ($queue !== []) {
$current = array_shift($queue);
if (!$current || isset($result[$current])) {
continue;
}
$result[$current] = $current;
foreach ($childrenByPid[$current] ?? [] as $childId) {
$queue[] = (int)$childId;
}
}
return array_values($result);
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄)
* @return array<int, array<string, mixed>>
*/
private static function loadAdminEntities(int $roleId, int $adminId = 0, ?array $visibleAdminIds = null): array
{
$query = Db::name('admin')
->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', $roleId)
->whereNull('a.delete_time')
->field('a.id, a.name');
if ($adminId > 0) {
$query->where('a.id', $adminId);
}
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
$rows = $query->order('a.id asc')->select()->toArray();
$entities = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? ''));
}
return $entities;
}
/**
* @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
{
return [
'id' => $id,
'name' => $name,
'add_fans_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,
'interview_count' => 0,
'business_order_amount' => 0.0,
'completed_order_amount' => 0.0,
'completed_order_count' => 0,
'account_cost' => 0.0,
'_virtual_bucket' => false,
];
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[]|null $visibleAdminIds 可见 admin 集合;非 null(即非 SCOPE_ALL)时不挂"未绑定后台账号"虚拟桶,
* 因为这些加粉无主,不属于任何 admin 的可见范围。
*/
private static function appendVirtualDeptEntities(array &$entities, ?array $visibleAdminIds = null): void
{
if ($visibleAdminIds === null) {
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge(
self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'),
[
'pid' => 0,
'sort' => -999998,
'_virtual_bucket' => true,
]
);
}
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge(
self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'),
[
'pid' => 0,
'sort' => -999999,
'_virtual_bucket' => true,
]
);
}
/**
* 把"可见 admin 集合"转换成"可见部门集合":取所有 admin 的 admin_dept 并集。
* - $visibleAdminIds === nullSCOPE_ALL)→ 返回 null(下游不收窄)
* - $visibleAdminIds === [] → 返回 [](无可见部门)
*
* @param int[]|null $visibleAdminIds
* @return int[]|null
*/
private static function resolveVisibleDeptIds(?array $visibleAdminIds): ?array
{
if ($visibleAdminIds === null) {
return null;
}
if ($visibleAdminIds === []) {
return [];
}
$deptIds = Db::name('admin_dept')
->whereIn('admin_id', $visibleAdminIds)
->column('dept_id');
$result = [];
foreach ($deptIds as $deptId) {
$deptId = (int)$deptId;
if ($deptId > 0) {
$result[$deptId] = $deptId;
}
}
return array_values($result);
}
/**
* @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')
->order('admin_id', 'asc')
->order('dept_id', 'asc')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
$deptId = (int)($row['dept_id'] ?? 0);
if ($adminId <= 0 || $deptId <= 0) {
continue;
}
$map[$adminId] ??= [];
$map[$adminId][] = $deptId;
}
return $map;
}
/**
* @param int[] $deptIds
* @return int[]
*/
private static function expandDeptIdsWithDescendants(array $deptIds): array
{
if ($deptIds === []) {
return [];
}
$expanded = [];
foreach (array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $deptId): bool => $deptId > 0))) as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $expandedDeptId) {
if ($expandedDeptId > 0) {
$expanded[$expandedDeptId] = $expandedDeptId;
}
}
}
return array_values($expanded);
}
/**
* 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。
*
* @return int[]
*/
private static function loadChannelBoundDeptIds(string $mediaChannelCode): array
{
$mediaChannelCode = trim($mediaChannelCode);
if ($mediaChannelCode === '' || !AccountCost::supportsDeptBinding()) {
return [];
}
$deptIds = Db::name('account_cost')
->where('media_channel_code', $mediaChannelCode)
->where('dept_id', '>', 0)
->distinct(true)
->column('dept_id');
return array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $deptId): bool => $deptId > 0)));
}
/**
* 自媒体渠道选中时,统计范围只允许落在渠道绑定部门及其子部门内。
*
* @param array<int, array<string, mixed>> $entities
* @param array<int, int[]> $adminToDeptIds
* @return array<int, array<string, mixed>>
*/
private static function filterEntitiesByChannelDeptScope(array $entities, string $dimension, array $eligibleDeptIds, array $adminToDeptIds): array
{
if ($eligibleDeptIds === []) {
return [];
}
if ($dimension === 'doctor') {
return $entities;
}
$eligibleDeptMap = array_fill_keys(array_map('intval', $eligibleDeptIds), true);
$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;
}
if ($dimension === 'dept') {
if (isset($eligibleDeptMap[$entityId])) {
$filtered[$key] = $entity;
}
continue;
}
foreach (($adminToDeptIds[$entityId] ?? []) as $deptId) {
if (isset($eligibleDeptMap[(int)$deptId])) {
$filtered[$key] = $entity;
break;
}
}
}
return $filtered;
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds 可见 admin 集合
*/
private static function hydrateFanStats(
array &$entities,
string $dimension,
array $entityIds,
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$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])
->fieldRaw('a.id AS admin_id, COUNT(*) AS add_fans_count')
->group('a.id');
if ($visibleAdminIds !== null) {
$query->whereIn('a.id', $visibleAdminIds);
} elseif ($dimension !== 'dept' && $entityIds !== []) {
$query->whereIn('a.id', $entityIds);
}
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();
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($dimension === 'dept' && $adminId <= 0) {
// -1 桶仅在 SCOPE_ALLvisibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。
if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount;
}
continue;
}
if ($adminId <= 0) {
continue;
}
$hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($dimension === 'dept' && $hitEntityIds === []) {
// admin 已通过 visibleAdminIds 校验但没找到归属部门 → 落入"未分配部门"。
if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) {
continue;
}
if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) {
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount;
}
continue;
}
if ($hitEntityIds === []) {
continue;
}
foreach ($hitEntityIds as $entityId) {
$entities[$entityId]['add_fans_count'] += $addFansCount;
}
}
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/
private static function hydrateAppointmentStats(
array &$entities,
string $dimension,
array $entityIds,
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
string $startDate,
string $endDate,
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group("{$sourceExpr}, a.patient_id");
$query->where(static function (Query $subQuery): void {
$subQuery->whereNull('u.id')
->whereOr(static function (Query $orQuery): void {
$orQuery->whereNull('u.delete_time');
});
});
if ($mediaChannel !== null) {
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
if ($legacyChannelValues !== []) {
$query->whereIn('a.channels', $legacyChannelValues);
} else {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$diagnosisId = (int)($row['diagnosis_id'] ?? 0);
if ($diagnosisId <= 0) {
continue;
}
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
$appointmentCount = (int)($row['appointment_count'] ?? 0);
$interviewCount = (int)($row['interview_count'] ?? 0);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['appointment_total_count'] += $appointmentCount;
$entities[$entityId]['interview_count'] += $interviewCount;
}
}
self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
foreach ($entities as &$entity) {
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
$paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0);
$entity['free_appointment_count'] = max($appointmentTotalCount - $paidAppointmentCount, 0);
}
unset($entity);
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/
private static function hydratePaidAppointmentStats(
array &$entities,
string $dimension,
array $entityIds,
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
$query = Db::name('order')
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.order_type', 1)
->where('o.amount', 5)
->whereNotNull('o.payment_time')
->where('o.payment_time', '<>', '')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
$count = (int)($row['paid_appointment_count'] ?? 0);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['paid_appointment_count'] += $count;
}
}
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/
private static function hydrateOrderAndAmountStats(
array &$entities,
string $dimension,
array $entityIds,
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$completedQuery = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.payment_slip_audit_status', 1)
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw("{$completedSourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount")
->group($completedSourceExpr);
if ($mediaChannel !== null) {
$completedQuery
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($completedQuery, 'q.follow_users', $mediaChannel);
}
$completedRows = $completedQuery->select()->toArray();
foreach ($completedRows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
$orderCount = (int)($row['order_count'] ?? 0);
$amount = round((float)($row['total_amount'] ?? 0), 2);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_count'] += $orderCount;
$entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2);
}
}
$businessSourceExpr = $dimension === 'doctor' ? 'po.creator_id' : 'dg.assistant_id';
$businessQuery = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($businessQuery, 'po');
$businessQuery
->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
->group($businessSourceExpr);
if ($mediaChannel !== null) {
$businessQuery
->leftJoin('order o2', 'o2.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q2', 'q2.external_userid = o2.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($businessQuery, 'q2.follow_users', $mediaChannel);
}
$businessRows = $businessQuery->select()->toArray();
foreach ($businessRows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
$amount = round((float)($row['total_amount'] ?? 0), 2);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['business_order_amount'] = round($entities[$entityId]['business_order_amount'] + $amount, 2);
}
}
}
/**
* 账户消耗:来源于独立维护表 zyt_account_cost。
*
* @param array<int, array<string, mixed>> $entities
* @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄)
* @return array{0: float, 1: int[]}
*/
private static function hydrateAccountCostStats(
array &$entities,
string $startDate,
string $endDate,
string $mediaChannelCode,
?array $visibleDeptIds = null
): array
{
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost')
->where('cost_date', '>=', $startDate)
->where('cost_date', '<=', $endDate);
if ($supportsDeptBinding) {
$query->field('dept_id, amount');
} else {
$query->field('amount');
}
if ($mediaChannelCode !== '') {
$query->where('media_channel_code', $mediaChannelCode);
}
if ($supportsDeptBinding) {
$query->where('dept_id', '>', 0);
// 数据隔离:仅统计可见部门的账户消耗;不支持 dept_binding 时无法按部门收窄,跳过。
if ($visibleDeptIds !== null) {
if ($visibleDeptIds === []) {
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = 0.0;
}
unset($entity);
return [0.0, []];
}
$query->whereIn('dept_id', $visibleDeptIds);
}
}
$rows = $query->select()->toArray();
$totalAmount = 0.0;
$deptIds = [];
foreach ($rows as $row) {
$amount = round((float)($row['amount'] ?? 0), 2);
$totalAmount = round($totalAmount + $amount, 2);
$deptId = $supportsDeptBinding ? (int)($row['dept_id'] ?? 0) : 0;
if ($supportsDeptBinding && $deptId > 0) {
$deptIds[$deptId] = $deptId;
}
}
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = $totalAmount;
}
unset($entity);
return [$totalAmount, array_values($deptIds)];
}
/**
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param int[]|null $visibleAdminIds 可见 admin 集合;非 null 时 admin 不在集合中直接拒绝(不归到任何 entity)
* @return int[]
*
* dept 维度下 admin 跨部门时,仅返回 admin_dept 中第一个落在当前 entityIds 内的部门
* (由 loadAdminDeptMap 排序决定),保证同一笔指标只累加到一个部门,避免重复计数。
*/
private static function mapEntityIds(
string $dimension,
int $adminId,
array $entityIds,
array $adminToDeptIds,
?array $visibleAdminIds = null
): array {
if ($adminId <= 0) {
return [];
}
if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) {
return [];
}
if ($dimension !== 'dept') {
return in_array($adminId, $entityIds, true) ? [$adminId] : [];
}
$deptIds = $adminToDeptIds[$adminId] ?? [];
if ($deptIds === []) {
return [];
}
foreach ($deptIds as $deptId) {
if (in_array((int)$deptId, $entityIds, true)) {
return [(int)$deptId];
}
}
return [];
}
/**
* @param array<int, array<string, mixed>> $entities
* @return array<int, array<string, mixed>>
*/
private static function finalizeRows(
array $entities,
bool $excludeEmpty = false,
?int $allocationAddFansCount = null,
bool $restrictAccountCostByDept = false,
array $eligibleDeptIds = [],
array $adminToDeptIds = [],
string $dimension = 'dept'
): array
{
$rows = [];
$filteredEntities = $excludeEmpty
? array_values(array_filter($entities, static fn (array $entity): bool => self::hasBusinessMetrics($entity)))
: array_values($entities);
$globalAccountCost = self::extractGlobalAccountCost($filteredEntities);
$globalAddFansCount = $allocationAddFansCount ?? self::sumEntityAddFans($filteredEntities);
foreach ($filteredEntities as $entity) {
$paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0);
$freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0);
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
$interviewCount = (int)$entity['interview_count'];
$addFansCount = (int)$entity['add_fans_count'];
$totalOpenCount = (int)$entity['total_open_count'];
$completedOrderCount = (int)$entity['completed_order_count'];
$businessOrderAmount = round((float)$entity['business_order_amount'], 2);
$completedOrderAmount = round((float)$entity['completed_order_amount'], 2);
$isEligibleForAccountCost = self::isAccountCostEligible($dimension, $entity, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds);
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
$entity['paid_appointment_count'] = $paidAppointmentCount;
$entity['free_appointment_count'] = $freeAppointmentCount;
$entity['appointment_total_count'] = $appointmentTotalCount;
$entity['business_order_amount'] = $businessOrderAmount;
$entity['completed_order_amount'] = $completedOrderAmount;
$entity['account_cost'] = $effectiveAccountCost;
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
if ($globalAccountCost > 0) {
$entity['_global_account_cost'] = $globalAccountCost;
}
$rows[] = $entity;
}
usort($rows, static function (array $left, array $right): int {
$order = $right['completed_order_amount'] <=> $left['completed_order_amount'];
if ($order !== 0) {
return $order;
}
$order = $right['completed_order_count'] <=> $left['completed_order_count'];
if ($order !== 0) {
return $order;
}
return $right['add_fans_count'] <=> $left['add_fans_count'];
});
return $rows;
}
/**
* @param array<int, array<string, mixed>> $entities
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: array<int, array<string, mixed>>}
*/
private static function buildDeptTreeRows(
array $entities,
int $selectedDeptId,
int $pageNo,
int $pageSize,
bool $excludeEmpty = false,
?int $allocationAddFansCount = null,
bool $restrictAccountCostByDept = false,
array $eligibleDeptIds = [],
array $adminToDeptIds = []
): array
{
$childrenByPid = [];
foreach ($entities as $entity) {
$pid = (int)($entity['pid'] ?? 0);
$childrenByPid[$pid] ??= [];
$childrenByPid[$pid][] = (int)$entity['id'];
}
foreach ($childrenByPid as &$ids) {
usort($ids, static function (int $left, int $right) use ($entities): int {
$sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0));
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
}
unset($ids);
$buildNode = function (int $deptId) use (&$buildNode, &$entities, $childrenByPid): array {
$node = $entities[$deptId];
$children = [];
foreach ($childrenByPid[$deptId] ?? [] as $childId) {
$childNode = $buildNode((int)$childId);
$children[] = $childNode;
$node['add_fans_count'] += (int)$childNode['add_fans_count'];
$node['total_open_count'] += (int)$childNode['total_open_count'];
$node['unreplied_count'] += (int)$childNode['unreplied_count'];
$node['paid_appointment_count'] += (int)$childNode['paid_appointment_count'];
$node['free_appointment_count'] += (int)$childNode['free_appointment_count'];
$node['appointment_total_count'] += (int)$childNode['appointment_total_count'];
$node['interview_count'] += (int)$childNode['interview_count'];
$node['completed_order_count'] += (int)$childNode['completed_order_count'];
$node['business_order_amount'] = round((float)$node['business_order_amount'] + (float)$childNode['business_order_amount'], 2);
$node['completed_order_amount'] = round((float)$node['completed_order_amount'] + (float)$childNode['completed_order_amount'], 2);
$node['account_cost'] = round((float)$node['account_cost'] + (float)$childNode['account_cost'], 2);
}
$node['children'] = $children;
return $node;
};
$rootIds = [];
foreach ($entities as $entity) {
$pid = (int)($entity['pid'] ?? 0);
if (!isset($entities[$pid])) {
$rootIds[] = (int)$entity['id'];
}
}
usort($rootIds, static function (int $left, int $right) use ($entities): int {
$sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0));
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
$rootRows = array_map($buildNode, $rootIds);
if ($excludeEmpty) {
$rootRows = array_values(array_filter(array_map(
static fn (array $row): ?array => self::pruneDeptNode($row),
$rootRows
)));
}
$realRootRows = array_values(array_filter($rootRows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
$virtualRoot = count($realRootRows) === 1 && !empty($realRootRows[0]['children']) ? $realRootRows[0] : null;
if ($selectedDeptId > 0) {
$selectedNode = self::findDeptNode($rootRows, $selectedDeptId);
if ($selectedNode !== null) {
$allRowsRaw = [$selectedNode];
$chartRowsRaw = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode];
} else {
$allRowsRaw = $rootRows;
$chartRowsRaw = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows);
}
} else {
$allRowsRaw = $rootRows;
$chartRowsRaw = $virtualRoot !== null ? ($virtualRoot['children'] ?? [$virtualRoot]) : ($realRootRows ?: $rootRows);
}
$globalAccountCost = self::extractGlobalAccountCost($allRowsRaw);
$globalAddFansCount = $allocationAddFansCount ?? self::sumNodeAddFans($allRowsRaw);
$allRows = array_map(
static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds),
$allRowsRaw
);
$chartRows = array_map(
static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds),
$chartRowsRaw
);
$offset = ($pageNo - 1) * $pageSize;
return [$allRows, array_slice($allRows, $offset, $pageSize), $chartRows];
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>|null
*/
private static function findDeptNode(array $rows, int $deptId): ?array
{
foreach ($rows as $row) {
if ((int)($row['id'] ?? 0) === $deptId) {
return $row;
}
$children = $row['children'] ?? [];
if (!is_array($children) || $children === []) {
continue;
}
$matched = self::findDeptNode($children, $deptId);
if ($matched !== null) {
return $matched;
}
}
return null;
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>|null
*/
private static function pruneDeptNode(array $node): ?array
{
$children = [];
foreach ($node['children'] ?? [] as $child) {
if (!is_array($child)) {
continue;
}
$pruned = self::pruneDeptNode($child);
if ($pruned !== null) {
$children[] = $pruned;
}
}
$node['children'] = $children;
if ($children !== []) {
return $node;
}
return self::hasBusinessMetrics($node) ? $node : null;
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private static function finalizeDeptNode(
array $node,
float $globalAccountCost,
int $globalAddFansCount,
bool $restrictAccountCostByDept = false,
array $eligibleDeptIds = [],
array $adminToDeptIds = []
): array
{
$node['children'] = array_map(
static fn (array $child): array => self::finalizeDeptNode($child, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds),
$node['children'] ?? []
);
$paidAppointmentCount = (int)($node['paid_appointment_count'] ?? 0);
$freeAppointmentCount = (int)($node['free_appointment_count'] ?? 0);
$appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0);
$interviewCount = (int)$node['interview_count'];
$addFansCount = (int)$node['add_fans_count'];
$totalOpenCount = (int)$node['total_open_count'];
$completedOrderCount = (int)$node['completed_order_count'];
$businessOrderAmount = round((float)$node['business_order_amount'], 2);
$completedOrderAmount = round((float)$node['completed_order_amount'], 2);
$isEligibleForAccountCost = self::isDeptAccountCostEligible((int) ($node['id'] ?? 0), $restrictAccountCostByDept, $eligibleDeptIds);
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
$childrenAccountCost = 0.0;
foreach ($node['children'] as $child) {
$childrenAccountCost = round($childrenAccountCost + (float)($child['account_cost'] ?? 0), 2);
}
$node['paid_appointment_count'] = $paidAppointmentCount;
$node['free_appointment_count'] = $freeAppointmentCount;
$node['appointment_total_count'] = $appointmentTotalCount;
$node['business_order_amount'] = $businessOrderAmount;
$node['completed_order_amount'] = $completedOrderAmount;
$node['account_cost'] = $isEligibleForAccountCost ? $effectiveAccountCost : $childrenAccountCost;
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$node['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
if ($globalAccountCost > 0) {
$node['_global_account_cost'] = $globalAccountCost;
}
unset($node['sort'], $node['pid']);
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。
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离
* @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 $visibleAdminIds = null
): array
{
$assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
$doctorEntities = self::loadAdminEntities(1, 0, $visibleAdminIds);
$assistantIds = array_keys($assistantEntities);
$doctorIds = array_keys($doctorEntities);
if ($assistantIds !== []) {
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
}
if ($doctorIds !== []) {
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
}
// 复用调用方传入的 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;
}
// -1 桶仅在 SCOPE_ALL 下展示,因此非 SCOPE_ALL 时不再查未绑定 admin 的加粉。
if ($visibleAdminIds === null) {
$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,
$visibleAdminIds
);
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 集合
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄)
* @return array<int, array<string, mixed>>
*/
private static function buildLeftoverAdminRowsForUnassigned(
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
array $assignedAdminIds,
?array $visibleAdminIds = null
): 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 ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
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')
->whereNotNull('follow_users')
->where('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>
*/
private static function buildSummary(array $rows): array
{
$summary = [
'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,
'business_order_amount' => 0.0,
'completed_order_amount' => 0.0,
'completed_order_count' => 0,
'account_cost' => 0.0,
];
foreach ($rows as $row) {
$summary['add_fans_count'] += (int)$row['add_fans_count'];
$summary['total_open_count'] += (int)$row['total_open_count'];
$summary['unreplied_count'] += (int)$row['unreplied_count'];
$summary['paid_appointment_count'] += (int)$row['paid_appointment_count'];
$summary['free_appointment_count'] += (int)$row['free_appointment_count'];
$summary['appointment_total_count'] += (int)$row['appointment_total_count'];
$summary['interview_count'] += (int)$row['interview_count'];
$summary['completed_order_count'] += (int)$row['completed_order_count'];
$summary['business_order_amount'] = round($summary['business_order_amount'] + (float)($row['business_order_amount'] ?? 0), 2);
$summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2);
$summary['account_cost'] = round($summary['account_cost'] + (float)($row['account_cost'] ?? 0), 2);
}
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']);
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
$summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']);
$summary['roi'] = self::safeDivideRatio($summary['completed_order_amount'], $summary['account_cost']);
return $summary;
}
/**
* @param array<int, array<string, mixed>> $entities
*/
private static function extractGlobalAccountCost(array $entities): float
{
$globalAccountCost = 0.0;
foreach ($entities as $entity) {
$globalAccountCost = max($globalAccountCost, round((float)($entity['_global_account_cost'] ?? 0), 2));
}
return $globalAccountCost;
}
/**
* @param array<int, array<string, mixed>> $entities
*/
private static function sumEntityAddFans(array $entities): int
{
$total = 0;
foreach ($entities as $entity) {
$total += (int)($entity['add_fans_count'] ?? 0);
}
return $total;
}
/**
* @param array<int, array<string, mixed>> $nodes
*/
private static function sumNodeAddFans(array $nodes): int
{
$total = 0;
foreach ($nodes as $node) {
$total += (int)($node['add_fans_count'] ?? 0);
}
return $total;
}
private static function isAccountCostEligible(string $dimension, array $entity, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds): bool
{
if (!$restrictAccountCostByDept) {
return true;
}
if ($dimension === 'dept') {
return self::isDeptAccountCostEligible((int) ($entity['id'] ?? 0), true, $eligibleDeptIds);
}
$adminId = (int) ($entity['id'] ?? 0);
$deptIds = $adminToDeptIds[$adminId] ?? [];
foreach ($deptIds as $deptId) {
if (in_array((int) $deptId, $eligibleDeptIds, true)) {
return true;
}
}
return false;
}
private static function isDeptAccountCostEligible(int $deptId, bool $restrictAccountCostByDept, array $eligibleDeptIds): bool
{
if (!$restrictAccountCostByDept) {
return true;
}
if ($deptId <= 0) {
return false;
}
return in_array($deptId, $eligibleDeptIds, true);
}
private static function sumEntityAddFansForAccountCost(array $entities, string $dimension, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds): int
{
if (!$restrictAccountCostByDept) {
return self::sumEntityAddFans($entities);
}
$total = 0;
foreach ($entities as $entity) {
if (!self::isAccountCostEligible($dimension, $entity, true, $eligibleDeptIds, $adminToDeptIds)) {
continue;
}
$total += (int) ($entity['add_fans_count'] ?? 0);
}
return $total;
}
private static function allocateAccountCost(float $globalAccountCost, int $globalAddFansCount, int $rowAddFansCount, bool $isEligible = true): float
{
if (!$isEligible || $globalAccountCost <= 0 || $globalAddFansCount <= 0 || $rowAddFansCount <= 0) {
return 0.0;
}
return round(($globalAccountCost / $globalAddFansCount) * $rowAddFansCount, 2);
}
/**
* @param array<string, mixed> $row
*/
private static function hasBusinessMetrics(array $row): bool
{
return (int)($row['add_fans_count'] ?? 0) > 0
|| (int)($row['paid_appointment_count'] ?? 0) > 0
|| (int)($row['free_appointment_count'] ?? 0) > 0
|| (int)($row['appointment_total_count'] ?? 0) > 0
|| (int)($row['interview_count'] ?? 0) > 0
|| (int)($row['completed_order_count'] ?? 0) > 0
|| (float)($row['business_order_amount'] ?? 0) > 0
|| (float)($row['completed_order_amount'] ?? 0) > 0;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function buildCharts(array $rows): array
{
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
$topRows = array_slice($chartableRows, 0, 10);
$ranking = [
'names' => [],
'amounts' => [],
'order_counts' => [],
'fan_counts' => [],
'rois' => [],
];
foreach ($topRows as $row) {
$ranking['names'][] = $row['name'];
$ranking['amounts'][] = $row['completed_order_amount'];
$ranking['order_counts'][] = $row['completed_order_count'];
$ranking['fan_counts'][] = $row['add_fans_count'];
$ranking['rois'][] = $row['roi'];
}
return [
'ranking' => $ranking,
'amount_share' => array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
),
'fan_share' => array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
),
];
}
private static function percent(int $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round(($numerator / $denominator) * 100, 2);
}
private static function safeDivideMoney(float $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round($numerator / $denominator, 2);
}
private static function safeDivideRatio(float $numerator, float $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round($numerator / $denominator, 2);
}
/**
* @param int[]|null $visibleAdminIds
* @param int[] $eligibleDeptIds
* @return int[]
*/
private static function resolveFilterAllowedDeptIds(?array $visibleAdminIds, array $eligibleDeptIds): array
{
if ($visibleAdminIds === null) {
return array_values(array_unique(array_filter(array_map('intval', $eligibleDeptIds), static fn (int $deptId): bool => $deptId > 0)));
}
$allowedDeptIds = [];
if ($visibleAdminIds !== []) {
$deptIds = Db::name('admin_dept')
->whereIn('admin_id', $visibleAdminIds)
->column('dept_id');
foreach ($deptIds as $deptId) {
$deptId = (int)$deptId;
if ($deptId > 0) {
$allowedDeptIds[$deptId] = $deptId;
}
}
}
foreach ($eligibleDeptIds as $deptId) {
$deptId = (int)$deptId;
if ($deptId > 0) {
$allowedDeptIds[$deptId] = $deptId;
}
}
return array_values($allowedDeptIds);
}
}