mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-21 21:48:00 +00:00
feat(dashboard): 增加负责人视角真实数据接口
This commit is contained in:
parent
4b92ecb422
commit
7ad27e3326
86
app/Http/Controllers/Api/DashboardController.php
Normal file
86
app/Http/Controllers/Api/DashboardController.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\DashboardTeam;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine dashboard
|
||||
*
|
||||
* 仪表盘
|
||||
*/
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/dashboard/team/stats 负责人视角统计
|
||||
*
|
||||
* @apiDescription 需要token身份。返回所选管理部门(含下级部门)的团队任务统计。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dashboard
|
||||
* @apiName team__stats
|
||||
*
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID,逗号分隔;不传表示全部
|
||||
* @apiParam {Number} [refresh] 传 1 时主动刷新当前统计缓存
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 团队统计数据
|
||||
*/
|
||||
public function team__stats()
|
||||
{
|
||||
$user = User::auth();
|
||||
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
|
||||
return Base::retSuccess('success', DashboardTeam::stats($context, intval(Request::input('refresh')) === 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dashboard/team/tasks 负责人视角任务列表
|
||||
*
|
||||
* @apiDescription 需要token身份。按关注类型、成员或优先级分页返回团队任务。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dashboard
|
||||
* @apiName team__tasks
|
||||
*
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID,逗号分隔;不传表示全部
|
||||
* @apiParam {String} [type] 任务类型:uncompleted/overdue/soon/hi/noowner
|
||||
* @apiParam {Number} [member_id] 成员ID;传入后优先于 type
|
||||
* @apiParam {Number} [level] 优先级;-1 表示未设置,传入后优先于 type
|
||||
* @apiParam {Number} [page] 当前页
|
||||
* @apiParam {Number} [pagesize] 每页数量,默认20,最大50
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 分页任务数据
|
||||
*/
|
||||
public function team__tasks()
|
||||
{
|
||||
$user = User::auth();
|
||||
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
|
||||
|
||||
$memberId = intval(Request::input('member_id'));
|
||||
$levelValue = Request::input('level');
|
||||
$level = $levelValue !== null && $levelValue !== '' ? intval($levelValue) : null;
|
||||
$type = trim((string)Request::input('type'));
|
||||
|
||||
if ($memberId > 0) {
|
||||
if (!in_array($memberId, $context['member_userids'], true)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
} elseif ($level !== null) {
|
||||
if ($level !== -1 && !in_array($level, DashboardTeam::priorityLevels(), true)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
} elseif (!in_array($type, ['uncompleted', 'overdue', 'soon', 'hi', 'noowner'], true)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', DashboardTeam::tasks($context, [
|
||||
'type' => $type,
|
||||
'member_id' => $memberId,
|
||||
'level' => $level,
|
||||
]));
|
||||
}
|
||||
}
|
||||
538
app/Module/DashboardTeam.php
Normal file
538
app/Module/DashboardTeam.php
Normal file
@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 仪表盘负责人视角数据聚合。
|
||||
*
|
||||
* 口径:所选管理部门(含下级部门)的在职成员所参与、且允许负责人查看的未归档项目;
|
||||
* 仅统计主任务、全员可见任务,任务需由范围内成员负责或当前没有负责人。
|
||||
*/
|
||||
class DashboardTeam
|
||||
{
|
||||
public const SOON_DAYS = 3;
|
||||
public const HIGH_PRIORITY_COUNT = 2;
|
||||
public const STATS_CACHE_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* 解析并校验负责人视角范围。
|
||||
*/
|
||||
public static function context(User $user, $selectedDepartmentIds = null): array
|
||||
{
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
throw new ApiException('未开启部门负责人视角功能');
|
||||
}
|
||||
|
||||
$managedIds = UserDepartment::getManagedDepartments($user->userid)
|
||||
->pluck('id')
|
||||
->map(fn($id) => intval($id))
|
||||
->values()
|
||||
->toArray();
|
||||
if (empty($managedIds)) {
|
||||
throw new ApiException('没有可查看的部门数据');
|
||||
}
|
||||
|
||||
$selectedIds = self::normalizeSelectedDepartmentIds($selectedDepartmentIds, $managedIds);
|
||||
if (empty($selectedIds)) {
|
||||
throw new ApiException('没有可查看的部门数据');
|
||||
}
|
||||
|
||||
$departmentIds = self::expandDepartmentIds($selectedIds);
|
||||
$members = self::loadMembers($departmentIds);
|
||||
$memberUserids = $members->pluck('userid')->map(fn($id) => intval($id))->values()->toArray();
|
||||
$projectIds = self::loadProjectIds($memberUserids);
|
||||
$ownProjectIds = empty($projectIds) ? [] : ProjectUser::whereUserid($user->userid)
|
||||
->whereIn('project_id', $projectIds)
|
||||
->pluck('project_id')
|
||||
->map(fn($id) => intval($id))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'viewer_userid' => intval($user->userid),
|
||||
'selected_department_ids' => $selectedIds,
|
||||
'department_ids' => $departmentIds,
|
||||
'member_userids' => $memberUserids,
|
||||
'member_map' => $members->keyBy('userid')->map(fn(User $member) => [
|
||||
'userid' => intval($member->userid),
|
||||
'nickname' => $member->nickname,
|
||||
'userimg' => $member->userimg,
|
||||
])->toArray(),
|
||||
'project_ids' => $projectIds,
|
||||
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队统计。
|
||||
*/
|
||||
public static function stats(array $context, bool $refresh = false): array
|
||||
{
|
||||
$cacheKey = 'dashboard:team:stats:v3:' . $context['viewer_userid'] . ':' . sha1(json_encode([
|
||||
$context['selected_department_ids'],
|
||||
$context['department_ids'],
|
||||
$context['member_userids'],
|
||||
$context['project_ids'],
|
||||
]));
|
||||
if ($refresh) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return Cache::remember($cacheKey, now()->addSeconds(self::STATS_CACHE_SECONDS), function () use ($context) {
|
||||
$now = Carbon::now();
|
||||
$taskAlias = DB::getTablePrefix() . 't';
|
||||
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
|
||||
$weekStart = $now->clone()->startOfWeek(Carbon::MONDAY)->startOfDay();
|
||||
$nextWeekStart = $weekStart->clone()->addWeek();
|
||||
$lastWeekStart = $weekStart->clone()->subWeek();
|
||||
$base = self::baseTaskBuilder($context);
|
||||
|
||||
$core = (clone $base)->selectRaw("
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL THEN 1 ELSE 0 END) AS stat_uncompleted,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN 1 ELSE 0 END) AS stat_overdue,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at >= ? AND {$taskAlias}.end_at <= ? THEN 1 ELSE 0 END) AS stat_due_soon,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_week_completed,
|
||||
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_last_week_completed
|
||||
", [
|
||||
$now->toDateTimeString(),
|
||||
$now->toDateTimeString(),
|
||||
$soonEnd->toDateTimeString(),
|
||||
$weekStart->toDateTimeString(),
|
||||
$nextWeekStart->toDateTimeString(),
|
||||
$lastWeekStart->toDateTimeString(),
|
||||
$weekStart->toDateTimeString(),
|
||||
])->first();
|
||||
|
||||
$noOwner = (clone $base)
|
||||
->whereNull('t.complete_at')
|
||||
->whereNotExists(self::ownerExistsQuery('t.id'))
|
||||
->count();
|
||||
$members = self::memberDistribution($context, $now);
|
||||
|
||||
return [
|
||||
'generated_at' => $now->toDateTimeString(),
|
||||
'member_count' => count($context['member_userids']),
|
||||
'blocks' => [
|
||||
'uncompleted' => intval($core->stat_uncompleted ?? 0),
|
||||
'overdue' => intval($core->stat_overdue ?? 0),
|
||||
'overdue_owner_count' => count(array_filter($members, fn($member) => $member['overdue'] > 0)),
|
||||
'due_soon' => intval($core->stat_due_soon ?? 0),
|
||||
'week_completed' => intval($core->stat_week_completed ?? 0),
|
||||
'last_week_completed' => intval($core->stat_last_week_completed ?? 0),
|
||||
'no_owner' => intval($noOwner),
|
||||
],
|
||||
'priority' => self::priorityDistribution($context),
|
||||
'members' => $members,
|
||||
'high_levels' => self::highPriorityLevels(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页任务列表。
|
||||
*/
|
||||
public static function tasks(array $context, array $filters)
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
|
||||
$memberId = intval($filters['member_id'] ?? 0);
|
||||
$level = $filters['level'] ?? null;
|
||||
$type = $filters['type'] ?? '';
|
||||
|
||||
$builder = self::baseTaskBuilder($context)->whereNull('t.complete_at');
|
||||
if ($memberId > 0) {
|
||||
$builder->whereExists(self::ownerExistsQuery('t.id', [$memberId]));
|
||||
} elseif ($level !== null) {
|
||||
if (intval($level) === -1) {
|
||||
$levels = self::priorityLevels();
|
||||
if (!empty($levels)) {
|
||||
$builder->whereNotIn('t.p_level', $levels);
|
||||
}
|
||||
} else {
|
||||
$builder->where('t.p_level', intval($level));
|
||||
}
|
||||
} else {
|
||||
switch ($type) {
|
||||
case 'overdue':
|
||||
$builder->whereNotNull('t.end_at')->where('t.end_at', '<', $now);
|
||||
break;
|
||||
|
||||
case 'soon':
|
||||
$builder->whereNotNull('t.end_at')->whereBetween('t.end_at', [$now, $soonEnd]);
|
||||
break;
|
||||
|
||||
case 'hi':
|
||||
$levels = self::highPriorityLevels();
|
||||
$builder->whereIn('t.p_level', $levels ?: [-1]);
|
||||
break;
|
||||
|
||||
case 'noowner':
|
||||
$builder->whereNotExists(self::ownerExistsQuery('t.id'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$taskAlias = DB::getTablePrefix() . 't';
|
||||
$builder->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
|
||||
->select([
|
||||
't.id',
|
||||
't.parent_id',
|
||||
't.project_id',
|
||||
't.column_id',
|
||||
't.name',
|
||||
't.end_at',
|
||||
't.p_level',
|
||||
't.p_name',
|
||||
't.p_color',
|
||||
't.flow_item_id',
|
||||
't.flow_item_name',
|
||||
'p.name as project_name',
|
||||
'fi.status as flow_item_status',
|
||||
'fi.color as flow_item_color',
|
||||
])
|
||||
->orderByRaw("{$taskAlias}.end_at IS NULL")
|
||||
->orderBy('t.end_at')
|
||||
->orderByDesc('t.id');
|
||||
|
||||
$list = $builder->paginate(Base::getPaginate(50, 20));
|
||||
$taskIds = $list->getCollection()->pluck('id')->map(fn($id) => intval($id))->toArray();
|
||||
$owners = self::taskOwners($taskIds, $context['member_userids'], $memberId);
|
||||
$ownProjectMap = $context['own_project_id_map'];
|
||||
|
||||
$list->setCollection($list->getCollection()->map(function ($task) use ($owners, $ownProjectMap) {
|
||||
$item = (array)$task;
|
||||
$item['id'] = intval($item['id']);
|
||||
$item['parent_id'] = intval($item['parent_id']);
|
||||
$item['project_id'] = intval($item['project_id']);
|
||||
$item['column_id'] = intval($item['column_id']);
|
||||
$item['p_level'] = intval($item['p_level']);
|
||||
$flowParts = explode('|', $item['flow_item_name'] ?: '');
|
||||
if (count($flowParts) >= 2) {
|
||||
$item['flow_item_status'] = $item['flow_item_status'] ?: ($flowParts[0] ?? '');
|
||||
$item['flow_item_name'] = $flowParts[1] ?? $item['flow_item_name'];
|
||||
$item['flow_item_color'] = $item['flow_item_color'] ?: ($flowParts[2] ?? '');
|
||||
}
|
||||
$item['owners'] = $owners[$item['id']] ?? [];
|
||||
$item['owner'] = $item['owners'][0] ?? null;
|
||||
$item['department_readonly'] = !isset($ownProjectMap[$item['project_id']]);
|
||||
return $item;
|
||||
}));
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前系统优先级ID列表(保持设置顺序)。
|
||||
* @return array<int>
|
||||
*/
|
||||
public static function priorityLevels(): array
|
||||
{
|
||||
return array_map(fn($item) => intval($item['priority']), self::priorityList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础任务范围:主任务、全员可见、未归档项目,且由团队成员负责或没有负责人。
|
||||
*/
|
||||
protected static function baseTaskBuilder(array $context): Builder
|
||||
{
|
||||
$projectIds = $context['project_ids'] ?: [0];
|
||||
$memberUserids = $context['member_userids'];
|
||||
|
||||
return DB::table('project_tasks as t')
|
||||
->join('projects as p', 'p.id', '=', 't.project_id')
|
||||
->whereIn('t.project_id', $projectIds)
|
||||
->where('t.parent_id', 0)
|
||||
->where('t.visibility', 1)
|
||||
->whereNull('t.archived_at')
|
||||
->whereNull('t.deleted_at')
|
||||
->whereNull('p.archived_at')
|
||||
->whereNull('p.deleted_at')
|
||||
->where(function (Builder $query) {
|
||||
$query->where('p.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('p.department_owner_view');
|
||||
})
|
||||
->where(function (Builder $query) use ($memberUserids) {
|
||||
if (empty($memberUserids)) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereExists(self::ownerExistsQuery('t.id', $memberUserids))
|
||||
->orWhereNotExists(self::ownerExistsQuery('t.id'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责人存在子查询。
|
||||
*/
|
||||
protected static function ownerExistsQuery(string $taskColumn, ?array $userids = null): callable
|
||||
{
|
||||
return function (Builder $query) use ($taskColumn, $userids) {
|
||||
$query->selectRaw('1')
|
||||
->from('project_task_users as owner_scope')
|
||||
->whereColumn('owner_scope.task_id', $taskColumn)
|
||||
->where('owner_scope.owner', 1);
|
||||
if ($userids !== null) {
|
||||
$query->whereIn('owner_scope.userid', $userids ?: [0]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先级分布(包含无负责人任务)。
|
||||
*/
|
||||
protected static function priorityDistribution(array $context): array
|
||||
{
|
||||
$taskAlias = DB::getTablePrefix() . 't';
|
||||
$rows = self::baseTaskBuilder($context)
|
||||
->whereNull('t.complete_at')
|
||||
->selectRaw("{$taskAlias}.p_level, COUNT(*) AS stat_num")
|
||||
->groupBy('t.p_level')
|
||||
->pluck('stat_num', 't.p_level');
|
||||
|
||||
$result = [];
|
||||
$matched = [];
|
||||
foreach (self::priorityList() as $item) {
|
||||
$level = intval($item['priority']);
|
||||
$matched[] = $level;
|
||||
$result[] = [
|
||||
'level' => $level,
|
||||
'name' => $item['name'],
|
||||
'color' => $item['color'],
|
||||
'num' => intval($rows->get($level, 0)),
|
||||
];
|
||||
}
|
||||
|
||||
$unset = 0;
|
||||
foreach ($rows as $level => $num) {
|
||||
if (!in_array(intval($level), $matched, true)) {
|
||||
$unset += intval($num);
|
||||
}
|
||||
}
|
||||
if ($unset > 0) {
|
||||
$result[] = [
|
||||
'level' => -1,
|
||||
'name' => '',
|
||||
'color' => '#c5c8ce',
|
||||
'num' => $unset,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员任务分配。流程阶段与超期风险独立统计,无工作流的普通未完成任务归入待处理;
|
||||
* 多负责人任务会分别计入每个负责人的工作量,团队总数仍按任务去重。
|
||||
*/
|
||||
protected static function memberDistribution(array $context, Carbon $now): array
|
||||
{
|
||||
$memberUserids = $context['member_userids'];
|
||||
if (empty($memberUserids) || empty($context['project_ids'])) {
|
||||
return array_values(array_map(fn($member) => array_merge($member, [
|
||||
'total' => 0,
|
||||
'overdue' => 0,
|
||||
'segments' => ['progress' => 0, 'start' => 0, 'test' => 0],
|
||||
]), $context['member_map']));
|
||||
}
|
||||
|
||||
$nowText = $now->toDateTimeString();
|
||||
$prefix = DB::getTablePrefix();
|
||||
$taskAlias = $prefix . 't';
|
||||
$taskUserAlias = $prefix . 'tu';
|
||||
$flowAlias = $prefix . 'fi';
|
||||
$rows = DB::table('project_task_users as tu')
|
||||
->join('project_tasks as t', 't.id', '=', 'tu.task_id')
|
||||
->join('projects as p', 'p.id', '=', 't.project_id')
|
||||
->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
|
||||
->whereIn('tu.userid', $memberUserids)
|
||||
->where('tu.owner', 1)
|
||||
->whereIn('t.project_id', $context['project_ids'])
|
||||
->where('t.parent_id', 0)
|
||||
->where('t.visibility', 1)
|
||||
->whereNull('t.complete_at')
|
||||
->whereNull('t.archived_at')
|
||||
->whereNull('t.deleted_at')
|
||||
->whereNull('p.archived_at')
|
||||
->whereNull('p.deleted_at')
|
||||
->where(function (Builder $query) {
|
||||
$query->where('p.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('p.department_owner_view');
|
||||
})
|
||||
->selectRaw("
|
||||
{$taskUserAlias}.userid,
|
||||
COUNT(DISTINCT {$taskAlias}.id) AS stat_total,
|
||||
COUNT(DISTINCT CASE WHEN {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN {$taskAlias}.id END) AS stat_overdue,
|
||||
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'progress' THEN {$taskAlias}.id END) AS stat_progress,
|
||||
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'test' THEN {$taskAlias}.id END) AS stat_test,
|
||||
COUNT(DISTINCT CASE WHEN {$flowAlias}.status IS NULL OR {$flowAlias}.status NOT IN ('progress', 'test') THEN {$taskAlias}.id END) AS stat_start
|
||||
", [$nowText])
|
||||
->groupBy('tu.userid')
|
||||
->get()
|
||||
->keyBy(fn($row) => intval($row->userid));
|
||||
|
||||
$members = [];
|
||||
foreach ($context['member_map'] as $userid => $member) {
|
||||
$row = $rows->get(intval($userid));
|
||||
$members[] = array_merge($member, [
|
||||
'total' => intval($row->stat_total ?? 0),
|
||||
'overdue' => intval($row->stat_overdue ?? 0),
|
||||
'segments' => [
|
||||
'progress' => intval($row->stat_progress ?? 0),
|
||||
'start' => intval($row->stat_start ?? 0),
|
||||
'test' => intval($row->stat_test ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
usort($members, function ($a, $b) {
|
||||
return $b['overdue'] <=> $a['overdue']
|
||||
?: $b['total'] <=> $a['total']
|
||||
?: $a['userid'] <=> $b['userid'];
|
||||
});
|
||||
return $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务负责人数据,范围内成员优先展示;成员筛选时将该成员置首。
|
||||
*/
|
||||
protected static function taskOwners(array $taskIds, array $memberUserids, int $preferredUserid = 0): array
|
||||
{
|
||||
if (empty($taskIds)) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::table('project_task_users')
|
||||
->whereIn('task_id', $taskIds)
|
||||
->where('owner', 1)
|
||||
->orderBy('id')
|
||||
->get(['task_id', 'userid']);
|
||||
$userids = $rows->pluck('userid')->map(fn($id) => intval($id))->unique()->values()->toArray();
|
||||
$users = empty($userids) ? collect() : User::select(User::$basicField)
|
||||
->whereIn('userid', $userids)
|
||||
->get()
|
||||
->keyBy('userid');
|
||||
$memberMap = array_fill_keys($memberUserids, true);
|
||||
$owners = [];
|
||||
foreach ($rows as $row) {
|
||||
$userid = intval($row->userid);
|
||||
$user = $users->get($userid);
|
||||
$owners[intval($row->task_id)][] = [
|
||||
'userid' => $userid,
|
||||
'nickname' => $user?->nickname ?? '',
|
||||
];
|
||||
}
|
||||
foreach ($owners as &$list) {
|
||||
usort($list, function ($a, $b) use ($memberMap, $preferredUserid) {
|
||||
if ($preferredUserid > 0) {
|
||||
$preferred = ($b['userid'] === $preferredUserid) <=> ($a['userid'] === $preferredUserid);
|
||||
if ($preferred !== 0) {
|
||||
return $preferred;
|
||||
}
|
||||
}
|
||||
$managed = isset($memberMap[$b['userid']]) <=> isset($memberMap[$a['userid']]);
|
||||
return $managed !== 0 ? $managed : $a['userid'] <=> $b['userid'];
|
||||
});
|
||||
}
|
||||
unset($list);
|
||||
return $owners;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在全部部门中一次性展开下级部门,避免递归 N+1。
|
||||
*/
|
||||
protected static function expandDepartmentIds(array $selectedIds): array
|
||||
{
|
||||
$children = UserDepartment::select(['id', 'parent_id'])
|
||||
->get()
|
||||
->groupBy(fn(UserDepartment $department) => intval($department->parent_id));
|
||||
$result = [];
|
||||
$queue = array_values(array_unique(array_map('intval', $selectedIds)));
|
||||
while (!empty($queue)) {
|
||||
$departmentId = array_shift($queue);
|
||||
if ($departmentId <= 0 || isset($result[$departmentId])) {
|
||||
continue;
|
||||
}
|
||||
$result[$departmentId] = true;
|
||||
foreach ($children->get($departmentId, collect()) as $child) {
|
||||
$queue[] = intval($child->id);
|
||||
}
|
||||
}
|
||||
return array_keys($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前范围内的在职非机器人成员。users.department 为现有权限口径的权威数据源。
|
||||
*/
|
||||
protected static function loadMembers(array $departmentIds): Collection
|
||||
{
|
||||
if (empty($departmentIds)) {
|
||||
return collect();
|
||||
}
|
||||
return User::select(User::$basicField)
|
||||
->whereNull('disable_at')
|
||||
->where('bot', 0)
|
||||
->where(function ($query) use ($departmentIds) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$query->orWhere('department', 'like', "%,{$departmentId},%");
|
||||
}
|
||||
})
|
||||
->orderBy('userid')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员参与且允许负责人查看的活动项目。
|
||||
*/
|
||||
protected static function loadProjectIds(array $memberUserids): array
|
||||
{
|
||||
if (empty($memberUserids)) {
|
||||
return [];
|
||||
}
|
||||
return DB::table('project_users as pu')
|
||||
->join('projects as p', 'p.id', '=', 'pu.project_id')
|
||||
->whereIn('pu.userid', $memberUserids)
|
||||
->whereNull('p.archived_at')
|
||||
->whereNull('p.deleted_at')
|
||||
->where(function (Builder $query) {
|
||||
$query->where('p.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('p.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->orderBy('p.id')
|
||||
->pluck('p.id')
|
||||
->map(fn($id) => intval($id))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected static function normalizeSelectedDepartmentIds($selectedIds, array $managedIds): array
|
||||
{
|
||||
if ($selectedIds === null || $selectedIds === '' || $selectedIds === 'all' || $selectedIds === []) {
|
||||
return $managedIds;
|
||||
}
|
||||
if (!is_array($selectedIds)) {
|
||||
$selectedIds = explode(',', (string)$selectedIds);
|
||||
}
|
||||
return array_values(array_unique(array_intersect(
|
||||
array_map('intval', $selectedIds),
|
||||
$managedIds
|
||||
)));
|
||||
}
|
||||
|
||||
protected static function highPriorityLevels(): array
|
||||
{
|
||||
return array_slice(self::priorityLevels(), 0, self::HIGH_PRIORITY_COUNT);
|
||||
}
|
||||
|
||||
protected static function priorityList(): array
|
||||
{
|
||||
return Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_tasks', function (Blueprint $table) {
|
||||
$table->index(
|
||||
['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'end_at'],
|
||||
'idx_pt_dashboard_due'
|
||||
);
|
||||
$table->index(
|
||||
['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'p_level'],
|
||||
'idx_pt_dashboard_priority'
|
||||
);
|
||||
});
|
||||
|
||||
Schema::table('project_task_users', function (Blueprint $table) {
|
||||
$table->index(['task_id', 'owner', 'userid'], 'idx_ptu_task_owner_user');
|
||||
$table->index(['userid', 'owner', 'task_id'], 'idx_ptu_user_owner_task');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_tasks', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_pt_dashboard_due');
|
||||
$table->dropIndex('idx_pt_dashboard_priority');
|
||||
});
|
||||
|
||||
Schema::table('project_task_users', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_ptu_task_owner_user');
|
||||
$table->dropIndex('idx_ptu_user_owner_task');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1041,3 +1041,5 @@ scene 暂未实现:(*)
|
||||
upload_id 不能为空
|
||||
合并繁忙,请稍后再试
|
||||
仅项目负责人或任务相关成员删除
|
||||
未开启部门负责人视角功能
|
||||
没有可查看的部门数据
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||
|
||||
接口总数:312
|
||||
接口总数:314
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -163,6 +163,13 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
||||
| api/project/task/ai_apply | task__ai_apply() | post | 采纳AI建议 |
|
||||
| api/project/task/ai_dismiss | task__ai_dismiss() | post | 忽略AI建议 |
|
||||
|
||||
## dashboard(DashboardController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| api/dashboard/team/stats | team__stats() | get | 负责人视角统计 |
|
||||
| api/dashboard/team/tasks | team__tasks() | get | 负责人视角任务列表 |
|
||||
|
||||
## system(SystemController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
|
||||
@ -16,6 +16,7 @@ use App\Http\Controllers\Api\ComplaintController;
|
||||
use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\AppsController;
|
||||
use App\Http\Controllers\Api\UploadController;
|
||||
use App\Http\Controllers\Api\DashboardController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -39,6 +40,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
// 项目
|
||||
Route::any('project/{method}', ProjectController::class);
|
||||
Route::any('project/{method}/{action}', ProjectController::class);
|
||||
// 仪表盘
|
||||
Route::any('dashboard/{method}', DashboardController::class);
|
||||
Route::any('dashboard/{method}/{action}', DashboardController::class);
|
||||
// 系统
|
||||
Route::any('system/{method}', SystemController::class);
|
||||
Route::any('system/{method}/{action}', SystemController::class);
|
||||
|
||||
170
tests/Feature/DashboardTeamTest.php
Normal file
170
tests/Feature/DashboardTeamTest.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Module\DashboardTeam;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTeamTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
Carbon::setTestNow(Carbon::parse('2026-07-15 12:00:00'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_stats_and_lists_use_the_same_team_scope(): void
|
||||
{
|
||||
$memberA = $this->createUser('dashboard_member_a');
|
||||
$memberB = $this->createUser('dashboard_member_b');
|
||||
$outside = $this->createUser('dashboard_outside');
|
||||
$projectId = DB::table('projects')->insertGetId([
|
||||
'name' => 'Dashboard Team Project',
|
||||
'userid' => $memberA,
|
||||
'department_owner_view' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$overdue = $this->createTask($projectId, [
|
||||
'name' => 'Managed overdue',
|
||||
'end_at' => Carbon::now()->subDay(),
|
||||
'p_level' => 1,
|
||||
]);
|
||||
$this->addOwner($projectId, $overdue, $memberA);
|
||||
|
||||
$noOwnerSoon = $this->createTask($projectId, [
|
||||
'name' => 'No owner soon',
|
||||
'end_at' => Carbon::now()->addDay(),
|
||||
'p_level' => 2,
|
||||
]);
|
||||
|
||||
$outsideTask = $this->createTask($projectId, ['name' => 'Outside owner']);
|
||||
$this->addOwner($projectId, $outsideTask, $outside);
|
||||
|
||||
$subtask = $this->createTask($projectId, [
|
||||
'name' => 'Subtask',
|
||||
'parent_id' => $overdue,
|
||||
]);
|
||||
$this->addOwner($projectId, $subtask, $memberA);
|
||||
|
||||
$privateTask = $this->createTask($projectId, [
|
||||
'name' => 'Private task',
|
||||
'visibility' => 2,
|
||||
]);
|
||||
$this->addOwner($projectId, $privateTask, $memberA);
|
||||
|
||||
$weekCompleted = $this->createTask($projectId, [
|
||||
'name' => 'Completed this week',
|
||||
'complete_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
$this->addOwner($projectId, $weekCompleted, $memberA);
|
||||
|
||||
$lastWeekCompleted = $this->createTask($projectId, [
|
||||
'name' => 'Completed last week',
|
||||
'complete_at' => Carbon::parse('2026-07-10 10:00:00'),
|
||||
]);
|
||||
$this->addOwner($projectId, $lastWeekCompleted, $memberA);
|
||||
|
||||
$context = [
|
||||
'viewer_userid' => $memberA,
|
||||
'selected_department_ids' => [1],
|
||||
'department_ids' => [1],
|
||||
'member_userids' => [$memberA, $memberB],
|
||||
'member_map' => [
|
||||
$memberA => ['userid' => $memberA, 'nickname' => 'Member A', 'userimg' => ''],
|
||||
$memberB => ['userid' => $memberB, 'nickname' => 'Member B', 'userimg' => ''],
|
||||
],
|
||||
'project_ids' => [$projectId],
|
||||
'own_project_id_map' => [$projectId => true],
|
||||
];
|
||||
|
||||
$stats = DashboardTeam::stats($context);
|
||||
$this->assertSame(2, $stats['blocks']['uncompleted']);
|
||||
$this->assertSame(1, $stats['blocks']['overdue']);
|
||||
$this->assertSame(1, $stats['blocks']['due_soon']);
|
||||
$this->assertSame(1, $stats['blocks']['no_owner']);
|
||||
$this->assertSame(1, $stats['blocks']['week_completed']);
|
||||
$this->assertSame(1, $stats['blocks']['last_week_completed']);
|
||||
$this->assertSame(2, array_sum(array_column($stats['priority'], 'num')));
|
||||
$memberStats = collect($stats['members'])->firstWhere('userid', $memberA);
|
||||
$this->assertSame(1, $memberStats['total']);
|
||||
$this->assertSame(1, $memberStats['overdue']);
|
||||
$this->assertSame(['progress' => 0, 'start' => 1, 'test' => 0], $memberStats['segments']);
|
||||
$this->assertSame($memberStats['total'], array_sum($memberStats['segments']));
|
||||
|
||||
$uncompleted = DashboardTeam::tasks($context, ['type' => 'uncompleted', 'member_id' => 0, 'level' => null]);
|
||||
$overdueList = DashboardTeam::tasks($context, ['type' => 'overdue', 'member_id' => 0, 'level' => null]);
|
||||
$soonList = DashboardTeam::tasks($context, ['type' => 'soon', 'member_id' => 0, 'level' => null]);
|
||||
$noOwnerList = DashboardTeam::tasks($context, ['type' => 'noowner', 'member_id' => 0, 'level' => null]);
|
||||
$memberList = DashboardTeam::tasks($context, ['type' => '', 'member_id' => $memberA, 'level' => null]);
|
||||
|
||||
$this->assertSame(2, $uncompleted->total());
|
||||
$this->assertSame(1, $overdueList->total());
|
||||
$this->assertSame(1, $soonList->total());
|
||||
$this->assertSame(1, $noOwnerList->total());
|
||||
$this->assertSame(1, $memberList->total());
|
||||
$this->assertSame($noOwnerSoon, $noOwnerList->items()[0]['id']);
|
||||
|
||||
$newTask = $this->createTask($projectId, ['name' => 'Refresh cache task']);
|
||||
$this->addOwner($projectId, $newTask, $memberA);
|
||||
$this->assertSame(2, DashboardTeam::stats($context)['blocks']['uncompleted']);
|
||||
$this->assertSame(3, DashboardTeam::stats($context, true)['blocks']['uncompleted']);
|
||||
|
||||
$nextTask = $this->createTask($projectId, ['name' => 'Next refresh cache task']);
|
||||
$this->addOwner($projectId, $nextTask, $memberA);
|
||||
$this->assertSame(4, DashboardTeam::stats($context, true)['blocks']['uncompleted']);
|
||||
}
|
||||
|
||||
private function createUser(string $name): int
|
||||
{
|
||||
return DB::table('users')->insertGetId([
|
||||
'email' => $name . '_' . uniqid() . '@test.local',
|
||||
'nickname' => $name,
|
||||
'password' => md5('123456'),
|
||||
'department' => '',
|
||||
'bot' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], 'userid');
|
||||
}
|
||||
|
||||
private function createTask(int $projectId, array $data): int
|
||||
{
|
||||
return DB::table('project_tasks')->insertGetId(array_merge([
|
||||
'project_id' => $projectId,
|
||||
'parent_id' => 0,
|
||||
'name' => 'Dashboard task',
|
||||
'visibility' => 1,
|
||||
'p_level' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $data));
|
||||
}
|
||||
|
||||
private function addOwner(int $projectId, int $taskId, int $userid): void
|
||||
{
|
||||
DB::table('project_task_users')->insert([
|
||||
'project_id' => $projectId,
|
||||
'task_id' => $taskId,
|
||||
'task_pid' => $taskId,
|
||||
'userid' => $userid,
|
||||
'owner' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user