feat(dashboard): 增加负责人视角真实数据接口

This commit is contained in:
kuaifan 2026-07-18 23:42:58 +00:00
parent 4b92ecb422
commit 7ad27e3326
7 changed files with 848 additions and 1 deletions

View 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,
]));
}
}

View 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'));
}
}

View File

@ -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');
});
}
};

View File

@ -1041,3 +1041,5 @@ scene 暂未实现:(*)
upload_id 不能为空
合并繁忙,请稍后再试
仅项目负责人或任务相关成员删除
未开启部门负责人视角功能
没有可查看的部门数据

View File

@ -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建议 |
## dashboardDashboardController
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/dashboard/team/stats | team__stats() | get | 负责人视角统计 |
| api/dashboard/team/tasks | team__tasks() | get | 负责人视角任务列表 |
## systemSystemController
| URL | 方法名 | HTTP | 说明 |

View File

@ -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);

View 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(),
]);
}
}