增加数据权限

增加调试登录
This commit is contained in:
2026-05-04 15:07:44 +08:00
parent 22b8b38b5d
commit 28fbbd28b7
3 changed files with 413 additions and 61 deletions
@@ -7,6 +7,7 @@ namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\finance\AccountCost;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
@@ -16,9 +17,17 @@ class ConversionLogic
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
/**
* @param array $params
* @param int $adminId 当前操作 admin(来自 BaseAdminController
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
* @return array<string, mixed>
*
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。
* - nullSCOPE_ALL,全数据放行(与历史行为一致)
* - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据
* - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项
*/
public static function overview(array $params = []): array
public static function overview(array $params = [], int $adminId = 0, ?array $adminInfo = null): array
{
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
@@ -29,9 +38,37 @@ class ConversionLogic
$pageNo = max(1, (int)($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
$entities = self::loadEntities($dimension, $params);
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
: null;
// 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。
if ($visibleAdminIds === []) {
$emptyResult = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
$emptyResult['extend']['filters'] = self::buildFilterOptions([], []);
}
return $emptyResult;
}
$entities = self::loadEntities($dimension, $params, $visibleAdminIds);
$entityIds = array_keys($entities);
$allocationEntities = self::loadEntities($dimension, $params);
$allocationEntities = self::loadEntities($dimension, $params, $visibleAdminIds);
$allocationEntityIds = array_keys($allocationEntities);
if ($entityIds === []) {
@@ -52,18 +89,20 @@ class ConversionLogic
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, [], $adminId, $adminInfo);
}
return $result;
}
$adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode);
self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
@@ -98,7 +137,7 @@ class ConversionLogic
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
}
return $result;
@@ -142,7 +181,8 @@ class ConversionLogic
$eligibleDeptIds,
$adminToDeptIds,
$validDeptIds,
$globalAccountCost
$globalAccountCost,
$visibleAdminIds
);
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
}
@@ -164,7 +204,7 @@ class ConversionLogic
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
$result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
}
return $result;
@@ -199,25 +239,194 @@ class ConversionLogic
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
$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
private static function buildFilterOptions(
?array $visibleAdminIds = null,
array $eligibleDeptIds = [],
int $adminId = 0,
?array $adminInfo = null
): array
{
return [
'departments' => DeptLogic::getAllData(),
'assistants' => DiagnosisLogic::getAssistants(),
'doctors' => DiagnosisLogic::getDoctors(),
'media_channels' => MediaChannelService::getOptions(),
'departments' => self::buildDepartmentOptions($visibleAdminIds, $eligibleDeptIds),
'assistants' => DiagnosisLogic::getAssistants($adminId, $adminInfo),
'doctors' => self::buildDoctorOptions($visibleAdminIds),
'media_channels' => self::buildMediaChannelOptions($visibleAdminIds, $eligibleDeptIds),
];
}
/**
* 部门下拉:
* - SCOPE_ALLvisibleAdminIds = null):全部启用部门树
* - 其他:仅展示"可见 admin 所属部门 + 媒体渠道作用域部门"的并集,并保留祖先链以维持树形结构
*
* @return array<int, array<string, mixed>>
*/
private static function buildDepartmentOptions(?array $visibleAdminIds, array $eligibleDeptIds): array
{
$allTree = DeptLogic::getAllData();
if ($visibleAdminIds === null) {
return $allTree;
}
$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] = true;
}
}
}
foreach ($eligibleDeptIds as $deptId) {
$deptId = (int)$deptId;
if ($deptId > 0) {
$allowedDeptIds[$deptId] = true;
}
}
if ($allowedDeptIds === []) {
return [];
}
return self::filterDeptTreeByIds($allTree, array_keys($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 === []) {
return [];
}
$query->whereIn('a.id', $visibleAdminIds);
}
return $query
->field(['a.id', 'a.name', 'a.account'])
->order('a.id', 'asc')
->distinct(true)
->select()
->toArray();
}
/**
* 媒体渠道下拉:按可见部门绑定的渠道收窄。
*
* @return array<int, array<string, mixed>>
*/
private static function buildMediaChannelOptions(?array $visibleAdminIds, array $eligibleDeptIds): array
{
$allOptions = MediaChannelService::getOptions();
if ($visibleAdminIds === null) {
return $allOptions;
}
if (!AccountCost::supportsDeptBinding()) {
return $allOptions;
}
$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] = true;
}
}
}
foreach ($eligibleDeptIds as $deptId) {
$deptId = (int)$deptId;
if ($deptId > 0) {
$allowedDeptIds[$deptId] = true;
}
}
if ($allowedDeptIds === []) {
return [];
}
$allowedCodes = Db::name('account_cost')
->whereIn('dept_id', array_keys($allowedDeptIds))
->where('media_channel_code', '<>', '')
->distinct(true)
->column('media_channel_code');
$allowedCodeSet = array_fill_keys(array_filter(array_map('strval', $allowedCodes)), true);
if ($allowedCodeSet === []) {
return [];
}
return array_values(array_filter($allOptions, static function ($option) use ($allowedCodeSet): bool {
$code = is_array($option) ? (string)($option['code'] ?? '') : '';
return $code !== '' && isset($allowedCodeSet[$code]);
}));
}
/**
* 按 dept_id 集合裁剪部门树,自动保留命中节点的祖先链以保证树形完整。
*
* @param array<int, array<string, mixed>> $tree
* @param int[] $allowedIds
* @return array<int, array<string, mixed>>
*/
private static function filterDeptTreeByIds(array $tree, array $allowedIds): array
{
$allowedSet = array_fill_keys($allowedIds, true);
$walker = function (array $node) use (&$walker, $allowedSet): ?array {
$children = [];
foreach ($node['children'] ?? [] as $child) {
if (!is_array($child)) {
continue;
}
$kept = $walker($child);
if ($kept !== null) {
$children[] = $kept;
}
}
$selfHit = isset($allowedSet[(int)($node['id'] ?? 0)]);
if ($selfHit || $children !== []) {
$node['children'] = $children;
return $node;
}
return null;
};
$result = [];
foreach ($tree as $node) {
$kept = $walker($node);
if ($kept !== null) {
$result[] = $kept;
}
}
return $result;
}
private static function normalizeDimension(string $dimension): string
{
return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept';
@@ -271,18 +480,19 @@ class ConversionLogic
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于 entities 加载与虚拟桶可见性
* @return array<int, array<string, mixed>>
*/
private static function loadEntities(string $dimension, array $params): array
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),
'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0),
'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);
self::appendVirtualDeptEntities($entities, $visibleAdminIds);
}
return $entities;
@@ -348,9 +558,10 @@ class ConversionLogic
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄)
* @return array<int, array<string, mixed>>
*/
private static function loadAdminEntities(int $roleId, int $adminId = 0): array
private static function loadAdminEntities(int $roleId, int $adminId = 0, ?array $visibleAdminIds = null): array
{
$query = Db::name('admin')
->alias('a')
@@ -362,6 +573,12 @@ class ConversionLogic
if ($adminId > 0) {
$query->where('a.id', $adminId);
}
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
return [];
}
$query->whereIn('a.id', $visibleAdminIds);
}
$rows = $query->order('a.id asc')->select()->toArray();
$entities = [];
@@ -406,17 +623,21 @@ class ConversionLogic
/**
* @param array<int, array<string, mixed>> $entities
* @param int[]|null $visibleAdminIds 可见 admin 集合;非 null(即非 SCOPE_ALL)时不挂"未绑定后台账号"虚拟桶,
* 因为这些加粉无主,不属于任何 admin 的可见范围。
*/
private static function appendVirtualDeptEntities(array &$entities): void
private static function appendVirtualDeptEntities(array &$entities, ?array $visibleAdminIds = null): void
{
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge(
self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'),
[
'pid' => 0,
'sort' => -999998,
'_virtual_bucket' => true,
]
);
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, '未分配部门'),
@@ -428,6 +649,37 @@ class ConversionLogic
);
}
/**
* 把"可见 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[]>
*
@@ -557,6 +809,7 @@ class ConversionLogic
* @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,
@@ -565,7 +818,8 @@ class ConversionLogic
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$query = Db::name('qywx_external_contact_event')
->alias('e')
@@ -587,7 +841,8 @@ class ConversionLogic
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($dimension === 'dept' && $adminId <= 0) {
if (isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
// -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;
@@ -597,8 +852,12 @@ class ConversionLogic
continue;
}
$hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds);
$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;
}
@@ -620,6 +879,7 @@ class ConversionLogic
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/
private static function hydrateAppointmentStats(
array &$entities,
@@ -630,7 +890,8 @@ class ConversionLogic
int $endTimestamp,
string $startDate,
string $endDate,
?array $mediaChannel
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$query = Db::name('doctor_appointment')
@@ -662,7 +923,7 @@ class ConversionLogic
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
@@ -675,7 +936,7 @@ class ConversionLogic
}
}
self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
foreach ($entities as &$entity) {
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
@@ -690,6 +951,7 @@ class ConversionLogic
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/
private static function hydratePaidAppointmentStats(
array &$entities,
@@ -698,7 +960,8 @@ class ConversionLogic
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
@@ -720,7 +983,7 @@ class ConversionLogic
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
@@ -737,6 +1000,7 @@ class ConversionLogic
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/
private static function hydrateOrderAndAmountStats(
array &$entities,
@@ -745,7 +1009,8 @@ class ConversionLogic
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$completedQuery = Db::name('tcm_prescription_order')
@@ -770,7 +1035,7 @@ class ConversionLogic
foreach ($completedRows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
@@ -805,7 +1070,7 @@ class ConversionLogic
foreach ($businessRows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) {
continue;
}
@@ -821,9 +1086,16 @@ class ConversionLogic
* 账户消耗:来源于独立维护表 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
private static function hydrateAccountCostStats(
array &$entities,
string $startDate,
string $endDate,
string $mediaChannelCode,
?array $visibleDeptIds = null
): array
{
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost')
@@ -842,6 +1114,19 @@ class ConversionLogic
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();
@@ -870,16 +1155,25 @@ class ConversionLogic
/**
* @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
{
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] : [];
@@ -1228,6 +1522,7 @@ class ConversionLogic
* 挂到不存在的部门上、错过"未分配部门"虚拟桶。
* @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。
* -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离
* @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...]
*/
private static function buildMemberRowsByDept(
@@ -1241,24 +1536,25 @@ class ConversionLogic
array $eligibleDeptIds,
array $adminToDeptIds,
array $validDeptIds = [],
float $globalAccountCost = -1.0
float $globalAccountCost = -1.0,
?array $visibleAdminIds = null
): array
{
$assistantEntities = self::loadAdminEntities(2);
$doctorEntities = self::loadAdminEntities(1);
$assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
$doctorEntities = self::loadAdminEntities(1, 0, $visibleAdminIds);
$assistantIds = array_keys($assistantEntities);
$doctorIds = array_keys($doctorEntities);
if ($assistantIds !== []) {
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
}
if ($doctorIds !== []) {
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
}
// 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。
@@ -1366,9 +1662,12 @@ class ConversionLogic
$memberRowsByDeptId[$primaryDeptId][] = $row;
}
$unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel);
if ($unboundRows !== []) {
$memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows;
// -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 桶,
@@ -1379,7 +1678,8 @@ class ConversionLogic
$startTimestamp,
$endTimestamp,
$mediaChannel,
$assignedAdminIds
$assignedAdminIds,
$visibleAdminIds
);
if ($leftoverRows !== []) {
$existing = $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] ?? [];
@@ -1477,13 +1777,15 @@ class ConversionLogic
* 这些 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 $assignedAdminIds,
?array $visibleAdminIds = null
): array
{
$query = Db::name('qywx_external_contact_event')
@@ -1494,6 +1796,13 @@ class ConversionLogic
->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count')
->group('a.id, a.name');
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
return [];
}
$query->whereIn('a.id', $visibleAdminIds);
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);