1
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
|
||||
class ConversionLogic
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function overview(array $params = []): array
|
||||
{
|
||||
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
|
||||
$cacheParams = $params;
|
||||
ksort($cacheParams);
|
||||
$cacheKey = 'conversion_stats_overview_' . md5(json_encode($cacheParams, JSON_UNESCAPED_UNICODE));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
if ($includeFilters) {
|
||||
$cached['extend']['filters'] = self::buildFilterOptions();
|
||||
}
|
||||
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
|
||||
[$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);
|
||||
|
||||
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();
|
||||
}
|
||||
Cache::set($cacheKey, $result, 60);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$adminToDeptIds = self::loadAdminDeptMap();
|
||||
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
|
||||
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate);
|
||||
self::finalizeAppointmentStats($entities);
|
||||
self::hydratePaidAuditAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
|
||||
self::hydrateCompletedOrderStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
|
||||
|
||||
if ($dimension === 'dept') {
|
||||
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
|
||||
$entities,
|
||||
isset($params['dept_id']) ? (int)$params['dept_id'] : 0,
|
||||
$pageNo,
|
||||
$pageSize
|
||||
);
|
||||
|
||||
$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();
|
||||
}
|
||||
Cache::set($cacheKey, $result, 60);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$rows = self::finalizeRows($entities);
|
||||
$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();
|
||||
}
|
||||
Cache::set($cacheKey, $result, 60);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildFilterOptions(): array
|
||||
{
|
||||
$cacheKey = 'conversion_stats_filters';
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$filters = [
|
||||
'departments' => DeptLogic::getAllData(),
|
||||
'assistants' => DiagnosisLogic::getAssistants(),
|
||||
'doctors' => DiagnosisLogic::getDoctors(),
|
||||
];
|
||||
Cache::set($cacheKey, $filters, 600);
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
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 '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
|
||||
{
|
||||
return 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
'completed_order_amount' => 0.0,
|
||||
'completed_order_count' => 0,
|
||||
'account_cost' => 0.0,
|
||||
'_appointment_groups' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 array<int, array<string, mixed>> $entities
|
||||
* @param int[] $entityIds
|
||||
* @param array<int, int[]> $adminToDeptIds
|
||||
*/
|
||||
private static function hydrateFanStats(
|
||||
array &$entities,
|
||||
string $dimension,
|
||||
array $entityIds,
|
||||
array $adminToDeptIds,
|
||||
int $startTimestamp,
|
||||
int $endTimestamp
|
||||
): void {
|
||||
$wxUserIdToAdminIds = self::loadWorkWechatAdminMap();
|
||||
$rows = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->whereRaw(
|
||||
'((external_first_add_time BETWEEN ? AND ?) OR (external_first_add_time = 0 AND create_time BETWEEN ? AND ?))',
|
||||
[$startTimestamp, $endTimestamp, $startTimestamp, $endTimestamp]
|
||||
)
|
||||
->field('follow_admin_ids, follow_users')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$adminIds = self::extractFollowAdminIds($row, $wxUserIdToAdminIds);
|
||||
if ($adminIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($adminIds as $adminId) {
|
||||
foreach (self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds) as $entityId) {
|
||||
$entities[$entityId]['add_fans_count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
private static function loadWorkWechatAdminMap(): array
|
||||
{
|
||||
$rows = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id, work_wechat_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = trim((string)($row['work_wechat_userid'] ?? ''));
|
||||
$adminId = (int)($row['id'] ?? 0);
|
||||
if ($userId === '' || $adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$map[$userId] ??= [];
|
||||
$map[$userId][] = $adminId;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<string, int[]> $wxUserIdToAdminIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function extractFollowAdminIds(array $row, array $wxUserIdToAdminIds): array
|
||||
{
|
||||
$adminIds = json_decode((string)($row['follow_admin_ids'] ?? '[]'), true);
|
||||
if (is_array($adminIds)) {
|
||||
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $id): bool => $id > 0)));
|
||||
} else {
|
||||
$adminIds = [];
|
||||
}
|
||||
|
||||
if ($adminIds !== []) {
|
||||
return $adminIds;
|
||||
}
|
||||
|
||||
$followUsers = json_decode((string)($row['follow_users'] ?? '[]'), true);
|
||||
if (!is_array($followUsers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($followUsers as $followUser) {
|
||||
if (!is_array($followUser)) {
|
||||
continue;
|
||||
}
|
||||
$userId = trim((string)($followUser['userid'] ?? ''));
|
||||
if ($userId === '' || !isset($wxUserIdToAdminIds[$userId])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($wxUserIdToAdminIds[$userId] as $adminId) {
|
||||
$resolved[$adminId] = $adminId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
* @param int[] $entityIds
|
||||
* @param array<int, int[]> $adminToDeptIds
|
||||
*/
|
||||
private static function hydrateAppointmentStats(
|
||||
array &$entities,
|
||||
string $dimension,
|
||||
array $entityIds,
|
||||
array $adminToDeptIds,
|
||||
string $startDate,
|
||||
string $endDate
|
||||
): void {
|
||||
$rows = Db::name('doctor_appointment')
|
||||
->where('appointment_date', '>=', $startDate)
|
||||
->where('appointment_date', '<=', $endDate)
|
||||
->field('patient_id, doctor_id, assistant_id, status')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$diagnosisId = (int)($row['patient_id'] ?? 0);
|
||||
if ($diagnosisId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceAdminId = $dimension === 'doctor'
|
||||
? (int)($row['doctor_id'] ?? 0)
|
||||
: (int)($row['assistant_id'] ?? 0);
|
||||
|
||||
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
|
||||
if ($mappedEntityIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($mappedEntityIds as $entityId) {
|
||||
$entities[$entityId]['appointment_total_count']++;
|
||||
if (!isset($entities[$entityId]['_appointment_groups'][$diagnosisId])) {
|
||||
$entities[$entityId]['_appointment_groups'][$diagnosisId] = [
|
||||
'count' => 0,
|
||||
'has_completed' => false,
|
||||
];
|
||||
}
|
||||
$entities[$entityId]['_appointment_groups'][$diagnosisId]['count']++;
|
||||
if ((int)($row['status'] ?? 0) === 3) {
|
||||
$entities[$entityId]['_appointment_groups'][$diagnosisId]['has_completed'] = true;
|
||||
$entities[$entityId]['interview_count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
*/
|
||||
private static function finalizeAppointmentStats(array &$entities): void
|
||||
{
|
||||
foreach ($entities as &$entity) {
|
||||
$groups = $entity['_appointment_groups'] ?? [];
|
||||
$paid = 0;
|
||||
$free = 0;
|
||||
foreach ($groups as $group) {
|
||||
$count = (int)($group['count'] ?? 0);
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$paid += 1;
|
||||
// 与问诊列表总数口径对齐:
|
||||
// 同一诊单第一条记为付费挂号,其余挂号记录都记为免费挂号,
|
||||
// 否则“重复挂号但未完成”的记录会被漏掉,导致付费+免费 < 挂号总数。
|
||||
if ($count > 1) {
|
||||
$free += ($count - 1);
|
||||
}
|
||||
}
|
||||
$entity['paid_appointment_count'] = $paid;
|
||||
$entity['free_appointment_count'] = $free;
|
||||
unset($entity['_appointment_groups']);
|
||||
}
|
||||
unset($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
* @param int[] $entityIds
|
||||
* @param array<int, int[]> $adminToDeptIds
|
||||
*/
|
||||
private static function hydrateCompletedOrderStats(
|
||||
array &$entities,
|
||||
string $dimension,
|
||||
array $entityIds,
|
||||
array $adminToDeptIds,
|
||||
int $startTimestamp,
|
||||
int $endTimestamp
|
||||
): void {
|
||||
$rows = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.fulfillment_status', 3)
|
||||
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->field('po.amount, po.internal_cost, rx.assistant_id, rx.creator_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sourceAdminId = $dimension === 'doctor'
|
||||
? (int)($row['creator_id'] ?? 0)
|
||||
: (int)($row['assistant_id'] ?? 0);
|
||||
|
||||
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
|
||||
if ($mappedEntityIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$internalCost = round((float)($row['internal_cost'] ?? 0), 2);
|
||||
|
||||
foreach ($mappedEntityIds as $entityId) {
|
||||
$entities[$entityId]['completed_order_count']++;
|
||||
$entities[$entityId]['account_cost'] = round($entities[$entityId]['account_cost'] + $internalCost, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单金额:按支付单审核通过(payment_slip_audit_status=1)的业务订单 amount 汇总
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
* @param int[] $entityIds
|
||||
* @param array<int, int[]> $adminToDeptIds
|
||||
*/
|
||||
private static function hydratePaidAuditAmountStats(
|
||||
array &$entities,
|
||||
string $dimension,
|
||||
array $entityIds,
|
||||
array $adminToDeptIds,
|
||||
int $startTimestamp,
|
||||
int $endTimestamp
|
||||
): void {
|
||||
$rows = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.payment_slip_audit_status', 1)
|
||||
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->field('po.amount, rx.assistant_id, rx.creator_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sourceAdminId = $dimension === 'doctor'
|
||||
? (int)($row['creator_id'] ?? 0)
|
||||
: (int)($row['assistant_id'] ?? 0);
|
||||
|
||||
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
|
||||
if ($mappedEntityIds === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = round((float)($row['amount'] ?? 0), 2);
|
||||
foreach ($mappedEntityIds as $entityId) {
|
||||
$entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($entities 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'];
|
||||
$completedOrderAmount = round((float)$entity['completed_order_amount'], 2);
|
||||
$accountCost = round((float)$entity['account_cost'], 2);
|
||||
|
||||
$entity['paid_appointment_count'] = $paidAppointmentCount;
|
||||
$entity['free_appointment_count'] = $freeAppointmentCount;
|
||||
$entity['appointment_total_count'] = $appointmentTotalCount;
|
||||
$entity['completed_order_amount'] = $completedOrderAmount;
|
||||
$entity['account_cost'] = $accountCost;
|
||||
$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($accountCost, $addFansCount);
|
||||
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost);
|
||||
|
||||
$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): 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['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(static fn (int $deptId): array => self::finalizeDeptNode($buildNode($deptId)), $rootIds);
|
||||
$virtualRoot = count($rootRows) === 1 && !empty($rootRows[0]['children']) ? $rootRows[0] : null;
|
||||
|
||||
if ($selectedDeptId > 0) {
|
||||
$selectedNode = self::findDeptNode($rootRows, $selectedDeptId);
|
||||
if ($selectedNode !== null) {
|
||||
if ($virtualRoot !== null && (int)$selectedNode['id'] === (int)$virtualRoot['id']) {
|
||||
$allRows = $selectedNode['children'] ?? [];
|
||||
$chartRows = $selectedNode['children'] ?? [];
|
||||
} else {
|
||||
$allRows = [$selectedNode];
|
||||
$chartRows = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode];
|
||||
}
|
||||
} else {
|
||||
$allRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? []) : $rootRows;
|
||||
$chartRows = $allRows;
|
||||
}
|
||||
} else {
|
||||
$allRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? []) : $rootRows;
|
||||
$chartRows = $allRows;
|
||||
}
|
||||
|
||||
$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>
|
||||
*/
|
||||
private static function finalizeDeptNode(array $node): array
|
||||
{
|
||||
$node['children'] = array_map(static fn (array $child): array => self::finalizeDeptNode($child), $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'];
|
||||
$completedOrderAmount = round((float)$node['completed_order_amount'], 2);
|
||||
$accountCost = round((float)$node['account_cost'], 2);
|
||||
|
||||
$node['paid_appointment_count'] = $paidAppointmentCount;
|
||||
$node['free_appointment_count'] = $freeAppointmentCount;
|
||||
$node['appointment_total_count'] = $appointmentTotalCount;
|
||||
$node['completed_order_amount'] = $completedOrderAmount;
|
||||
$node['account_cost'] = $accountCost;
|
||||
$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($accountCost, $addFansCount);
|
||||
$node['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost);
|
||||
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,
|
||||
'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['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'], 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>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildCharts(array $rows): array
|
||||
{
|
||||
$topRows = array_slice($rows, 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user