1361 lines
53 KiB
PHP
1361 lines
53 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\adminapi\logic\stats;
|
|
|
|
use app\adminapi\logic\dept\DeptLogic;
|
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
|
use app\common\model\finance\AccountCost;
|
|
use app\common\service\qywx\MediaChannelService;
|
|
use think\facade\Db;
|
|
|
|
class ConversionLogic
|
|
{
|
|
private const VIRTUAL_DEPT_UNBOUND_ADMIN_ID = -1;
|
|
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function overview(array $params = []): 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)));
|
|
|
|
$entities = self::loadEntities($dimension, $params);
|
|
$entityIds = array_keys($entities);
|
|
$allocationEntities = self::loadEntities($dimension, $params);
|
|
$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();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
$adminToDeptIds = self::loadAdminDeptMap();
|
|
self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
|
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
|
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
|
|
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
|
|
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode);
|
|
$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();
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
$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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function buildFilterOptions(): array
|
|
{
|
|
return [
|
|
'departments' => DeptLogic::getAllData(),
|
|
'assistants' => DiagnosisLogic::getAssistants(),
|
|
'doctors' => DiagnosisLogic::getDoctors(),
|
|
'media_channels' => MediaChannelService::getOptions(),
|
|
];
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private static function loadEntities(string $dimension, array $params): array
|
|
{
|
|
$entities = match ($dimension) {
|
|
'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0),
|
|
'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0),
|
|
default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0),
|
|
};
|
|
|
|
if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) {
|
|
self::appendVirtualDeptEntities($entities);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private static function loadAdminEntities(int $roleId, int $adminId = 0): 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);
|
|
}
|
|
|
|
$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>
|
|
*/
|
|
private static function newEntityRow(int $id, string $name): array
|
|
{
|
|
return [
|
|
'id' => $id,
|
|
'name' => $name,
|
|
'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,
|
|
'_virtual_bucket' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $entities
|
|
*/
|
|
private static function appendVirtualDeptEntities(array &$entities): void
|
|
{
|
|
$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,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int[]>
|
|
*/
|
|
private static function loadAdminDeptMap(): array
|
|
{
|
|
$rows = Db::name('admin_dept')->field('admin_id, dept_id')->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 [];
|
|
}
|
|
|
|
$eligibleDeptMap = array_fill_keys(array_map('intval', $eligibleDeptIds), true);
|
|
$filtered = [];
|
|
foreach ($entities as $key => $entity) {
|
|
$entityId = (int)($entity['id'] ?? 0);
|
|
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
|
|
*/
|
|
private static function hydrateFanStats(
|
|
array &$entities,
|
|
string $dimension,
|
|
array $entityIds,
|
|
array $adminToDeptIds,
|
|
int $startTimestamp,
|
|
int $endTimestamp,
|
|
?array $mediaChannel
|
|
): 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('e.user_id, a.id AS admin_id, COUNT(*) AS add_fans_count')
|
|
->group('e.user_id, a.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();
|
|
|
|
foreach ($rows as $row) {
|
|
$adminId = (int)($row['admin_id'] ?? 0);
|
|
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
|
|
|
if ($dimension === 'dept' && $adminId <= 0) {
|
|
if (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);
|
|
if ($dimension === 'dept' && $hitEntityIds === []) {
|
|
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
|
|
*/
|
|
private static function hydrateAppointmentStats(
|
|
array &$entities,
|
|
string $dimension,
|
|
array $entityIds,
|
|
array $adminToDeptIds,
|
|
int $startTimestamp,
|
|
int $endTimestamp,
|
|
string $startDate,
|
|
string $endDate,
|
|
?array $mediaChannel
|
|
): 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)
|
|
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
|
|
->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");
|
|
|
|
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);
|
|
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);
|
|
|
|
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
|
|
*/
|
|
private static function hydratePaidAppointmentStats(
|
|
array &$entities,
|
|
string $dimension,
|
|
array $entityIds,
|
|
array $adminToDeptIds,
|
|
int $startTimestamp,
|
|
int $endTimestamp,
|
|
?array $mediaChannel
|
|
): 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)
|
|
->whereRaw('(o.payment_time IS NOT NULL AND o.payment_time <> "" AND o.payment_time >= ? AND 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);
|
|
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
|
|
*/
|
|
private static function hydrateOrderAndAmountStats(
|
|
array &$entities,
|
|
string $dimension,
|
|
array $entityIds,
|
|
array $adminToDeptIds,
|
|
int $startTimestamp,
|
|
int $endTimestamp,
|
|
?array $mediaChannel
|
|
): 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);
|
|
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])
|
|
->where('po.fulfillment_status', '<>', 4)
|
|
->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);
|
|
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
|
|
* @return array{0: float, 1: int[]}
|
|
*/
|
|
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate, string $mediaChannelCode): 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);
|
|
}
|
|
|
|
$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
|
|
* @return int[]
|
|
*/
|
|
private static function mapEntityIds(string $dimension, int $adminId, array $entityIds, array $adminToDeptIds): array
|
|
{
|
|
if ($adminId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
if ($dimension !== 'dept') {
|
|
return in_array($adminId, $entityIds, true) ? [$adminId] : [];
|
|
}
|
|
|
|
$deptIds = $adminToDeptIds[$adminId] ?? [];
|
|
if ($deptIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter($deptIds, static fn (int $deptId): bool => in_array($deptId, $entityIds, true)));
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|