- getHourlyAggregation with timezone-correct CONVERT_TZ - getDayHourAggregation with weekStart-relative day index - getUserCustomers and getUserActivities cascade queries - activity/customer filter params on getDailyAggregation - Inject EntityManagerInterface for testable DBAL access
239 lines
8.5 KiB
PHP
239 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace KimaiPlugin\KimaiHeatmapBundle\Service;
|
|
|
|
use App\Entity\User;
|
|
use App\Repository\TimesheetRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
class HeatmapService
|
|
{
|
|
public function __construct(
|
|
private readonly TimesheetRepository $repository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{date: string, hours: float, count: int}>
|
|
*/
|
|
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
|
|
{
|
|
$qb = $this->repository->createQueryBuilder('t');
|
|
|
|
$qb
|
|
->select('COALESCE(SUM(t.duration), 0) as duration')
|
|
->addSelect('COUNT(t.id) as count')
|
|
->addSelect('DATE(t.date) as day')
|
|
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
|
|
->andWhere($qb->expr()->eq('t.user', ':user'))
|
|
->andWhere($qb->expr()->isNotNull('t.end'))
|
|
->setParameter('begin', $begin->format('Y-m-d'))
|
|
->setParameter('end', $end->format('Y-m-d'))
|
|
->setParameter('user', $user)
|
|
->groupBy('day')
|
|
->orderBy('day', 'ASC')
|
|
;
|
|
|
|
if ($projectId !== null) {
|
|
$qb->andWhere($qb->expr()->eq('t.project', ':project'))
|
|
->setParameter('project', $projectId);
|
|
}
|
|
|
|
if ($activityId !== null) {
|
|
$qb->andWhere($qb->expr()->eq('t.activity', ':activity'))
|
|
->setParameter('activity', $activityId);
|
|
}
|
|
|
|
if ($customerId !== null) {
|
|
$qb->join('t.project', 'p')
|
|
->andWhere($qb->expr()->eq('p.customer', ':customer'))
|
|
->setParameter('customer', $customerId);
|
|
}
|
|
|
|
$results = $qb->getQuery()->getResult();
|
|
|
|
return array_map(function (array $row) {
|
|
return [
|
|
'date' => $row['day'],
|
|
'hours' => round((int) $row['duration'] / 3600, 2),
|
|
'count' => (int) $row['count'],
|
|
];
|
|
}, $results);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{hour: int, hours: float, count: int}>
|
|
*/
|
|
public function getHourlyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
|
|
{
|
|
$conn = $this->entityManager->getConnection();
|
|
$offsetStr = $this->computeTimezoneOffset($user);
|
|
|
|
$sql = 'SELECT HOUR(CONVERT_TZ(t.start_time, \'+00:00\', :tz)) as hour_slot,
|
|
COALESCE(SUM(t.duration), 0) as duration,
|
|
COUNT(t.id) as count
|
|
FROM kimai2_timesheet t
|
|
WHERE t.user = :user
|
|
AND t.date_tz BETWEEN :begin AND :end
|
|
AND t.end_time IS NOT NULL';
|
|
|
|
$params = [
|
|
'tz' => $offsetStr,
|
|
'user' => $user->getId(),
|
|
'begin' => $begin->format('Y-m-d'),
|
|
'end' => $end->format('Y-m-d'),
|
|
];
|
|
|
|
if ($projectId !== null) {
|
|
$sql .= ' AND t.project_id = :project';
|
|
$params['project'] = $projectId;
|
|
}
|
|
|
|
if ($activityId !== null) {
|
|
$sql .= ' AND t.activity_id = :activity';
|
|
$params['activity'] = $activityId;
|
|
}
|
|
|
|
if ($customerId !== null) {
|
|
$sql .= ' AND t.project_id IN (SELECT p.id FROM kimai2_projects p WHERE p.customer_id = :customer)';
|
|
$params['customer'] = $customerId;
|
|
}
|
|
|
|
$sql .= ' GROUP BY hour_slot ORDER BY hour_slot ASC';
|
|
|
|
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
|
|
|
|
return array_map(fn(array $row) => [
|
|
'hour' => (int) $row['hour_slot'],
|
|
'hours' => round((int) $row['duration'] / 3600, 2),
|
|
'count' => (int) $row['count'],
|
|
], $results);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{day: int, hour: int, hours: float, count: int}>
|
|
*/
|
|
public function getDayHourAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null, string $weekStart = 'monday'): array
|
|
{
|
|
$conn = $this->entityManager->getConnection();
|
|
$offsetStr = $this->computeTimezoneOffset($user);
|
|
|
|
$sql = 'SELECT DAYOFWEEK(t.date_tz) as dow,
|
|
HOUR(CONVERT_TZ(t.start_time, \'+00:00\', :tz)) as hour_slot,
|
|
COALESCE(SUM(t.duration), 0) as duration,
|
|
COUNT(t.id) as count
|
|
FROM kimai2_timesheet t
|
|
WHERE t.user = :user
|
|
AND t.date_tz BETWEEN :begin AND :end
|
|
AND t.end_time IS NOT NULL';
|
|
|
|
$params = [
|
|
'tz' => $offsetStr,
|
|
'user' => $user->getId(),
|
|
'begin' => $begin->format('Y-m-d'),
|
|
'end' => $end->format('Y-m-d'),
|
|
];
|
|
|
|
if ($projectId !== null) {
|
|
$sql .= ' AND t.project_id = :project';
|
|
$params['project'] = $projectId;
|
|
}
|
|
|
|
if ($activityId !== null) {
|
|
$sql .= ' AND t.activity_id = :activity';
|
|
$params['activity'] = $activityId;
|
|
}
|
|
|
|
if ($customerId !== null) {
|
|
$sql .= ' AND t.project_id IN (SELECT p.id FROM kimai2_projects p WHERE p.customer_id = :customer)';
|
|
$params['customer'] = $customerId;
|
|
}
|
|
|
|
$sql .= ' GROUP BY dow, hour_slot ORDER BY dow, hour_slot';
|
|
|
|
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
|
|
|
|
$weekStartOffset = ($weekStart === 'sunday') ? 1 : 2;
|
|
|
|
return array_map(fn(array $row) => [
|
|
'day' => ((int) $row['dow'] - $weekStartOffset + 7) % 7,
|
|
'hour' => (int) $row['hour_slot'],
|
|
'hours' => round((int) $row['duration'] / 3600, 2),
|
|
'count' => (int) $row['count'],
|
|
], $results);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, name: string}>
|
|
*/
|
|
public function getUserProjects(User $user): array
|
|
{
|
|
$qb = $this->repository->createQueryBuilder('t');
|
|
$qb->select('DISTINCT IDENTITY(t.project) as projectId, p.name')
|
|
->join('t.project', 'p')
|
|
->andWhere($qb->expr()->eq('t.user', ':user'))
|
|
->andWhere($qb->expr()->isNotNull('t.end'))
|
|
->setParameter('user', $user)
|
|
->orderBy('p.name', 'ASC');
|
|
|
|
return array_map(fn(array $row) => [
|
|
'id' => (int) $row['projectId'],
|
|
'name' => $row['name'],
|
|
], $qb->getQuery()->getResult());
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, name: string}>
|
|
*/
|
|
public function getUserCustomers(User $user): array
|
|
{
|
|
$qb = $this->repository->createQueryBuilder('t');
|
|
$qb->select('DISTINCT IDENTITY(p.customer) as customerId, c.name')
|
|
->join('t.project', 'p')
|
|
->join('p.customer', 'c')
|
|
->andWhere($qb->expr()->eq('t.user', ':user'))
|
|
->andWhere($qb->expr()->isNotNull('t.end'))
|
|
->setParameter('user', $user)
|
|
->orderBy('c.name', 'ASC');
|
|
|
|
return array_map(fn(array $row) => [
|
|
'id' => (int) $row['customerId'],
|
|
'name' => $row['name'],
|
|
], $qb->getQuery()->getResult());
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, name: string}>
|
|
*/
|
|
public function getUserActivities(User $user, ?int $projectId = null): array
|
|
{
|
|
$qb = $this->repository->createQueryBuilder('t');
|
|
$qb->select('DISTINCT IDENTITY(t.activity) as activityId, a.name')
|
|
->join('t.activity', 'a')
|
|
->andWhere($qb->expr()->eq('t.user', ':user'))
|
|
->andWhere($qb->expr()->isNotNull('t.end'))
|
|
->setParameter('user', $user)
|
|
->orderBy('a.name', 'ASC');
|
|
|
|
if ($projectId !== null) {
|
|
$qb->andWhere($qb->expr()->eq('t.project', ':project'))
|
|
->setParameter('project', $projectId);
|
|
}
|
|
|
|
return array_map(fn(array $row) => [
|
|
'id' => (int) $row['activityId'],
|
|
'name' => $row['name'],
|
|
], $qb->getQuery()->getResult());
|
|
}
|
|
|
|
private function computeTimezoneOffset(User $user): string
|
|
{
|
|
$tz = new \DateTimeZone($user->getTimezone());
|
|
$offset = $tz->getOffset(new \DateTime('now', new \DateTimeZone('UTC')));
|
|
$hours = intdiv($offset, 3600);
|
|
$minutes = abs(intdiv($offset % 3600, 60));
|
|
|
|
return sprintf('%+03d:%02d', $hours, $minutes);
|
|
}
|
|
}
|