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

2784 lines
112 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\finance\AccountCost;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\db\Query;
use think\facade\Db;
class ConversionLogic
{
private const VIRTUAL_DEPT_UNBOUND_ADMIN_ID = -1;
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
/**
* 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 集合"。
* - nullSCOPE_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_ALLvisibleAdminIds = null):全部启用部门树
* - 其他:仅展示"可见 admin 所属部门 + 媒体渠道作用域部门"的并集,并保留祖先链以维持树形结构
*
* @return array<int, array<string, mixed>>
*/
private static function buildDepartmentOptions(?array $visibleAdminIds, array $eligibleDeptIds, ?array $allowedDeptIds = null): array
{
$allTree = DeptLogic::getAllData();
if ($visibleAdminIds === null) {
return $allTree;
}
$allowedDeptIds ??= self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds);
if ($allowedDeptIds === []) {
return [];
}
return self::filterDeptTreeByIds($allTree, $allowedDeptIds);
}
/**
* 医生下拉:按 visibleAdminIds 收窄。
*
* @return array<int, array<string, mixed>>
*/
private static function buildDoctorOptions(?array $visibleAdminIds): array
{
$query = Db::name('admin')
->alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->where('ar.role_id', 1)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空,
// 避免和"未传 visibleAdminIds (=SCOPE_ALL)"在调用侧产生混淆。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
return $query
->field(['a.id', 'a.name', 'a.account'])
->order('a.id', 'asc')
->distinct(true)
->select()
->toArray();
}
/**
* 媒体渠道下拉:返回全局启用渠道;事实数据仍由 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');
$rows = $query->order('sort desc, id asc')->select()->toArray();
if ($deptId > 0) {
$allowedIds = self::collectDeptSubtreeIds($rows, $deptId);
$rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)$row['id'], $allowedIds, true)));
}
$entities = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? ''));
$entities[$id]['pid'] = (int)($row['pid'] ?? 0);
$entities[$id]['sort'] = (int)($row['sort'] ?? 0);
}
return $entities;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return int[]
*/
private static function collectDeptSubtreeIds(array $rows, int $rootId): array
{
$childrenByPid = [];
foreach ($rows as $row) {
$pid = (int)($row['pid'] ?? 0);
$id = (int)($row['id'] ?? 0);
$childrenByPid[$pid] ??= [];
$childrenByPid[$pid][] = $id;
}
$queue = [$rootId];
$result = [];
while ($queue !== []) {
$current = array_shift($queue);
if (!$current || isset($result[$current])) {
continue;
}
$result[$current] = $current;
foreach ($childrenByPid[$current] ?? [] as $childId) {
$queue[] = (int)$childId;
}
}
return array_values($result);
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄)
* @return array<int, array<string, mixed>>
*/
private static function loadAdminEntities(int $roleId, int $adminId = 0, ?array $visibleAdminIds = null): array
{
$query = Db::name('admin')
->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', $roleId)
->whereNull('a.delete_time')
->field('a.id, a.name');
if ($adminId > 0) {
$query->where('a.id', $adminId);
}
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
$rows = $query->order('a.id asc')->select()->toArray();
$entities = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? ''));
}
return $entities;
}
/**
* @return array<string, mixed>
*
* 字段说明:
* - total_open_count / unreplied_count:预留字段,对应"总开口数 / 未回复数"。当前 IM
* 会话归档系统未上线,没有数据源可以填充,前端按 placeholder='—' 显示。等接入后
* 在 hydrateXxx 系列里追加 SQL 写入即可,无需调整下游 finalize / chart / summary。
*/
private static function newEntityRow(int $id, string $name): array
{
return [
'id' => $id,
'name' => $name,
'add_fans_count' => 0,
'total_open_count' => 0, // TODO: 接入 IM 会话统计后回填
'unreplied_count' => 0, // TODO: 接入 IM 会话统计后回填
'paid_appointment_count' => 0,
'free_appointment_count' => 0,
'appointment_total_count' => 0,
'interview_count' => 0,
'business_order_amount' => 0.0,
'completed_order_amount' => 0.0,
'completed_order_count' => 0,
'account_cost' => 0.0,
'_virtual_bucket' => false,
];
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[]|null $visibleAdminIds 可见 admin 集合;非 null(即非 SCOPE_ALL)时不挂"未绑定后台账号"虚拟桶,
* 因为这些加粉无主,不属于任何 admin 的可见范围。
*/
private static function appendVirtualDeptEntities(array &$entities, ?array $visibleAdminIds = null): void
{
if ($visibleAdminIds === null) {
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge(
self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'),
[
'pid' => 0,
'sort' => -999998,
'_virtual_bucket' => true,
]
);
}
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge(
self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'),
[
'pid' => 0,
'sort' => -999999,
'_virtual_bucket' => true,
]
);
}
/**
* 把"可见 admin 集合"转换成"可见部门集合":取所有 admin 的 admin_dept 并集。
* - $visibleAdminIds === nullSCOPE_ALL)→ 返回 null(下游不收窄)
* - $visibleAdminIds === [] → 返回 [](无可见部门)
*
* @param int[]|null $visibleAdminIds
* @return int[]|null
*/
private static function resolveVisibleDeptIds(?array $visibleAdminIds): ?array
{
if ($visibleAdminIds === null) {
return null;
}
if ($visibleAdminIds === []) {
return [];
}
$deptIds = Db::name('admin_dept')
->whereIn('admin_id', $visibleAdminIds)
->column('dept_id');
$result = [];
foreach ($deptIds as $deptId) {
$deptId = (int)$deptId;
if ($deptId > 0) {
$result[$deptId] = $deptId;
}
}
return array_values($result);
}
/**
* @return array<int, int[]>
*
* 返回 admin_id => [dept_id, ...]。跨部门时优先最深层、再按部门排序,
* 与一诊挂号统计和业绩看板的人员归属规则保持一致。
* 当 admin 跨部门时,下游 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_ALLvisibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。
if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount;
}
continue;
}
if ($adminId <= 0) {
continue;
}
$hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($dimension === 'dept' && $hitEntityIds === []) {
// admin 已通过 visibleAdminIds 校验但没找到归属部门 → 落入"未分配部门"。
if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) {
continue;
}
if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) {
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount;
}
continue;
}
if ($hitEntityIds === []) {
continue;
}
foreach ($hitEntityIds as $entityId) {
$entities[$entityId]['add_fans_count'] += $addFansCount;
}
}
}
/**
* Aggregate add-contact events by WeCom user once, then project that raw
* snapshot to departments, members and virtual buckets in PHP.
*
* @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', [
$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];
}
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
->group('e.user_id');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
}
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
}
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
return self::$requestRowsCache[$cacheKey];
}
/**
* @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 NULLMySQL 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_sourcevarchar),旧表写 channelsint),
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
*
* @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['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['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,
'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),
'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['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);
}
}