更新bug
This commit is contained in:
@@ -164,10 +164,8 @@ class AdminLogic extends BaseLogic
|
||||
|
||||
// 禁用或更换角色后.设置token过期
|
||||
$roleId = AdminRole::where('admin_id', $params['id'])->column('role_id');
|
||||
$editRole = false;
|
||||
if (!empty(array_diff_assoc($roleId, $params['role_id']))) {
|
||||
$editRole = true;
|
||||
}
|
||||
$submittedRoleIds = self::normalizeRoleIds($params['role_id'] ?? []);
|
||||
$editRole = self::roleIdsChanged($roleId, $submittedRoleIds);
|
||||
|
||||
if ($params['disable'] == 1 || $editRole) {
|
||||
$tokenArr = AdminSession::where('admin_id', $params['id'])->select()->toArray();
|
||||
@@ -177,14 +175,13 @@ class AdminLogic extends BaseLogic
|
||||
}
|
||||
|
||||
Admin::update($data);
|
||||
(new AdminAuthCache($params['id']))->clearAuthCache();
|
||||
|
||||
// 删除旧的关联信息
|
||||
AdminRole::delByUserId($params['id']);
|
||||
AdminDept::delByUserId($params['id']);
|
||||
AdminJobs::delByUserId($params['id']);
|
||||
// 角色
|
||||
self::insertRole($params['id'], $params['role_id']);
|
||||
self::insertRole($params['id'], $submittedRoleIds);
|
||||
// 部门
|
||||
self::insertDept($params['id'], $params['dept_id'] ?? []);
|
||||
// 岗位
|
||||
@@ -194,6 +191,13 @@ class AdminLogic extends BaseLogic
|
||||
self::importDoctorAccountToIm($params['id'], $params['name']);
|
||||
|
||||
Db::commit();
|
||||
// 必须在角色关联提交后删除该账号的 URI 权限缓存,避免并发请求重新写入旧权限。
|
||||
try {
|
||||
(new AdminAuthCache($params['id']))->clearAuthCache();
|
||||
} catch (\Throwable $cacheError) {
|
||||
// 数据已经提交,缓存清理失败不能把成功的编辑伪装成失败;记录后等待缓存自然过期。
|
||||
Log::warning('管理员角色权限缓存清理失败:admin_id=' . $params['id'] . ',error=' . $cacheError->getMessage());
|
||||
}
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
@@ -363,7 +367,8 @@ class AdminLogic extends BaseLogic
|
||||
*/
|
||||
public static function insertRole($adminId, $roleIds)
|
||||
{
|
||||
if (!empty($roleIds)) {
|
||||
$roleIds = self::normalizeRoleIds($roleIds);
|
||||
if ($roleIds !== []) {
|
||||
// 角色
|
||||
$roleData = [];
|
||||
foreach ($roleIds as $roleId) {
|
||||
@@ -376,6 +381,33 @@ class AdminLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private static function normalizeRoleIds(mixed $roleIds): array
|
||||
{
|
||||
if (!is_array($roleIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique(array_filter(
|
||||
array_map('intval', $roleIds),
|
||||
static fn (int $roleId): bool => $roleId > 0
|
||||
)));
|
||||
sort($normalized, SORT_NUMERIC);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色是无序集合;新增、移除或替换都必须使现有 Token 失效,仅顺序变化不算修改。
|
||||
*
|
||||
* @param array<int|string,mixed> $currentRoleIds
|
||||
* @param array<int|string,mixed> $submittedRoleIds
|
||||
*/
|
||||
private static function roleIdsChanged(array $currentRoleIds, array $submittedRoleIds): bool
|
||||
{
|
||||
return self::normalizeRoleIds($currentRoleIds) !== self::normalizeRoleIds($submittedRoleIds);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 新增部门
|
||||
@@ -453,4 +485,4 @@ class AdminLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,11 @@ class FirstVisitConversionLogic
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
@@ -136,19 +137,20 @@ class FirstVisitConversionLogic
|
||||
(int) $summary['total_open_count']
|
||||
);
|
||||
|
||||
$rankingRows = self::rankingRows($rows);
|
||||
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
|
||||
$rankingRows = self::rankingRows($rows, $rankingKind);
|
||||
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
|
||||
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
|
||||
? []
|
||||
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
|
||||
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
|
||||
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
|
||||
: '';
|
||||
@@ -159,8 +161,9 @@ class FirstVisitConversionLogic
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'ranking_kind' => $rankingKind,
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||||
@@ -529,10 +532,33 @@ class FirstVisitConversionLogic
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows): array
|
||||
{
|
||||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||||
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
|
||||
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
|
||||
{
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows, string $rankingKind): array
|
||||
{
|
||||
if ($rankingKind === 'hidden') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
|
||||
// 直属下级,避免父子汇总同时参与占比。
|
||||
if ($rankingKind === 'member') {
|
||||
$members = [];
|
||||
self::collectRankingMembers($rows, $members);
|
||||
|
||||
return array_values($members);
|
||||
}
|
||||
|
||||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||||
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
|
||||
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
|
||||
@@ -557,23 +583,59 @@ class FirstVisitConversionLogic
|
||||
$chartRows[] = $row;
|
||||
}
|
||||
|
||||
return $chartRows;
|
||||
}
|
||||
return $chartRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @param array<int,array<string,mixed>> $members
|
||||
*/
|
||||
private static function collectRankingMembers(array $rows, array &$members): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||
if ($adminId > 0) {
|
||||
$members[$adminId] = $row;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self::collectRankingMembers(
|
||||
is_array($row['children'] ?? null) ? $row['children'] : [],
|
||||
$members
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int) ($row['id'] ?? 0) > 0));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], array_slice($rows, 0, 6));
|
||||
}
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
return (int) ($row['admin_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
return (int) ($row['id'] ?? 0) > 0;
|
||||
}));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
if ($valueCompare !== 0) {
|
||||
return $valueCompare;
|
||||
}
|
||||
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
if ($nameCompare !== 0) {
|
||||
return $nameCompare;
|
||||
}
|
||||
|
||||
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'] ?? 0,
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemRole;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
|
||||
/**
|
||||
* 首页 KPI 数据范围:按角色而不是单纯按 data_scope。
|
||||
*
|
||||
* 医助=仅本人;组长=本小组全部成员;经理=本部门及下级;管理员/超管=全部。
|
||||
* 本人业绩卡片始终按登录账号单独统计,不走这套范围。
|
||||
*/
|
||||
class PerformanceDashboardScope
|
||||
{
|
||||
public const KIND_ADMIN = 'admin';
|
||||
public const KIND_MANAGER = 'manager';
|
||||
public const KIND_GROUP_LEADER = 'group_leader';
|
||||
public const KIND_ASSISTANT = 'assistant';
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* kind: string,
|
||||
* label: string,
|
||||
* metric_admin_ids: array<int>|null
|
||||
* }
|
||||
*/
|
||||
public static function resolve(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$roleNames = self::roleNames($adminId);
|
||||
$kind = self::classify((int) ($adminInfo['root'] ?? 0) === 1, $roleNames, $adminInfo);
|
||||
$metricAdminIds = self::metricAdminIds($kind, $adminId, $adminInfo);
|
||||
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'label' => self::kindLabel($kind),
|
||||
'metric_admin_ids' => $metricAdminIds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function kindLabel(string $kind): string
|
||||
{
|
||||
return [
|
||||
self::KIND_ADMIN => '全部数据',
|
||||
self::KIND_MANAGER => '本部门',
|
||||
self::KIND_GROUP_LEADER => '本小组',
|
||||
self::KIND_ASSISTANT => '仅本人',
|
||||
][$kind] ?? '数据范围';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roleNames
|
||||
*/
|
||||
public static function classify(bool $isRoot, array $roleNames, array $adminInfo = []): string
|
||||
{
|
||||
if ($isRoot) {
|
||||
return self::KIND_ADMIN;
|
||||
}
|
||||
if (self::roleNamesMatch($roleNames, ['管理员'])) {
|
||||
return self::KIND_ADMIN;
|
||||
}
|
||||
if (self::roleNamesMatch($roleNames, ['经理'])) {
|
||||
return self::KIND_MANAGER;
|
||||
}
|
||||
if (self::roleNamesMatch($roleNames, ['诊室组长', '组长'])) {
|
||||
return self::KIND_GROUP_LEADER;
|
||||
}
|
||||
// 医助角色固定仅本人,不因部门负责人或 data_scope 放大到小组。
|
||||
if (self::roleNamesMatch($roleNames, ['医助'])) {
|
||||
return self::KIND_ASSISTANT;
|
||||
}
|
||||
|
||||
$scope = DataScopeService::getEffectiveScope($adminInfo);
|
||||
return match ($scope) {
|
||||
DataScopeService::SCOPE_ALL => self::KIND_ADMIN,
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => self::KIND_MANAGER,
|
||||
DataScopeService::SCOPE_DEPT => self::KIND_GROUP_LEADER,
|
||||
default => self::KIND_ASSISTANT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>|null
|
||||
*/
|
||||
private static function metricAdminIds(string $kind, int $adminId, array $adminInfo = []): ?array
|
||||
{
|
||||
if ($kind === self::KIND_ADMIN) {
|
||||
return null;
|
||||
}
|
||||
if ($kind === self::KIND_ASSISTANT || $adminId <= 0) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
if ($kind === self::KIND_GROUP_LEADER) {
|
||||
$ids = self::adminsInGroup($adminId, $adminInfo);
|
||||
return $ids !== [] ? $ids : ($adminId > 0 ? [$adminId] : []);
|
||||
}
|
||||
|
||||
$ids = self::adminsInOwnDeptTree($adminId);
|
||||
if ($ids === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组长小组:只取本人最深的部门(不含一中心/二中心整棵树),并并入其担任负责人的部门。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function adminsInGroup(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$ownDeptIds = self::ownDeptIds($adminId);
|
||||
$leafDeptIds = self::leafDeptIds($ownDeptIds);
|
||||
$ledDeptIds = self::ledDeptIds($adminId, $adminInfo, $ownDeptIds);
|
||||
$groupDeptIds = array_values(array_unique(array_merge($leafDeptIds, $ledDeptIds)));
|
||||
$groupDeptIds = self::dropCenterRootsIfHasDeeper($groupDeptIds);
|
||||
if ($groupDeptIds === []) {
|
||||
$groupDeptIds = $leafDeptIds !== [] ? $leafDeptIds : $ownDeptIds;
|
||||
}
|
||||
|
||||
$deptIds = [];
|
||||
foreach ($groupDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds((int) $deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$deptIds[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
$deptIds = array_values(array_unique($deptIds));
|
||||
if ($deptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
|
||||
$adminIds[] = $adminId;
|
||||
}
|
||||
|
||||
return $adminIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private static function ownDeptIds(int $adminId): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在本人所属部门里只留最深的节点,避免挂在「一中心」上就把整个中心当成小组。
|
||||
*
|
||||
* @param int[] $ownDeptIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function leafDeptIds(array $ownDeptIds): array
|
||||
{
|
||||
if ($ownDeptIds === []) {
|
||||
return [];
|
||||
}
|
||||
$deptById = [];
|
||||
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$deptById[$id] = [
|
||||
'pid' => (int) ($row['pid'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$ownSet = array_fill_keys($ownDeptIds, true);
|
||||
$leaves = [];
|
||||
foreach ($ownDeptIds as $id) {
|
||||
$hasOwnDescendant = false;
|
||||
foreach ($ownDeptIds as $other) {
|
||||
if ($other === $id) {
|
||||
continue;
|
||||
}
|
||||
if (self::isAncestorOf($id, $other, $deptById)) {
|
||||
$hasOwnDescendant = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasOwnDescendant && isset($ownSet[$id])) {
|
||||
$leaves[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($leaves));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{pid: int, name: string}> $deptById
|
||||
*/
|
||||
private static function isAncestorOf(int $ancestorId, int $nodeId, array $deptById): bool
|
||||
{
|
||||
$current = $nodeId;
|
||||
$seen = [];
|
||||
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
|
||||
$seen[$current] = true;
|
||||
$pid = $deptById[$current]['pid'];
|
||||
if ($pid === $ancestorId) {
|
||||
return true;
|
||||
}
|
||||
$current = $pid;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门负责人姓名匹配当前组长时,把该部门算进小组。仅用于已判定为组长的账号。
|
||||
*
|
||||
* @param int[] $ownDeptIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function ledDeptIds(int $adminId, array $adminInfo, array $ownDeptIds): array
|
||||
{
|
||||
$name = self::normalizePersonName((string) ($adminInfo['name'] ?? ''));
|
||||
if ($adminId <= 0 || $name === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'leader'])->select()->toArray();
|
||||
$ownSet = array_fill_keys($ownDeptIds, true);
|
||||
$led = [];
|
||||
foreach ($rows as $row) {
|
||||
$deptId = (int) ($row['id'] ?? 0);
|
||||
$leaderName = self::normalizePersonName((string) ($row['leader'] ?? ''));
|
||||
if ($deptId <= 0 || $leaderName === '' || $leaderName !== $name) {
|
||||
continue;
|
||||
}
|
||||
if ($ownSet === [] || isset($ownSet[$deptId]) || self::deptUnderOwnTree($deptId, $ownDeptIds)) {
|
||||
$led[] = $deptId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($led));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ownDeptIds
|
||||
*/
|
||||
private static function deptUnderOwnTree(int $deptId, array $ownDeptIds): bool
|
||||
{
|
||||
foreach ($ownDeptIds as $rootId) {
|
||||
$ids = DeptLogic::getSelfAndDescendantIds((int) $rootId);
|
||||
foreach ($ids as $id) {
|
||||
if ((int) $id === $deptId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $deptIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function dropCenterRootsIfHasDeeper(array $deptIds): array
|
||||
{
|
||||
$names = [];
|
||||
if ($deptIds !== []) {
|
||||
$names = Dept::whereIn('id', $deptIds)->whereNull('delete_time')->column('name', 'id');
|
||||
}
|
||||
$hasDeeper = false;
|
||||
foreach ($deptIds as $id) {
|
||||
$name = (string) ($names[$id] ?? '');
|
||||
if ($name !== '' && mb_strpos($name, '一中心') === false && mb_strpos($name, '二中心') === false) {
|
||||
$hasDeeper = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasDeeper) {
|
||||
return $deptIds;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($deptIds as $id) {
|
||||
$name = (string) ($names[$id] ?? '');
|
||||
if ($name !== '' && (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false)) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $id;
|
||||
}
|
||||
|
||||
return $kept;
|
||||
}
|
||||
|
||||
private static function normalizePersonName(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private static function adminsInOwnDeptTree(int $adminId): array
|
||||
{
|
||||
$ownDeptIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($ownDeptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$deptIds = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$deptIds[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
$deptIds = array_values(array_unique($deptIds));
|
||||
if ($deptIds === []) {
|
||||
return [$adminId];
|
||||
}
|
||||
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
|
||||
$adminIds[] = $adminId;
|
||||
}
|
||||
|
||||
return $adminIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private static function roleNames(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$roleIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($roleIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->column('name');
|
||||
|
||||
return array_values(array_filter(array_map('strval', $names)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roleNames
|
||||
* @param string[] $needles
|
||||
*/
|
||||
private static function roleNamesMatch(array $roleNames, array $needles): bool
|
||||
{
|
||||
foreach ($roleNames as $name) {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($needles as $needle) {
|
||||
if ($name === $needle || mb_strpos($name, $needle) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -114,9 +114,10 @@ class AdminAuthCache extends BaseCache
|
||||
*/
|
||||
public function clearAuthCache()
|
||||
{
|
||||
$this->tag($this->cacheUrlKey)->clear();
|
||||
return true;
|
||||
// BaseCache::set() 使用类名作为标签,并没有使用 cacheUrlKey 作为标签;
|
||||
// 这里必须直接删除单账号缓存键,否则角色变更后旧权限会继续保留一小时。
|
||||
return $this->delete($this->cacheUrlKey);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ use think\facade\Config;
|
||||
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
|
||||
* - SELF (4) = 仅本人
|
||||
*
|
||||
* 多角色时取「最严格」可见范围 = data_scope 最大值(1=全部 … 4=仅本人),
|
||||
* 与常见「数据权限取交集」一致,避免挂了一个「全部」角色就把其它角色的部门范围冲掉。
|
||||
* 多角色时,2/3/4 范围按授权并集取最宽范围;迁移期遗留的普通 scope=1 角色不会
|
||||
* 自动冲掉有限范围。只有明确的管理员角色、root 或 exempt_roles 才能在组合角色中放开全部。
|
||||
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
|
||||
*
|
||||
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 null(ALL = 不过滤)。
|
||||
@@ -32,6 +32,9 @@ class DataScopeService
|
||||
public const SCOPE_DEPT = 3;
|
||||
public const SCOPE_SELF = 4;
|
||||
|
||||
/** @var string[] 明确允许在多角色组合中放开全部数据的内置角色名。 */
|
||||
private const ALL_SCOPE_ROLE_NAMES = ['管理员', '系统管理员'];
|
||||
|
||||
/**
|
||||
* 计算当前 admin 的有效数据范围。
|
||||
*/
|
||||
@@ -51,18 +54,59 @@ class DataScopeService
|
||||
if ($roleIds === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
$scopes = SystemRole::whereIn('id', $roleIds)
|
||||
$roleRows = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->column('data_scope');
|
||||
$scopes = array_values(array_filter(array_map('intval', $scopes), static function (int $v): bool {
|
||||
return $v >= self::SCOPE_ALL && $v <= self::SCOPE_SELF;
|
||||
}));
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到「仅本人」,避免误放开到全站
|
||||
if ($scopes === []) {
|
||||
->field('id,name,data_scope')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return self::mergeRoleScopes($roleRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个角色的数据范围。
|
||||
*
|
||||
* 历史迁移曾把全部旧角色默认成 scope=1;如果账号同时具有有限范围角色,普通的
|
||||
* scope=1 视为功能角色而不参与放大,防止“医生 + 医助”等组合意外获得全站数据。
|
||||
* 2/3/4 是嵌套授权,取最小值即可表达多个有效数据角色的可见范围并集。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $roleRows
|
||||
*/
|
||||
private static function mergeRoleScopes(array $roleRows): int
|
||||
{
|
||||
$validRows = [];
|
||||
foreach ($roleRows as $roleRow) {
|
||||
$scope = (int) ($roleRow['data_scope'] ?? 0);
|
||||
if ($scope < self::SCOPE_ALL || $scope > self::SCOPE_SELF) {
|
||||
continue;
|
||||
}
|
||||
$validRows[] = [
|
||||
'name' => trim((string) ($roleRow['name'] ?? '')),
|
||||
'scope' => $scope,
|
||||
];
|
||||
}
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到本人。
|
||||
if ($validRows === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
|
||||
return (int) max($scopes);
|
||||
foreach ($validRows as $roleRow) {
|
||||
if ($roleRow['scope'] === self::SCOPE_ALL
|
||||
&& in_array($roleRow['name'], self::ALL_SCOPE_ROLE_NAMES, true)) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
}
|
||||
|
||||
$boundedScopes = array_column(array_values(array_filter(
|
||||
$validRows,
|
||||
static fn (array $roleRow): bool => $roleRow['scope'] > self::SCOPE_ALL
|
||||
)), 'scope');
|
||||
if ($boundedScopes !== []) {
|
||||
return (int) min($boundedScopes);
|
||||
}
|
||||
|
||||
// 只有普通 scope=1 角色时保持原有“全部数据”行为。
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,7 +140,8 @@ return [
|
||||
/*
|
||||
* 数据隔离(按部门):全局可用,角色上的 data_scope 控制范围
|
||||
* 1=全部 2=本部门及下级 3=仅本部门 4=仅本人
|
||||
* root 管理员永远视为「全部」;多角色取最严格范围(数值最大,与 DataScopeService 一致)
|
||||
* root/管理员/豁免角色永远视为「全部」;多角色的有限范围取授权并集(数值最小)。
|
||||
* 历史默认 scope=1 的普通功能角色与有限范围角色组合时不参与放大,避免意外越权。
|
||||
*/
|
||||
'data_scope' => [
|
||||
// 顶层总开关:false 时所有列表不应用按部门的数据隔离(仍保留老的白名单规则)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\auth\AdminLogic;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function adminMultiRoleExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$adminReflection = new ReflectionClass(AdminLogic::class);
|
||||
$normalizeRoleIds = $adminReflection->getMethod('normalizeRoleIds');
|
||||
$roleIdsChanged = $adminReflection->getMethod('roleIdsChanged');
|
||||
$normalizeRoleIds->setAccessible(true);
|
||||
$roleIdsChanged->setAccessible(true);
|
||||
|
||||
adminMultiRoleExpect(
|
||||
$normalizeRoleIds->invoke(null, ['7', 2, 7, 0, -1]) === [2, 7],
|
||||
'Role ids must be normalized, deduplicated and sorted'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
$roleIdsChanged->invoke(null, [2], [2, 7]) === true,
|
||||
'Adding a role must invalidate the existing token'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
$roleIdsChanged->invoke(null, [2, 7], [2]) === true,
|
||||
'Removing a role must invalidate the existing token'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
$roleIdsChanged->invoke(null, [2, 7], [7, 2]) === false,
|
||||
'Changing only role order must not invalidate the token'
|
||||
);
|
||||
|
||||
$cacheMethod = new ReflectionMethod(AdminAuthCache::class, 'clearAuthCache');
|
||||
$sourceLines = file($cacheMethod->getFileName());
|
||||
$methodSource = implode('', array_slice(
|
||||
$sourceLines,
|
||||
$cacheMethod->getStartLine() - 1,
|
||||
$cacheMethod->getEndLine() - $cacheMethod->getStartLine() + 1
|
||||
));
|
||||
adminMultiRoleExpect(
|
||||
str_contains($methodSource, 'delete($this->cacheUrlKey)'),
|
||||
'Single-admin permission cache must delete its concrete cache key'
|
||||
);
|
||||
adminMultiRoleExpect(
|
||||
!str_contains($methodSource, 'tag($this->cacheUrlKey)'),
|
||||
'Single-admin permission cache must not clear a tag that was never assigned'
|
||||
);
|
||||
|
||||
echo "AdminMultiRoleRegressionTest passed\n";
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function dataScopeMultiRoleExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$mergeRoleScopes = (new ReflectionClass(DataScopeService::class))->getMethod('mergeRoleScopes');
|
||||
$mergeRoleScopes->setAccessible(true);
|
||||
$role = static fn (string $name, int $scope): array => ['name' => $name, 'data_scope' => $scope];
|
||||
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('医助', 4)]) === DataScopeService::SCOPE_SELF,
|
||||
'Single assistant role must remain self-only'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('诊室组长', 3), $role('医助', 4)]) === DataScopeService::SCOPE_DEPT,
|
||||
'Group leader plus assistant must use the group scope'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('经理', 2), $role('诊室组长', 3), $role('医助', 4)])
|
||||
=== DataScopeService::SCOPE_DEPT_AND_CHILD,
|
||||
'Manager plus narrower roles must use department-and-child scope'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('医生', 1), $role('医助', 4)]) === DataScopeService::SCOPE_SELF,
|
||||
'Legacy all-scope functional role must not widen a bounded role'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('管理员', 1), $role('医助', 4)]) === DataScopeService::SCOPE_ALL,
|
||||
'Explicit administrator role must retain all-data scope'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('下单', 1)]) === DataScopeService::SCOPE_ALL,
|
||||
'A standalone legacy all-scope role must keep its existing behavior'
|
||||
);
|
||||
dataScopeMultiRoleExpect(
|
||||
$mergeRoleScopes->invoke(null, [$role('脏数据', 0)]) === DataScopeService::SCOPE_SELF,
|
||||
'Invalid role scopes must fail closed to self-only'
|
||||
);
|
||||
|
||||
echo "DataScopeMultiRoleTest passed\n";
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function conversionRankingExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass(FirstVisitConversionLogic::class);
|
||||
$rankingKind = $reflection->getMethod('rankingKind');
|
||||
$rankingRows = $reflection->getMethod('rankingRows');
|
||||
$topRows = $reflection->getMethod('topRows');
|
||||
$rankingKind->setAccessible(true);
|
||||
$rankingRows->setAccessible(true);
|
||||
$topRows->setAccessible(true);
|
||||
|
||||
$member = static fn (int $id, string $name, int $orders, float $amount): array => [
|
||||
'id' => "M{$id}_11",
|
||||
'admin_id' => $id,
|
||||
'name' => $name,
|
||||
'type' => 'member',
|
||||
'completed_order_count' => $orders,
|
||||
'completed_order_amount' => $amount,
|
||||
'children' => [],
|
||||
];
|
||||
|
||||
$groupRows = [[
|
||||
'id' => 10,
|
||||
'name' => '一诊中心',
|
||||
'children' => [
|
||||
[
|
||||
'id' => 11,
|
||||
'name' => '一组',
|
||||
'completed_order_count' => 8,
|
||||
'completed_order_amount' => 800,
|
||||
'children' => [$member(101, '甲', 5, 500), $member(102, '乙', 3, 300)],
|
||||
],
|
||||
[
|
||||
'id' => 12,
|
||||
'name' => '二组',
|
||||
'completed_order_count' => 6,
|
||||
'completed_order_amount' => 600,
|
||||
'children' => [$member(103, '丙', 6, 600)],
|
||||
],
|
||||
],
|
||||
]];
|
||||
|
||||
conversionRankingExpect(
|
||||
$rankingKind->invoke(null, DataScopeService::SCOPE_SELF, 0) === 'hidden',
|
||||
'Self-only data range must hide rankings'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankingRows->invoke(null, $groupRows, 'hidden') === [],
|
||||
'Hidden ranking mode must not return ranking rows'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankingKind->invoke(null, DataScopeService::SCOPE_DEPT_AND_CHILD, 101) === 'hidden',
|
||||
'Selecting one employee must switch the ranking to personal mode'
|
||||
);
|
||||
|
||||
$memberRows = $rankingRows->invoke(null, $groupRows, 'member');
|
||||
conversionRankingExpect(count($memberRows) === 3, 'Member ranking mode must include visible group members');
|
||||
$rankedMembers = $topRows->invoke(null, $memberRows, 'completed_order_count');
|
||||
conversionRankingExpect(
|
||||
array_column($rankedMembers, 'name') === ['丙', '甲', '乙'],
|
||||
'Member ranking must be ordered by the selected metric'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankedMembers[0]['id'] === 'M103_11',
|
||||
'Member ranking must preserve its string row key'
|
||||
);
|
||||
|
||||
$teamRows = $rankingRows->invoke(null, $groupRows, 'group');
|
||||
conversionRankingExpect(
|
||||
array_column($teamRows, 'name') === ['一组', '二组'],
|
||||
'Group ranking mode must use direct child departments'
|
||||
);
|
||||
conversionRankingExpect(
|
||||
$rankingKind->invoke(null, DataScopeService::SCOPE_ALL, 0) === 'group',
|
||||
'All-data range must use the group ranking dimension'
|
||||
);
|
||||
|
||||
echo "FirstVisitConversionRankingTest passed\n";
|
||||
Reference in New Issue
Block a user