2931 lines
121 KiB
PHP
Executable File
2931 lines
121 KiB
PHP
Executable File
<?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;
|
||
|
||
/**
|
||
* Per-overview raw aggregate cache. It is reset at the beginning of every
|
||
* overview call so long-running workers never reuse stale business data.
|
||
*
|
||
* @var array<string, array<int, array<string, mixed>>>
|
||
*/
|
||
private static array $requestRowsCache = [];
|
||
|
||
/**
|
||
* @param array $params
|
||
* @param int $adminId 当前操作 admin(来自 BaseAdminController)
|
||
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
|
||
* @param int[]|null $trustedVisibleAdminIdsOverride 仅供服务端内部可信调用覆盖本次可见管理员;不从 HTTP 参数读取
|
||
* @param int[]|null $trustedCostAllocationAdminIdsOverride 仅用于成本按加粉占比分摊的分母,不会放大任何业务指标
|
||
* @param array<string,mixed>|null $trustedMediaChannelOverride 仅供服务端内部传入已校验渠道,避免再次按全局历史渠道口径解析
|
||
* @return array<string, mixed>
|
||
*
|
||
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。
|
||
* - null:SCOPE_ALL,全数据放行(与历史行为一致)
|
||
* - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据
|
||
* - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项
|
||
*/
|
||
public static function overview(
|
||
array $params = [],
|
||
int $adminId = 0,
|
||
?array $adminInfo = null,
|
||
?array $trustedVisibleAdminIdsOverride = null,
|
||
?array $trustedCostAllocationAdminIdsOverride = null,
|
||
?array $trustedMediaChannelOverride = null
|
||
): array
|
||
{
|
||
self::$requestRowsCache = [];
|
||
|
||
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
|
||
// 仅供需要“有效挂号”口径的内部看板调用;默认保持转换统计历史口径不变。
|
||
$excludeCancelledAppointments = (int)($params['exclude_cancelled_appointments'] ?? 0) === 1;
|
||
// 一诊综合转化复用处方订单页的业绩口径;其它调用方继续保留历史“双审完成单”口径。
|
||
$usePerformanceOrderMetrics = strtolower(trim((string)($params['order_metric_mode'] ?? ''))) === 'performance';
|
||
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
|
||
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
|
||
$mediaChannel = $trustedMediaChannelOverride;
|
||
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
|
||
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
|
||
}
|
||
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
|
||
$filterEmptyEntities = $mediaChannel !== null;
|
||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||
$pageNo = max(1, (int)($params['page_no'] ?? 1));
|
||
if ($trustedVisibleAdminIdsOverride !== null) {
|
||
$trustedVisibleAdminIdsOverride = array_values(array_unique(array_filter(
|
||
array_map('intval', $trustedVisibleAdminIdsOverride),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
}
|
||
if ($trustedCostAllocationAdminIdsOverride !== null) {
|
||
$trustedCostAllocationAdminIdsOverride = array_values(array_unique(array_filter(
|
||
array_map('intval', $trustedCostAllocationAdminIdsOverride),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
}
|
||
$pageSizeLimit = $trustedVisibleAdminIdsOverride !== null
|
||
? max(100, count($trustedVisibleAdminIdsOverride))
|
||
: 100;
|
||
$pageSize = max(1, min($pageSizeLimit, (int)($params['page_size'] ?? 15)));
|
||
|
||
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
|
||
if ($trustedVisibleAdminIdsOverride === null) {
|
||
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
|
||
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
|
||
: null;
|
||
}
|
||
// 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。
|
||
if ($visibleAdminIds === []) {
|
||
$emptyResult = [
|
||
'dimension' => $dimension,
|
||
'date_range' => [$startDate, $endDate],
|
||
'summary' => self::buildSummary([], $dimension),
|
||
'charts' => self::buildCharts([], $dimension),
|
||
'lists' => [],
|
||
'count' => 0,
|
||
'page_no' => $pageNo,
|
||
'page_size' => $pageSize,
|
||
'extend' => [
|
||
'dimension' => $dimension,
|
||
'date_range' => [$startDate, $endDate],
|
||
'summary' => self::buildSummary([], $dimension),
|
||
'charts' => self::buildCharts([], $dimension),
|
||
],
|
||
];
|
||
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([], $dimension),
|
||
'charts' => self::buildCharts([], $dimension),
|
||
'lists' => [],
|
||
'count' => 0,
|
||
'page_no' => $pageNo,
|
||
'page_size' => $pageSize,
|
||
'extend' => [
|
||
'dimension' => $dimension,
|
||
'date_range' => [$startDate, $endDate],
|
||
'summary' => self::buildSummary([], $dimension),
|
||
'charts' => self::buildCharts([], $dimension),
|
||
],
|
||
];
|
||
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;
|
||
if ($trustedCostAllocationAdminIdsOverride !== null) {
|
||
// 个人口径下,仍以同部门全员加粉作为成本分摊分母,避免将整个部门成本全部计到一个人。
|
||
$allocationEntities = self::loadEntities($dimension, $params, $trustedCostAllocationAdminIdsOverride);
|
||
// 分摊只能使用实际响应中已允许的部门,防止同事的多部门绑定扩大成本范围。
|
||
$allocationEntities = array_intersect_key($allocationEntities, $entities);
|
||
$allocationEntityIds = array_keys($allocationEntities);
|
||
self::hydrateFanStats(
|
||
$allocationEntities,
|
||
$dimension,
|
||
$allocationEntityIds,
|
||
$adminToDeptIds,
|
||
$startTimestamp,
|
||
$endTimestamp,
|
||
$mediaChannel,
|
||
$trustedCostAllocationAdminIdsOverride
|
||
);
|
||
}
|
||
self::hydrateAppointmentStats(
|
||
$entities,
|
||
$dimension,
|
||
$entityIds,
|
||
$adminToDeptIds,
|
||
$startTimestamp,
|
||
$endTimestamp,
|
||
$startDate,
|
||
$endDate,
|
||
$mediaChannel,
|
||
$visibleAdminIds,
|
||
$excludeCancelledAppointments,
|
||
$usePerformanceOrderMetrics
|
||
);
|
||
self::hydrateOrderAndAmountStats(
|
||
$entities,
|
||
$dimension,
|
||
$entityIds,
|
||
$adminToDeptIds,
|
||
$startTimestamp,
|
||
$endTimestamp,
|
||
$mediaChannel,
|
||
$visibleAdminIds,
|
||
$usePerformanceOrderMetrics
|
||
);
|
||
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
|
||
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
|
||
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
|
||
$supportsDeptBinding = AccountCost::supportsDeptBinding();
|
||
$restrictAccountCostByDept = $supportsDeptBinding;
|
||
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
|
||
? self::loadChannelBoundDeptIds($mediaChannelCode)
|
||
: [];
|
||
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
|
||
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
|
||
$restrictStatsByDept = $channelBoundDeptIds !== [];
|
||
$scopeDeptIds = $restrictStatsByDept
|
||
? $channelBoundDeptIds
|
||
: $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([], $dimension),
|
||
'charts' => self::buildCharts([], $dimension),
|
||
'lists' => [],
|
||
'count' => 0,
|
||
'page_no' => $pageNo,
|
||
'page_size' => $pageSize,
|
||
'extend' => [
|
||
'dimension' => $dimension,
|
||
'date_range' => [$startDate, $endDate],
|
||
'summary' => self::buildSummary([], $dimension),
|
||
'charts' => self::buildCharts([], $dimension),
|
||
],
|
||
];
|
||
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,
|
||
$excludeCancelledAppointments,
|
||
$usePerformanceOrderMetrics
|
||
);
|
||
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
|
||
}
|
||
|
||
$result = [
|
||
'dimension' => $dimension,
|
||
'date_range' => [$startDate, $endDate],
|
||
'summary' => self::buildSummary($allRows, $dimension),
|
||
'charts' => self::buildCharts($chartRows, $dimension),
|
||
'lists' => $pagedRows,
|
||
'count' => count($allRows),
|
||
'page_no' => $pageNo,
|
||
'page_size' => $pageSize,
|
||
'extend' => [
|
||
'dimension' => $dimension,
|
||
'date_range' => [$startDate, $endDate],
|
||
'summary' => self::buildSummary($allRows, $dimension),
|
||
'charts' => self::buildCharts($chartRows, $dimension),
|
||
],
|
||
];
|
||
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, $dimension),
|
||
'charts' => self::buildCharts($rows, $dimension),
|
||
'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, $dimension),
|
||
'charts' => self::buildCharts($rows, $dimension),
|
||
],
|
||
];
|
||
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(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 部门下拉:
|
||
* - SCOPE_ALL(visibleAdminIds = 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();
|
||
}
|
||
|
||
/**
|
||
* 媒体渠道下拉:返回全局启用渠道;事实数据仍由 DataScope 单独约束。
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function buildMediaChannelOptions(): array
|
||
{
|
||
// Channels are dimension values, while account_cost rows are optional
|
||
// facts. Using cost bindings as an option whitelist hid valid labels and
|
||
// made the dropdown change when department/assistant filters changed.
|
||
return MediaChannelService::getOptions();
|
||
}
|
||
|
||
/**
|
||
* 按 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, leader');
|
||
|
||
$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);
|
||
// 明细列表「组」后展示组长归属;优先规范化姓名(去职务后缀),空则回退原文
|
||
$rawLeader = trim((string)($row['leader'] ?? ''));
|
||
$normLeader = $rawLeader !== '' ? self::normalizeLeaderName($rawLeader) : '';
|
||
$entities[$id]['leader_name'] = $normLeader !== '' ? $normLeader : $rawLeader;
|
||
}
|
||
|
||
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 === null(SCOPE_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 跨部门时,下游 mapEntityIds 只取列表中第一个落在当前 entityIds 内的部门,
|
||
* 避免同一笔加粉/挂号/接诊被多次累加到不同部门。
|
||
*/
|
||
private static function loadAdminDeptMap(): array
|
||
{
|
||
$rows = Db::name('admin_dept')
|
||
->field('admin_id, dept_id')
|
||
->select()
|
||
->toArray();
|
||
$deptRows = Db::name('dept')
|
||
->whereNull('delete_time')
|
||
->field('id, pid, sort')
|
||
->select()
|
||
->toArray();
|
||
$deptById = [];
|
||
foreach ($deptRows as $deptRow) {
|
||
$deptId = (int)($deptRow['id'] ?? 0);
|
||
if ($deptId > 0) {
|
||
$deptById[$deptId] = [
|
||
'pid' => (int)($deptRow['pid'] ?? 0),
|
||
'sort' => (int)($deptRow['sort'] ?? 0),
|
||
];
|
||
}
|
||
}
|
||
$depthCache = [];
|
||
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptById): int {
|
||
if ($deptId <= 0 || !isset($deptById[$deptId])) {
|
||
return 0;
|
||
}
|
||
if (isset($depthCache[$deptId])) {
|
||
return $depthCache[$deptId];
|
||
}
|
||
$parentId = (int)($deptById[$deptId]['pid'] ?? 0);
|
||
if ($parentId <= 0 || $parentId === $deptId || !isset($deptById[$parentId])) {
|
||
return $depthCache[$deptId] = 0;
|
||
}
|
||
|
||
return $depthCache[$deptId] = $depthOf($parentId) + 1;
|
||
};
|
||
$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;
|
||
}
|
||
foreach ($map as &$deptIds) {
|
||
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptById): int {
|
||
$depthCompare = $depthOf($right) <=> $depthOf($left);
|
||
if ($depthCompare !== 0) {
|
||
return $depthCompare;
|
||
}
|
||
$sortCompare = (int)($deptById[$right]['sort'] ?? 0) <=> (int)($deptById[$left]['sort'] ?? 0);
|
||
if ($sortCompare !== 0) {
|
||
return $sortCompare;
|
||
}
|
||
|
||
return $left <=> $right;
|
||
});
|
||
}
|
||
unset($deptIds);
|
||
|
||
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 {
|
||
$queryAdminIds = $visibleAdminIds;
|
||
if ($queryAdminIds === null && $dimension !== 'dept') {
|
||
$queryAdminIds = $entityIds;
|
||
}
|
||
$rows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $queryAdminIds);
|
||
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($rows, 'user_id'));
|
||
|
||
foreach ($rows as $row) {
|
||
$userId = (string)($row['user_id'] ?? '');
|
||
$adminId = (int)($adminByUserId[$userId]['id'] ?? 0);
|
||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||
|
||
if ($dimension === 'dept' && $adminId <= 0) {
|
||
// -1 桶仅在 SCOPE_ALL(visibleAdminIds === 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||
*
|
||
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
|
||
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
||
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
|
||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
|
||
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
|
||
*
|
||
* @param array<string, mixed>|null $mediaChannel
|
||
* @param int[]|null $adminIds null means all active/unbound WeCom users
|
||
* @return array<int, array{user_id: string, add_fans_count: int|string}>
|
||
*/
|
||
private static function loadFanRows(
|
||
int $startTimestamp,
|
||
int $endTimestamp,
|
||
?array $mediaChannel,
|
||
?array $adminIds = null
|
||
): array {
|
||
if ($adminIds !== null) {
|
||
$adminIds = array_values(array_unique(array_filter(
|
||
array_map('intval', $adminIds),
|
||
static fn (int $id): bool => $id > 0
|
||
)));
|
||
sort($adminIds);
|
||
if ($adminIds === []) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
$baseKey = self::requestRowsCacheKey('fans-effective-v3', [
|
||
$startTimestamp,
|
||
$endTimestamp,
|
||
self::mediaChannelCacheKey($mediaChannel),
|
||
]);
|
||
$allKey = $baseKey . ':all';
|
||
$cacheKey = $adminIds === null
|
||
? $allKey
|
||
: $baseKey . ':admins:' . implode(',', $adminIds);
|
||
if (isset(self::$requestRowsCache[$cacheKey])) {
|
||
return self::$requestRowsCache[$cacheKey];
|
||
}
|
||
|
||
$workWechatUserIds = null;
|
||
if ($adminIds !== null) {
|
||
$workWechatUserIds = Db::name('admin')
|
||
->whereIn('id', $adminIds)
|
||
->whereNull('delete_time')
|
||
->where('work_wechat_userid', '<>', '')
|
||
->column('work_wechat_userid');
|
||
$workWechatUserIds = array_values(array_unique(array_filter(array_map('strval', $workWechatUserIds))));
|
||
if ($workWechatUserIds === []) {
|
||
self::$requestRowsCache[$cacheKey] = [];
|
||
|
||
return [];
|
||
}
|
||
|
||
if (isset(self::$requestRowsCache[$allKey])) {
|
||
$allowed = array_fill_keys($workWechatUserIds, true);
|
||
self::$requestRowsCache[$cacheKey] = array_values(array_filter(
|
||
self::$requestRowsCache[$allKey],
|
||
static fn (array $row): bool => isset($allowed[(string)($row['user_id'] ?? '')])
|
||
));
|
||
|
||
return self::$requestRowsCache[$cacheKey];
|
||
}
|
||
}
|
||
|
||
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||
$query = Db::name('qywx_external_contact_event')
|
||
->alias('e')
|
||
->where('e.change_type', 'add_external_contact')
|
||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||
->where('e.user_id', '<>', '')
|
||
->where('e.external_userid', '<>', '')
|
||
->whereRaw(
|
||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
|
||
. ' WHERE audit_e.user_id = e.user_id'
|
||
. ' AND audit_e.external_userid = e.external_userid'
|
||
. ' AND audit_e.change_type = ?'
|
||
. ' AND audit_e.event_time >= e.event_time'
|
||
. ' AND audit_e.event_time <= ?)',
|
||
['msg_audit_approved', $endTimestamp]
|
||
)
|
||
->whereRaw(
|
||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
|
||
. ' WHERE del_e.user_id = e.user_id'
|
||
. ' AND del_e.external_userid = e.external_userid'
|
||
. ' AND del_e.change_type = ?'
|
||
. ' AND del_e.event_time >= e.event_time'
|
||
. ' AND del_e.event_time <= ?)',
|
||
['del_external_contact', $endTimestamp]
|
||
)
|
||
->field(['e.user_id', 'e.external_userid'])
|
||
->group('e.user_id, e.external_userid');
|
||
if ($workWechatUserIds !== null) {
|
||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||
}
|
||
if ($mediaChannel !== null) {
|
||
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
|
||
}
|
||
|
||
$pairs = $query->select()->toArray();
|
||
$pairs = self::excludeInheritedFanPairs($pairs);
|
||
|
||
$countsByUser = [];
|
||
foreach ($pairs as $pair) {
|
||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||
if ($userId === '') {
|
||
continue;
|
||
}
|
||
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
|
||
}
|
||
|
||
$rows = [];
|
||
foreach ($countsByUser as $userId => $count) {
|
||
$rows[] = [
|
||
'user_id' => $userId,
|
||
'add_fans_count' => $count,
|
||
];
|
||
}
|
||
|
||
self::$requestRowsCache[$cacheKey] = $rows;
|
||
|
||
return self::$requestRowsCache[$cacheKey];
|
||
}
|
||
|
||
/**
|
||
* 剔除企微「继承/分配」客户:跟进人 add_way 为 201(内部成员共享)或 202(管理员/负责人分配,含在职/离职继承)。
|
||
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
|
||
*
|
||
* @param array<int, array<string, mixed>> $pairs
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function excludeInheritedFanPairs(array $pairs): array
|
||
{
|
||
if ($pairs === []) {
|
||
return [];
|
||
}
|
||
|
||
$externalUserIds = [];
|
||
foreach ($pairs as $pair) {
|
||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||
if ($extId !== '') {
|
||
$externalUserIds[$extId] = true;
|
||
}
|
||
}
|
||
$externalIdList = array_keys($externalUserIds);
|
||
if ($externalIdList === []) {
|
||
return $pairs;
|
||
}
|
||
|
||
/** @var array<string, true> $inheritedKeys user_id\0external_userid */
|
||
$inheritedKeys = [];
|
||
foreach (array_chunk($externalIdList, 500) as $chunk) {
|
||
$contactRows = Db::name('qywx_external_contact')
|
||
->whereIn('external_userid', $chunk)
|
||
->field(['external_userid', 'follow_users'])
|
||
->select()
|
||
->toArray();
|
||
foreach ($contactRows as $contact) {
|
||
$extId = trim((string) ($contact['external_userid'] ?? ''));
|
||
if ($extId === '') {
|
||
continue;
|
||
}
|
||
$followUsers = $contact['follow_users'] ?? null;
|
||
if (\is_string($followUsers) && $followUsers !== '') {
|
||
$decoded = json_decode($followUsers, true);
|
||
$followUsers = \is_array($decoded) ? $decoded : [];
|
||
}
|
||
if (!\is_array($followUsers)) {
|
||
continue;
|
||
}
|
||
foreach ($followUsers as $fu) {
|
||
if (!\is_array($fu)) {
|
||
continue;
|
||
}
|
||
$addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0);
|
||
if ($addWay !== 201 && $addWay !== 202) {
|
||
continue;
|
||
}
|
||
$followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? ''));
|
||
if ($followUserId === '') {
|
||
continue;
|
||
}
|
||
$inheritedKeys[$followUserId . "\0" . $extId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($inheritedKeys === []) {
|
||
return $pairs;
|
||
}
|
||
|
||
$kept = [];
|
||
foreach ($pairs as $pair) {
|
||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||
if ($userId === '' || $extId === '') {
|
||
continue;
|
||
}
|
||
if (isset($inheritedKeys[$userId . "\0" . $extId])) {
|
||
continue;
|
||
}
|
||
$kept[] = $pair;
|
||
}
|
||
|
||
return $kept;
|
||
}
|
||
|
||
/**
|
||
* @param string[] $userIds
|
||
* @return array<string, array{id: int|string, name: string}>
|
||
*/
|
||
private static function loadActiveAdminByWorkWechatUserIds(array $userIds): array
|
||
{
|
||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||
sort($userIds);
|
||
if ($userIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$cacheKey = self::requestRowsCacheKey('active-admin-by-wecom-user', $userIds);
|
||
if (!isset(self::$requestRowsCache[$cacheKey])) {
|
||
self::$requestRowsCache[$cacheKey] = Db::name('admin')
|
||
->whereIn('work_wechat_userid', $userIds)
|
||
->whereNull('delete_time')
|
||
->field('id, name, work_wechat_userid')
|
||
->select()
|
||
->toArray();
|
||
}
|
||
|
||
$result = [];
|
||
foreach (self::$requestRowsCache[$cacheKey] as $row) {
|
||
$userId = (string)($row['work_wechat_userid'] ?? '');
|
||
if ($userId !== '' && !isset($result[$userId])) {
|
||
$result[$userId] = [
|
||
'id' => (int)($row['id'] ?? 0),
|
||
'name' => (string)($row['name'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* @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,
|
||
bool $excludeCancelledAppointments = false,
|
||
bool $useRegistrationMetric = false
|
||
): void {
|
||
$sourceType = $dimension === 'doctor' ? 'doctor' : 'assistant';
|
||
$rows = self::cachedRequestRows('appointments', [
|
||
$sourceType,
|
||
$startDate,
|
||
$endDate,
|
||
self::mediaChannelCacheKey($mediaChannel),
|
||
$excludeCancelledAppointments,
|
||
], static function () use (
|
||
$sourceType,
|
||
$startDate,
|
||
$endDate,
|
||
$mediaChannel,
|
||
$excludeCancelledAppointments
|
||
): array {
|
||
$sourceExpr = $sourceType === 'doctor'
|
||
? 'a.doctor_id'
|
||
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||
$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)
|
||
->where('a.patient_id', '>', 0)
|
||
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
|
||
->group($sourceExpr);
|
||
|
||
if ($excludeCancelledAppointments) {
|
||
$query->whereIn('a.status', [1, 3, 4]);
|
||
}
|
||
|
||
$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 !== []) {
|
||
self::applyAppointmentChannelFilter($query, $legacyChannelValues);
|
||
} else {
|
||
MediaChannelService::applyExternalUserChannelFilter($query, 'u.external_userid', $mediaChannel);
|
||
}
|
||
}
|
||
|
||
return $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;
|
||
}
|
||
|
||
$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,
|
||
$useRegistrationMetric
|
||
);
|
||
|
||
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,
|
||
bool $useRegistrationMetric = false
|
||
): void {
|
||
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
|
||
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
|
||
$rows = self::cachedRequestRows('paid-appointments', [
|
||
$startDateTime,
|
||
$endDateTime,
|
||
self::mediaChannelCacheKey($mediaChannel),
|
||
$useRegistrationMetric,
|
||
], static function () use (
|
||
$startDateTime,
|
||
$endDateTime,
|
||
$mediaChannel,
|
||
$useRegistrationMetric
|
||
): array {
|
||
$query = Db::name('order')
|
||
->alias('o')
|
||
->whereNull('o.delete_time')
|
||
->where('o.status', 2)
|
||
// payment_time 是 DATETIME NULL;MySQL 8 严格模式下不能与空字符串比较。
|
||
->whereNotNull('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 ($useRegistrationMetric) {
|
||
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
|
||
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
|
||
} else {
|
||
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
|
||
$query->where('o.order_type', 1)->where('o.amount', 5);
|
||
}
|
||
|
||
if ($mediaChannel !== null) {
|
||
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
|
||
}
|
||
|
||
return $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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 挂号渠道兼容:新表写 channel_source(varchar),旧表写 channels(int),
|
||
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
|
||
*
|
||
* @param int[] $channelValues
|
||
*/
|
||
private static function applyAppointmentChannelFilter(Query $query, array $channelValues): void
|
||
{
|
||
$channelValues = array_values(array_unique(array_filter(
|
||
array_map('intval', $channelValues),
|
||
static fn (int $value): bool => $value > 0
|
||
)));
|
||
if ($channelValues === []) {
|
||
$query->whereRaw('0 = 1');
|
||
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$fields = Db::name('doctor_appointment')->getTableFields();
|
||
} catch (\Throwable) {
|
||
$fields = [];
|
||
}
|
||
$fields = is_array($fields) ? $fields : [];
|
||
$hasChannelSource = in_array('channel_source', $fields, true);
|
||
$hasChannels = in_array('channels', $fields, true);
|
||
|
||
if (!$hasChannelSource && !$hasChannels) {
|
||
$query->whereRaw('0 = 1');
|
||
|
||
return;
|
||
}
|
||
|
||
$placeholders = implode(',', array_fill(0, count($channelValues), '?'));
|
||
$parts = [];
|
||
$bindings = [];
|
||
if ($hasChannelSource) {
|
||
$parts[] = "a.channel_source IN ({$placeholders})";
|
||
array_push($bindings, ...array_map('strval', $channelValues));
|
||
}
|
||
if ($hasChannels) {
|
||
$parts[] = "a.channels IN ({$placeholders})";
|
||
array_push($bindings, ...$channelValues);
|
||
}
|
||
|
||
$query->whereRaw('(' . implode(' OR ', $parts) . ')', $bindings);
|
||
}
|
||
|
||
/**
|
||
* @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,
|
||
bool $usePerformanceOrderMetrics = false
|
||
): void {
|
||
if ($usePerformanceOrderMetrics) {
|
||
$isDoctorDimension = $dimension === 'doctor';
|
||
$rows = self::cachedRequestRows('performance-orders', [
|
||
$isDoctorDimension ? 'doctor' : 'assistant',
|
||
$startTimestamp,
|
||
$endTimestamp,
|
||
self::mediaChannelCacheKey($mediaChannel),
|
||
], static function () use (
|
||
$isDoctorDimension,
|
||
$startTimestamp,
|
||
$endTimestamp,
|
||
$mediaChannel
|
||
): array {
|
||
$sourceExpr = $isDoctorDimension ? 'rx.creator_id' : 'po.creator_id';
|
||
$query = Db::name('tcm_prescription_order')
|
||
->alias('po')
|
||
->whereNull('po.delete_time')
|
||
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
|
||
if ($isDoctorDimension) {
|
||
$query->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL');
|
||
}
|
||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
|
||
$query
|
||
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
|
||
->group($sourceExpr);
|
||
|
||
if ($mediaChannel !== null) {
|
||
$query->leftJoin('order o', 'o.id = po.linked_pay_order_id');
|
||
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
|
||
}
|
||
|
||
return $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;
|
||
}
|
||
$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(
|
||
(float)$entities[$entityId]['completed_order_amount'] + $amount,
|
||
2
|
||
);
|
||
$entities[$entityId]['business_order_amount'] = round(
|
||
(float)$entities[$entityId]['business_order_amount'] + $amount,
|
||
2
|
||
);
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
$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]);
|
||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($completedQuery, 'po');
|
||
$completedQuery
|
||
->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');
|
||
MediaChannelService::applyExternalUserChannelFilter($completedQuery, 'o.payer_external_userid', $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::applyPrescriptionOrderEffectiveAmountQuery($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');
|
||
MediaChannelService::applyExternalUserChannelFilter($businessQuery, 'o2.payer_external_userid', $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::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
||
// 面诊接诊率:接诊诊单 / 面诊
|
||
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||
// 面诊率:面诊 / 挂号(看挂号后流失)
|
||
$entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
|
||
$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::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
||
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||
$node['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
|
||
$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);用于成员明细的隔离
|
||
* @param bool $excludeCancelledAppointments 是否排除已取消挂号,须与部门汇总口径一致
|
||
* @param bool $usePerformanceOrderMetrics 是否使用有效业绩订单口径,须与部门汇总口径一致
|
||
* @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,
|
||
bool $excludeCancelledAppointments = false,
|
||
bool $usePerformanceOrderMetrics = false
|
||
): array
|
||
{
|
||
$assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
|
||
$doctorEntities = self::loadAdminEntities(1, 0, $visibleAdminIds);
|
||
|
||
$assistantIds = array_keys($assistantEntities);
|
||
$doctorIds = array_keys($doctorEntities);
|
||
|
||
if ($assistantIds !== [] && !$usePerformanceOrderMetrics) {
|
||
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
|
||
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
|
||
}
|
||
if ($doctorIds !== [] && !$usePerformanceOrderMetrics) {
|
||
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
|
||
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
|
||
}
|
||
|
||
// 复用调用方传入的 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 ($usePerformanceOrderMetrics && $combined !== []) {
|
||
// 一诊综合转化的部门指标均按业务归属人统计。成员明细也必须沿用同一归属,
|
||
// 不能再分别按“医助/医生”统计后相加,否则双角色员工会重复、人员合计也无法与部门汇总对齐。
|
||
$combinedIds = array_keys($combined);
|
||
self::hydrateFanStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||
self::hydrateAppointmentStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments, true);
|
||
self::hydrateOrderAndAmountStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, true);
|
||
}
|
||
|
||
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
|
||
{
|
||
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel);
|
||
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
|
||
$rows = array_values(array_filter($fanRows, static function (array $row) use ($adminByUserId): bool {
|
||
$userId = (string)($row['user_id'] ?? '');
|
||
|
||
return $userId !== '' && !isset($adminByUserId[$userId]);
|
||
}));
|
||
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
|
||
{
|
||
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||
if ($fanRows === []) {
|
||
return [];
|
||
}
|
||
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
|
||
|
||
$result = [];
|
||
foreach ($fanRows as $row) {
|
||
$userId = (string)($row['user_id'] ?? '');
|
||
$admin = $adminByUserId[$userId] ?? null;
|
||
$adminId = (int)($admin['id'] ?? 0);
|
||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||
if ($adminId <= 0 || $addFansCount <= 0) {
|
||
continue;
|
||
}
|
||
if (isset($assignedAdminIds[$adminId])) {
|
||
continue;
|
||
}
|
||
$name = trim((string)($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 表(含已软删的,避免离职后丢失映射)。未命中时直接展示原始 userid,
|
||
* 避免仅为展示名称对十几万行 follow_users TEXT 做前导通配全表扫描。
|
||
*
|
||
* @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;
|
||
}
|
||
|
||
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,
|
||
'interview_paid_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::receiveRate(
|
||
$completedOrderCount,
|
||
$addFansCount,
|
||
$interviewCount,
|
||
'member'
|
||
),
|
||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||
'interview_paid_rate' => self::percent($interviewCount, $paidAppointmentCount),
|
||
'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, string $dimension = 'dept'): 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::receiveRate(
|
||
$summary['completed_order_count'],
|
||
$summary['add_fans_count'],
|
||
$summary['interview_count'],
|
||
$dimension
|
||
);
|
||
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
||
$summary['interview_paid_rate'] = self::percent($summary['interview_count'], $summary['paid_appointment_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, string $dimension = 'dept'): 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' => [],
|
||
];
|
||
if ($dimension === 'doctor') {
|
||
$ranking['appointment_counts'] = [];
|
||
$ranking['interview_counts'] = [];
|
||
} else {
|
||
$ranking['fan_counts'] = [];
|
||
$ranking['rois'] = [];
|
||
}
|
||
|
||
foreach ($topRows as $row) {
|
||
$ranking['names'][] = $row['name'];
|
||
$ranking['amounts'][] = $row['completed_order_amount'];
|
||
$ranking['order_counts'][] = $row['completed_order_count'];
|
||
if ($dimension === 'doctor') {
|
||
$ranking['appointment_counts'][] = $row['appointment_total_count'];
|
||
$ranking['interview_counts'][] = $row['interview_count'];
|
||
} else {
|
||
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||
$ranking['rois'][] = $row['roi'];
|
||
}
|
||
}
|
||
|
||
$charts = [
|
||
'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)
|
||
),
|
||
];
|
||
|
||
if ($dimension === 'doctor') {
|
||
$charts['order_share'] = array_map(
|
||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']],
|
||
array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0)
|
||
);
|
||
} else {
|
||
$charts['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)
|
||
);
|
||
}
|
||
|
||
return $charts;
|
||
}
|
||
|
||
/**
|
||
* 接诊率:接诊诊单 ÷ 总进线(加粉)。
|
||
* 医生维度无加粉口径时,退化为接诊诊单 ÷ 面诊。
|
||
*/
|
||
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
|
||
{
|
||
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
|
||
|
||
return self::percent($completedOrderCount, $denominator);
|
||
}
|
||
|
||
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 array<int, mixed> $parts
|
||
*/
|
||
private static function requestRowsCacheKey(string $namespace, array $parts): string
|
||
{
|
||
return $namespace . ':' . hash('sha256', serialize($parts));
|
||
}
|
||
|
||
/**
|
||
* @param array<int, mixed> $parts
|
||
* @param callable(): array<int, array<string, mixed>> $loader
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private static function cachedRequestRows(string $namespace, array $parts, callable $loader): array
|
||
{
|
||
$cacheKey = self::requestRowsCacheKey($namespace, $parts);
|
||
if (!isset(self::$requestRowsCache[$cacheKey])) {
|
||
self::$requestRowsCache[$cacheKey] = $loader();
|
||
}
|
||
|
||
return self::$requestRowsCache[$cacheKey];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $mediaChannel
|
||
* @return array{code: string, tag_id: string, tag_name: string}
|
||
*/
|
||
private static function mediaChannelCacheKey(?array $mediaChannel): array
|
||
{
|
||
return [
|
||
'code' => (string)($mediaChannel['channel_code'] ?? ''),
|
||
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
|
||
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @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);
|
||
}
|
||
|
||
}
|