feat(08-01): add aggregation methods and filter support to HeatmapService
- 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
This commit is contained in:
parent
8a0e5de4a8
commit
c28220c83f
3 changed files with 194 additions and 11 deletions
|
|
@ -4,17 +4,20 @@ 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)
|
||||
{
|
||||
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): array
|
||||
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
|
||||
|
|
@ -37,6 +40,17 @@ class HeatmapService
|
|||
->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) {
|
||||
|
|
@ -48,6 +62,108 @@ class HeatmapService
|
|||
}, $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}>
|
||||
*/
|
||||
|
|
@ -66,4 +182,58 @@ class HeatmapService
|
|||
'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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,7 +248,9 @@ class HeatmapServiceTest extends TestCase
|
|||
$repo = $this->createMock(TimesheetRepository::class);
|
||||
$repo->method('createQueryBuilder')->willReturn($qb);
|
||||
|
||||
return new HeatmapService($repo);
|
||||
$em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
|
||||
return new HeatmapService($repo, $em);
|
||||
}
|
||||
|
||||
private function createServiceWithNativeResults(array $results): HeatmapService
|
||||
|
|
@ -262,12 +264,8 @@ class HeatmapServiceTest extends TestCase
|
|||
$em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->method('getConnection')->willReturn($connection);
|
||||
|
||||
$repo = $this->getMockBuilder(TimesheetRepository::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['getEntityManager'])
|
||||
->getMock();
|
||||
$repo->method('getEntityManager')->willReturn($em);
|
||||
$repo = $this->createMock(TimesheetRepository::class);
|
||||
|
||||
return new HeatmapService($repo);
|
||||
return new HeatmapService($repo, $em);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,22 @@ $autoloadPaths = [
|
|||
|
||||
foreach ($autoloadPaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
require_once $path;
|
||||
$loader = require_once $path;
|
||||
// In worktree contexts, register a prepended autoloader so worktree classes
|
||||
// take priority over the classmap entries pointing to the main repo symlink.
|
||||
$pluginRoot = dirname(__DIR__) . '/';
|
||||
spl_autoload_register(function (string $class) use ($pluginRoot) {
|
||||
$prefix = 'KimaiPlugin\\KimaiHeatmapBundle\\';
|
||||
if (str_starts_with($class, $prefix)) {
|
||||
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
|
||||
$file = $pluginRoot . $relative . '.php';
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, true, true); // prepend=true
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue