Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -15,9 +15,9 @@ use App\Timesheet\CalculatorInterface;
/**
* Implementation to calculate the billable field for a timesheet record.
*/
class BillableCalculator implements CalculatorInterface
final class BillableCalculator implements CalculatorInterface
{
public function calculate(Timesheet $record)
public function calculate(Timesheet $record, array $changeset): void
{
switch ($record->getBillableMode()) {
case Timesheet::BILLABLE_NO:

View File

@@ -18,20 +18,11 @@ use App\Timesheet\RoundingService;
*/
final class DurationCalculator implements CalculatorInterface
{
/**
* @var RoundingService
*/
private $roundings;
public function __construct(RoundingService $roundings)
public function __construct(private RoundingService $roundings)
{
$this->roundings = $roundings;
}
/**
* @param Timesheet $record
*/
public function calculate(Timesheet $record)
public function calculate(Timesheet $record, array $changeset): void
{
if (null === $record->getEnd()) {
return;

View File

@@ -9,7 +9,6 @@
namespace App\Timesheet\Calculator;
use App\Entity\Rate;
use App\Entity\Timesheet;
use App\Timesheet\CalculatorInterface;
use App\Timesheet\RateService;
@@ -17,19 +16,13 @@ use App\Timesheet\RateService;
/**
* Implementation to calculate the rate for a timesheet record.
*/
class RateCalculator implements CalculatorInterface
final class RateCalculator implements CalculatorInterface
{
/**
* @var RateService
*/
private $service;
public function __construct(RateService $service)
public function __construct(private RateService $service)
{
$this->service = $service;
}
public function calculate(Timesheet $record)
public function calculate(Timesheet $record, array $changeset): void
{
$rate = $this->service->calculate($record);

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\Calculator;
use App\Entity\Timesheet;
use App\Timesheet\CalculatorInterface;
final class RateResetCalculator implements CalculatorInterface
{
public function calculate(Timesheet $record, array $changeset): void
{
// check if the rate was changed manually
$changedRate = false;
foreach (['hourlyRate', 'fixedRate', 'internalRate', 'rate'] as $field) {
if (\array_key_exists($field, $changeset)) {
$changedRate = true;
break;
}
}
// if no manual rate changed was applied:
// check if a field changed, that is relevant for the rate calculation: if one was changed =>
// reset all rates, because most users do not even see their rates and would not be able
// to fix or empty the rate, even if they knew that the changed project has another base rate
if (!$changedRate) {
foreach (['project', 'activity', 'user'] as $field) {
if (\array_key_exists($field, $changeset)) {
// this has room for minor improvements: entries with a manual rate might be changed
$record->setRate(0.00);
$record->setInternalRate(null);
$record->setHourlyRate(null);
$record->setFixedRate(null);
$record->setBillableMode(Timesheet::BILLABLE_AUTOMATIC);
break;
}
}
}
}
public function getPriority(): int
{
// needs to run before all other
return 50;
}
}

View File

@@ -19,21 +19,18 @@ interface CalculatorInterface
{
/**
* All necessary changes need to be applied on the given $record.
* The methods return value will not be evaluated.
*
* @param Timesheet $record
* @ param array<string, array<mixed, mixed>> $changeset
* @param array<string, array<mixed, mixed>> $changeset
* @return void
*/
public function calculate(Timesheet $record/*, array $changeset*/);
public function calculate(Timesheet $record, array $changeset): void;
/*
* FIXME use with Kimai 2.0
*
* Default priority is 1000 (after all system Calculator were executed).
* The higher the priority the later it will be executed.
*
* @return int
*/
//public function getPriority(): int;
public function getPriority(): int;
}

View File

@@ -11,18 +11,13 @@ namespace App\Timesheet;
use App\Entity\User;
use DateTime;
use DateTimeInterface;
use DateTimeZone;
class DateTimeFactory
final class DateTimeFactory
{
/**
* @var DateTimeZone
*/
private $timezone;
/**
* @var bool
*/
private $startOnSunday;
private DateTimeZone $timezone;
private bool $startOnSunday = false;
public static function createByUser(User $user): self
{
@@ -34,13 +29,8 @@ class DateTimeFactory
if (null === $timezone) {
$timezone = new \DateTimeZone(date_default_timezone_get());
}
$this->setTimezone($timezone);
$this->startOnSunday = $startOnSunday;
}
protected function setTimezone(DateTimeZone $timezone)
{
$this->timezone = $timezone;
$this->startOnSunday = $startOnSunday;
}
public function getTimezone(): DateTimeZone
@@ -48,13 +38,9 @@ class DateTimeFactory
return $this->timezone;
}
public function getStartOfMonth(?DateTime $date = null): DateTime
public function getStartOfMonth(DateTimeInterface|string|null $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime();
} else {
$date = clone $date;
}
$date = $this->getDate($date);
$date->modify('first day of this month');
$date->setTime(0, 0, 0);
@@ -62,14 +48,22 @@ class DateTimeFactory
return $date;
}
public function getStartOfWeek(?DateTime $date = null): DateTime
private function getDate(DateTimeInterface|string|null $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime('now');
} else {
$date = clone $date;
if ($date === null) {
$date = 'now';
}
if (\is_string($date)) {
return $this->createDateTime($date);
}
return DateTime::createFromInterface($date);
}
public function getStartOfWeek(DateTimeInterface|string|null $date = null): DateTime
{
$date = $this->getDate($date);
$firstDay = 1;
if ($this->startOnSunday) {
@@ -84,14 +78,9 @@ class DateTimeFactory
return $this->createWeekDateTime($date->format('o'), $date->format('W'), $firstDay, 0, 0, 0);
}
public function getEndOfWeek(?DateTime $date = null): DateTime
public function getEndOfWeek(DateTimeInterface|string|null $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime();
} else {
$date = clone $date;
}
$date = $this->getDate($date);
$lastDay = 7;
if ($this->startOnSunday) {
@@ -106,13 +95,10 @@ class DateTimeFactory
return $this->createWeekDateTime($date->format('o'), $date->format('W'), $lastDay, 23, 59, 59);
}
public function getEndOfMonth(?DateTime $date = null): DateTime
public function getEndOfMonth(DateTimeInterface|string|null $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime();
}
$date = $this->getDate($date);
$date = clone $date;
$date = $date->modify('last day of this month');
$date->setTime(23, 59, 59);
@@ -138,31 +124,23 @@ class DateTimeFactory
* @param null|string $datetime
* @return bool|DateTime
*/
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now')
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): bool|DateTime
{
return DateTime::createFromFormat($format, $datetime, $this->getTimezone());
return DateTime::createFromFormat($format, $datetime ?? 'now', $this->getTimezone());
}
public function createStartOfYear(?DateTime $date = null): DateTime
public function createStartOfYear(DateTimeInterface|string|null $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime();
} else {
$date = clone $date;
}
$date = $this->getDate($date);
$date->modify('first day of january 00:00:00');
return $date;
}
public function createEndOfYear(?DateTime $date = null): DateTime
public function createEndOfYear(DateTimeInterface|string|null $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime();
} else {
$date = clone $date;
}
$date = $this->getDate($date);
$date->modify('last day of december 23:59:59');
@@ -189,9 +167,9 @@ class DateTimeFactory
return $financialYear;
}
public function createEndOfFinancialYear(DateTime $financialYear): DateTime
public function createEndOfFinancialYear(DateTimeInterface $financialYear): DateTime
{
$yearEnd = clone $financialYear;
$yearEnd = DateTime::createFromInterface($financialYear);
$yearEnd->modify('+1 year')->modify('-1 day')->setTime(23, 59, 59);
return $yearEnd;

View File

@@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Entity\Bookmark;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\FavoriteTimesheet;
use App\Repository\BookmarkRepository;
use App\Repository\TimesheetRepository;
/**
* @internal
*/
final class FavoriteRecordService
{
public function __construct(private TimesheetRepository $repository, private BookmarkRepository $bookmarkRepository)
{
}
/**
* @param User $user
* @param int $limit
* @return array<FavoriteTimesheet>
*/
public function favoriteEntries(User $user, int $limit = 5): array
{
$favIds = $this->getBookmark($user)->getContent();
$recentIds = [];
if (\count($favIds) < 5) {
$recentIds = $this->repository->getRecentActivityIds($user, null, $limit);
}
$ids = \array_slice(array_unique(array_merge($favIds, $recentIds)), 0, $limit);
$favorites = [];
foreach ($ids as $id) {
$favorites[$id] = \in_array($id, $favIds);
}
if (\count($ids) > 0) {
$timesheets = $this->repository->findTimesheetsById($ids, false, false);
foreach ($timesheets as $timesheet) {
$favorites[$timesheet->getId()] = new FavoriteTimesheet($timesheet, $favorites[$timesheet->getId()]);
}
}
return array_values($favorites);
}
private function getBookmark(User $user): Bookmark
{
$bookmark = $this->bookmarkRepository->findBookmark($user, 'favorite', 'recent');
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setUser($user);
$bookmark->setType('favorite');
$bookmark->setName('timesheet');
}
return $bookmark;
}
public function addFavorite(Timesheet $timesheet): void
{
if ($timesheet->getUser() === null) {
throw new \InvalidArgumentException('Cannot favorite timesheet without user');
}
$bookmark = $this->getBookmark($timesheet->getUser());
$ids = $bookmark->getContent();
if (\in_array($timesheet->getId(), $ids)) {
return;
}
if (\count($ids) >= 5) {
array_pop($ids); // remove the last element and make space for a new id
}
array_unshift($ids, $timesheet->getId());
$bookmark->setContent($ids);
$this->bookmarkRepository->saveBookmark($bookmark);
}
public function removeFavorite(Timesheet $timesheet): void
{
if ($timesheet->getUser() === null) {
throw new \InvalidArgumentException('Cannot favorite timesheet without user');
}
$bookmark = $this->getBookmark($timesheet->getUser());
$ids = $bookmark->getContent();
if (!\in_array($timesheet->getId(), $ids)) {
return;
}
$newIds = [];
foreach ($ids as $id) {
if ($id !== $timesheet->getId()) {
$newIds[] = $id;
}
}
$bookmark->setContent($newIds);
$this->bookmarkRepository->saveBookmark($bookmark);
}
}

View File

@@ -11,36 +11,171 @@ namespace App\Timesheet;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Entity\User;
final class LockdownService
{
private $configuration;
private $isActive;
private ?bool $isActive = null;
public function __construct(SystemConfiguration $configuration)
public function __construct(private SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
public function isLockdownActive(): bool
{
if ($this->isActive === null) {
$this->isActive = $this->configuration->isTimesheetLockdownActive();
$this->isActive = $this->getLockdownPeriodStart() !== null && $this->getLockdownPeriodEnd() !== null;
}
return $this->isActive;
}
public function getLockdownTimezone(): ?string
{
$timezone = $this->configuration->find('timesheet.rules.lockdown_period_timezone');
if ($timezone === null || $timezone === '') {
return null;
}
return (string) $timezone;
}
private function getTimezone(User $user): \DateTimeZone
{
$timezone = $this->getLockdownTimezone();
if ($timezone === null) {
$timezone = $user->getTimezone();
}
return new \DateTimeZone($timezone);
}
public function getLockdownStart(User $user): ?\DateTimeInterface
{
$start = $this->getLockdownPeriodStart();
if ($start === null) {
return null;
}
$start = new \DateTimeImmutable($start, $this->getTimezone($user));
return $start->setTimezone(new \DateTimeZone($user->getTimezone()));
}
private function getLockdownPeriodStart(): ?string
{
$start = $this->configuration->find('timesheet.rules.lockdown_period_start');
if (!\is_string($start) || trim($start) === '') {
return null;
}
$start = explode(',', $start);
if (\count($start) === 1) {
return $start[0];
}
$min = null;
$date = null;
foreach ($start as $dateString) {
$tmp = new \DateTimeImmutable($dateString);
if ($min === null) {
$min = $dateString;
$date = $tmp;
continue;
}
if ($tmp > $date) {
$min = $dateString;
$date = $tmp;
}
}
return $min;
}
public function getLockdownEnd(User $user): ?\DateTimeInterface
{
$end = $this->getLockdownPeriodEnd();
if ($end === null) {
return null;
}
$end = new \DateTimeImmutable($end, $this->getTimezone($user));
return $end->setTimezone(new \DateTimeZone($user->getTimezone()));
}
private function getLockdownPeriodEnd(): ?string
{
$end = $this->configuration->find('timesheet.rules.lockdown_period_end');
if (!\is_string($end) || trim($end) === '') {
return null;
}
$end = explode(',', $end);
if (\count($end) === 1) {
return $end[0];
}
$min = null;
$date = null;
foreach ($end as $dateString) {
$tmp = new \DateTimeImmutable($dateString);
if ($min === null) {
$min = $dateString;
$date = $tmp;
continue;
}
if ($tmp > $date) {
$min = $dateString;
$date = $tmp;
}
}
return $min;
}
public function getLockdownGrace(User $user): ?\DateTimeInterface
{
$gracePeriod = $this->getLockdownGracePeriod();
if ($gracePeriod === null) {
return null;
}
$end = $this->getLockdownEnd($user);
if ($end === null) {
return null;
}
$grace = \DateTimeImmutable::createFromInterface($end);
return $grace->modify($gracePeriod);
}
private function getLockdownGracePeriod(): ?string
{
$grace = $this->configuration->find('timesheet.rules.lockdown_grace_period');
if ($grace === null || $grace === '') {
return null;
}
return (string) $grace;
}
/**
* Does not check if the current user is allowed to edit timesheets in lockdown situations.
* This needs to be performed earlier by yourself (see TimesheetVoter or LockdownValidator).
*
* @param Timesheet $timesheet
* @param \DateTime $now
* @param \DateTimeInterface $now
* @param bool $allowEditInGracePeriod
* @return bool
*/
public function isEditable(Timesheet $timesheet, \DateTime $now, bool $allowEditInGracePeriod = false)
public function isEditable(Timesheet $timesheet, \DateTimeInterface $now, bool $allowEditInGracePeriod = false): bool
{
if (!$this->isLockdownActive()) {
return true;
@@ -52,10 +187,15 @@ final class LockdownService
return true;
}
$lockedStart = $this->configuration->getTimesheetLockdownPeriodStart();
$lockedEnd = $this->configuration->getTimesheetLockdownPeriodEnd();
$gracePeriod = $this->configuration->getTimesheetLockdownGracePeriod();
$timezone = $this->configuration->getTimesheetLockdownTimeZone();
$lockedStart = $this->getLockdownPeriodStart();
$lockedEnd = $this->getLockdownPeriodEnd();
if ($lockedStart === null || $lockedEnd === null) {
return true;
}
$gracePeriod = $this->getLockdownGracePeriod();
$timezone = $this->getLockdownTimezone();
if ($timezone === null) {
$timezone = $timesheetStart->getTimezone();
@@ -64,11 +204,11 @@ final class LockdownService
}
try {
$lockdownStart = new \DateTime($lockedStart, $timezone);
$lockdownEnd = new \DateTime($lockedEnd, $timezone);
$lockdownStart = new \DateTimeImmutable($lockedStart, $timezone);
$lockdownEnd = new \DateTimeImmutable($lockedEnd, $timezone);
$lockdownGrace = clone $lockdownEnd;
if (!empty($gracePeriod)) {
$lockdownGrace->modify($gracePeriod);
$lockdownGrace = $lockdownGrace->modify($gracePeriod);
}
} catch (\Exception $ex) {
// should not happen, but ... if parsing of datetimes fails: skip validation
@@ -86,7 +226,7 @@ final class LockdownService
}
// further validate entries inside of the most recent lockdown
if ($timesheetStart >= $lockdownStart && $timesheetStart <= $lockdownEnd) {
if ($timesheetStart >= $lockdownStart) {
// if grace period is still in effect, validation succeeds
if ($now <= $lockdownGrace) {
return true;

View File

@@ -11,22 +11,10 @@ namespace App\Timesheet;
final class Rate
{
/**
* @var float
*/
private $rate = 0.00;
/**
* @var float
*/
private $internalRate = 0.00;
/**
* @var float|null
*/
private $fixedRate = null;
/**
* @var float|null
*/
private $hourlyRate = null;
private float $rate;
private float $internalRate;
private ?float $hourlyRate;
private ?float $fixedRate;
public function __construct(float $rate, float $internalRate, ?float $hourlyRate = null, ?float $fixedRate = null)
{

View File

@@ -19,19 +19,8 @@ use App\Repository\TimesheetRepository;
*/
final class RateService implements RateServiceInterface
{
/**
* @var array
*/
private $rates;
/**
* @var TimesheetRepository
*/
private $repository;
public function __construct(array $rates, TimesheetRepository $repository)
public function __construct(private array $rates, private TimesheetRepository $repository)
{
$this->rates = $rates;
$this->repository = $repository;
}
public function calculate(Timesheet $record): Rate
@@ -49,13 +38,13 @@ final class RateService implements RateServiceInterface
if (null !== $rate) {
if ($rate->isFixed()) {
$fixedRate = $fixedRate ?? $rate->getRate();
$fixedRate ??= $rate->getRate();
$fixedInternalRate = $rate->getRate();
if (null !== $rate->getInternalRate()) {
$fixedInternalRate = $rate->getInternalRate();
}
} else {
$hourlyRate = $hourlyRate ?? $rate->getRate();
$hourlyRate ??= $rate->getRate();
$internalRate = $rate->getRate();
if (null !== $rate->getInternalRate()) {
$internalRate = $rate->getInternalRate();
@@ -86,8 +75,8 @@ final class RateService implements RateServiceInterface
$factor = $this->getRateFactor($record);
}
$factoredHourlyRate = (float) ($hourlyRate * $factor);
$factoredInternalRate = (float) ($internalRate * $factor);
$factoredHourlyRate = $hourlyRate * $factor;
$factoredInternalRate = $internalRate * $factor;
$totalRate = 0;
$totalInternalRate = 0;
@@ -130,7 +119,7 @@ final class RateService implements RateServiceInterface
$weekday = $record->getEnd()->format('l');
$days = array_map('strtolower', $rateFactor['days']);
if (\in_array(strtolower($weekday), $days)) {
$factor += $rateFactor['factor'];
$factor += (float) $rateFactor['factor'];
}
}
@@ -138,6 +127,6 @@ final class RateService implements RateServiceInterface
$factor = 1.00;
}
return (float) $factor;
return $factor;
}
}

View File

@@ -18,11 +18,7 @@ final class CeilRounding implements RoundingInterface
return 'ceil';
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundBegin(Timesheet $record, $minutes)
public function roundBegin(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -41,11 +37,7 @@ final class CeilRounding implements RoundingInterface
$record->setBegin($newBegin);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundEnd(Timesheet $record, $minutes)
public function roundEnd(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -64,17 +56,13 @@ final class CeilRounding implements RoundingInterface
$record->setEnd($newEnd);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundDuration(Timesheet $record, $minutes)
public function roundDuration(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getDuration();
$timestamp = $record->getDuration() ?? 0;
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;

View File

@@ -18,11 +18,7 @@ final class ClosestRounding implements RoundingInterface
return 'closest';
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundBegin(Timesheet $record, $minutes)
public function roundBegin(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -45,11 +41,7 @@ final class ClosestRounding implements RoundingInterface
$record->setBegin($newBegin);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundEnd(Timesheet $record, $minutes)
public function roundEnd(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -72,17 +64,13 @@ final class ClosestRounding implements RoundingInterface
$record->setEnd($newEnd);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundDuration(Timesheet $record, $minutes)
public function roundDuration(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getDuration();
$timestamp = $record->getDuration() ?? 0;
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;

View File

@@ -18,11 +18,7 @@ final class DefaultRounding implements RoundingInterface
return 'default';
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundBegin(Timesheet $record, $minutes)
public function roundBegin(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -41,11 +37,7 @@ final class DefaultRounding implements RoundingInterface
$record->setBegin($newBegin);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundEnd(Timesheet $record, $minutes)
public function roundEnd(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -64,17 +56,13 @@ final class DefaultRounding implements RoundingInterface
$record->setEnd($newEnd);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundDuration(Timesheet $record, $minutes)
public function roundDuration(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getDuration();
$timestamp = $record->getDuration() ?? 0;
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;

View File

@@ -18,11 +18,7 @@ final class FloorRounding implements RoundingInterface
return 'floor';
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundBegin(Timesheet $record, $minutes)
public function roundBegin(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -41,11 +37,7 @@ final class FloorRounding implements RoundingInterface
$record->setBegin($newBegin);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundEnd(Timesheet $record, $minutes)
public function roundEnd(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
@@ -64,17 +56,13 @@ final class FloorRounding implements RoundingInterface
$record->setEnd($newEnd);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundDuration(Timesheet $record, $minutes)
public function roundDuration(Timesheet $record, int $minutes): void
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getDuration();
$timestamp = $record->getDuration() ?? 0;
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;

View File

@@ -16,26 +16,11 @@ use App\Entity\Timesheet;
*/
interface RoundingInterface
{
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundBegin(Timesheet $record, $minutes);
public function roundBegin(Timesheet $record, int $minutes): void;
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundEnd(Timesheet $record, $minutes);
public function roundEnd(Timesheet $record, int $minutes): void;
/**
* @param Timesheet $record
* @param int $minutes
*/
public function roundDuration(Timesheet $record, $minutes);
public function roundDuration(Timesheet $record, int $minutes): void;
/**
* @return string
*/
public function getId(): string;
}

View File

@@ -15,33 +15,18 @@ use App\Timesheet\Rounding\RoundingInterface;
final class RoundingService
{
/**
* @var array
*/
private $rules;
/**
* @var array
*/
private $rulesCache;
/**
* @var SystemConfiguration
*/
private $configuration;
/**
* @var RoundingInterface[]
*/
private $roundingModes;
/**
* @param SystemConfiguration $configuration
* @param RoundingInterface[] $roundingModes
* @param array $rules
*/
public function __construct(SystemConfiguration $configuration, iterable $roundingModes, array $rules)
public function __construct(private SystemConfiguration $configuration, private iterable $roundingModes, private array $rules)
{
$this->configuration = $configuration;
$this->roundingModes = $roundingModes;
$this->rules = $rules;
}
private function getRoundingRules(): array
@@ -71,6 +56,9 @@ final class RoundingService
public function roundBegin(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {
if ($record->getBegin() === null) {
continue;
}
$weekday = $record->getBegin()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
@@ -83,6 +71,9 @@ final class RoundingService
public function roundEnd(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {
if ($record->getEnd() === null) {
continue;
}
$weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
@@ -95,6 +86,9 @@ final class RoundingService
public function roundDuration(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {
if ($record->getEnd() === null) {
continue;
}
$weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
@@ -111,6 +105,9 @@ final class RoundingService
}
foreach ($this->getRoundingRules() as $rounding) {
if ($record->getEnd() === null) {
continue;
}
$weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
@@ -118,10 +115,12 @@ final class RoundingService
$rounder->roundBegin($record, $rounding['begin']);
$rounder->roundEnd($record, $rounding['end']);
$duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp();
$record->setDuration($duration);
if ($record->getBegin() !== null) {
$duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp();
$record->setDuration($duration);
$rounder->roundDuration($record, $rounding['duration']);
$rounder->roundDuration($record, $rounding['duration']);
}
}
}
}

View File

@@ -38,45 +38,14 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
final class TimesheetService
{
/**
* @var TimesheetRepository
*/
private $repository;
/**
* @var SystemConfiguration
*/
private $configuration;
/**
* @var TrackingModeService
*/
private $trackingModeService;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var AuthorizationCheckerInterface
*/
private $auth;
/**
* @var ValidatorInterface
*/
private $validator;
public function __construct(
SystemConfiguration $configuration,
TimesheetRepository $repository,
TrackingModeService $service,
EventDispatcherInterface $dispatcher,
AuthorizationCheckerInterface $security,
ValidatorInterface $validator
private SystemConfiguration $configuration,
private TimesheetRepository $repository,
private TrackingModeService $trackingModeService,
private EventDispatcherInterface $dispatcher,
private AuthorizationCheckerInterface $auth,
private ValidatorInterface $validator
) {
$this->configuration = $configuration;
$this->repository = $repository;
$this->trackingModeService = $service;
$this->dispatcher = $dispatcher;
$this->auth = $security;
$this->validator = $validator;
}
/**
@@ -163,6 +132,7 @@ final class TimesheetService
$this->stopActiveEntries($timesheet);
} catch (ValidationFailedException $vex) {
// could happen for timesheets that were started in the future (end before begin)
// or if you try to create a new timesheet while an old one is running for too long
throw new ValidationFailedException($vex->getViolations(), 'Cannot stop running timesheet');
}
$this->repository->commit();
@@ -183,16 +153,6 @@ final class TimesheetService
*/
public function updateTimesheet(Timesheet $timesheet): Timesheet
{
// FIXME stop active entries upon update
// there is at least one edge case which leads to a problem:
// if you do not allow overlapping entries, you cannot restart a timesheet by removing the
// end date if another timesheet is running, because the check for existing timesheets will always trigger
/*
if ($timesheet->getEnd() === null) {
$this->stopActiveEntries($timesheet);
}
*/
$this->fixTimezone($timesheet);
$this->dispatcher->dispatch(new TimesheetUpdatePreEvent($timesheet));

View File

@@ -9,29 +9,16 @@
namespace App\Timesheet;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Model\MonthlyStatistic;
use App\Repository\TimesheetRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\Expr\Join;
final class TimesheetStatisticService
{
/**
* @var TimesheetRepository
*/
private $repository;
private $entityManager;
public function __construct(TimesheetRepository $repository, EntityManagerInterface $entityManager)
public function __construct(private TimesheetRepository $repository)
{
$this->repository = $repository;
$this->entityManager = $entityManager;
}
/**
@@ -110,9 +97,10 @@ final class TimesheetStatisticService
$usersById = [];
foreach ($users as $user) {
$usersById[$user->getId()] = $user;
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = [];
$uid = (string) $user->getId();
$usersById[$uid] = $user;
if (!isset($stats[$uid])) {
$stats[$uid] = [];
}
}
@@ -142,9 +130,9 @@ final class TimesheetStatisticService
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$uid = $row['user'];
$pid = $row['project'];
$aid = $row['activity'];
$uid = (string) $row['user'];
$pid = (string) $row['project'];
$aid = (string) $row['activity'];
if (!isset($stats[$uid][$pid])) {
$stats[$uid][$pid] = ['project' => $pid, 'activities' => []];
}
@@ -186,9 +174,10 @@ final class TimesheetStatisticService
$usersById = [];
foreach ($users as $user) {
$usersById[$user->getId()] = $user;
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = [];
$uid = (string) $user->getId();
$usersById[$uid] = $user;
if (!isset($stats[$uid])) {
$stats[$uid] = [];
}
}
@@ -220,9 +209,9 @@ final class TimesheetStatisticService
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$uid = $row['user'];
$pid = $row['project'];
$aid = $row['activity'];
$uid = (string) $row['user'];
$pid = (string) $row['project'];
$aid = (string) $row['activity'];
if (!isset($stats[$uid][$pid])) {
$stats[$uid][$pid] = ['project' => $pid, 'activities' => []];
}
@@ -328,145 +317,4 @@ final class TimesheetStatisticService
return array_values($stats);
}
/**
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return array
*/
public function getGroupedByCustomerProjectActivityUser(DateTime $begin, DateTime $end, array $users): array
{
$stats = [];
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('IDENTITY(t.user) as user')
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('IDENTITY(t.project) as project')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('user', $users)
->groupBy('project')
->addGroupBy('activity')
->addGroupBy('user')
;
$results = $qb->getQuery()->getResult();
$projectIds = [];
$activityIds = [];
$userIds = [];
foreach ($results as $row) {
$projectId = $row['project'];
$activityId = $row['activity'];
$userId = $row['user'];
$projectIds[$projectId] = $projectId;
$activityIds[$activityId] = $activityId;
$userIds[$userId] = $userId;
if (!isset($stats[$projectId])) {
$stats[$projectId] = [
'id' => $projectId,
'customer' => '',
'customer_id' => null,
'name' => null,
'activities' => [],
'duration' => 0,
'rate' => 0,
'internalRate' => 0,
'max_users' => 0,
];
}
$stats[$projectId]['duration'] += (int) $row['duration'];
$stats[$projectId]['rate'] += (int) $row['rate'];
$stats[$projectId]['internalRate'] += (int) $row['internalRate'];
if (!isset($stats[$projectId]['activities'][$activityId])) {
$stats[$projectId]['activities'][$activityId] = [
'id' => $activityId,
'name' => null,
'users' => [],
'duration' => 0,
'rate' => 0,
'internalRate' => 0,
];
}
$stats[$projectId]['activities'][$activityId]['duration'] += (int) $row['duration'];
$stats[$projectId]['activities'][$activityId]['rate'] += (int) $row['rate'];
$stats[$projectId]['activities'][$activityId]['internalRate'] += (int) $row['internalRate'];
if (!isset($stats[$projectId]['activities'][$activityId]['users'][$userId])) {
$stats[$projectId]['activities'][$activityId]['users'][$userId] = [
'id' => $userId,
'name' => null,
'duration' => 0,
'rate' => 0,
'internalRate' => 0,
];
}
$stats[$projectId]['activities'][$activityId]['users'][$userId]['duration'] += (int) $row['duration'];
$stats[$projectId]['activities'][$activityId]['users'][$userId]['rate'] += (int) $row['rate'];
$stats[$projectId]['activities'][$activityId]['users'][$userId]['internalRate'] += (int) $row['internalRate'];
}
$qb = $this->entityManager->createQueryBuilder();
$qb
->select('a.id, a.name')
->from(Activity::class, 'a', 'a.id')
->where($qb->expr()->in('a.id', ':id'))
->setParameter('id', array_values($activityIds))
;
$activities = $qb->getQuery()->getResult();
$qb = $this->entityManager->createQueryBuilder();
$qb
->select('p.id, p.name, c.id as customer_id, c.name as customer, c.currency')
->from(Project::class, 'p', 'p.id')
->leftJoin(Customer::class, 'c', Join::WITH, 'c.id = p.customer')
->where($qb->expr()->in('p.id', ':id'))
->setParameter('id', array_values($projectIds))
;
$projects = $qb->getQuery()->getResult();
$qb = $this->entityManager->createQueryBuilder();
$qb
->select('u')
->from(User::class, 'u', 'u.id')
->where($qb->expr()->in('u.id', ':id'))
->setParameter('id', array_values($userIds))
;
$users = $qb->getQuery()->getResult();
foreach (array_keys($stats) as $pid) {
$stats[$pid]['name'] = $projects[$pid]['name'];
$stats[$pid]['customer'] = $projects[$pid]['customer'];
$stats[$pid]['customer_id'] = $projects[$pid]['customer_id'];
foreach (array_keys($stats[$pid]['activities']) as $aid) {
$stats[$pid]['activities'][$aid]['name'] = $activities[$aid]['name'];
foreach (array_keys($stats[$pid]['activities'][$aid]['users']) as $uid) {
$stats[$pid]['activities'][$aid]['users'][$uid]['name'] = $users[$uid]->getDisplayName();
}
$stats[$pid]['max_users'] = max($stats[$pid]['max_users'], \count($stats[$pid]['activities'][$aid]['users']));
}
}
return [
'stats' => $stats,
'projects' => $projects,
'activities' => $activities,
'users' => $users,
];
}
}

View File

@@ -16,14 +16,8 @@ use Symfony\Component\HttpFoundation\Request;
final class DefaultMode extends AbstractTrackingMode
{
/**
* @var RoundingService
*/
private $rounding;
public function __construct(RoundingService $rounding)
public function __construct(private RoundingService $rounding)
{
$this->rounding = $rounding;
}
public function canEditBegin(): bool
@@ -56,6 +50,11 @@ final class DefaultMode extends AbstractTrackingMode
return true;
}
public function getEditTemplate(): string
{
return 'timesheet/edit-default.html.twig';
}
public function create(Timesheet $timesheet, ?Request $request = null): void
{
parent::create($timesheet, $request);

View File

@@ -18,14 +18,8 @@ final class DurationFixedBeginMode implements TrackingModeInterface
{
use TrackingModeTrait;
/**
* @var SystemConfiguration
*/
private $configuration;
public function __construct(SystemConfiguration $configuration)
public function __construct(private SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
public function canEditBegin(): bool
@@ -72,4 +66,9 @@ final class DurationFixedBeginMode implements TrackingModeInterface
{
return false;
}
public function getEditTemplate(): string
{
return 'timesheet/edit-default.html.twig';
}
}

View File

@@ -9,21 +9,21 @@
namespace App\Timesheet\TrackingMode;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Timesheet\RoundingService;
use DateTime;
use Symfony\Component\HttpFoundation\Request;
/**
* This is a copy of the DefaultMode from 2.0
* FIXME 2.1 remove me with the next release
* @deprecated since 2.0
* @codeCoverageIgnore
*/
final class DurationOnlyMode extends AbstractTrackingMode
{
/**
* @var SystemConfiguration
*/
private $configuration;
public function __construct(SystemConfiguration $configuration)
public function __construct(private RoundingService $rounding)
{
$this->configuration = $configuration;
}
public function canEditBegin(): bool
@@ -33,7 +33,7 @@ final class DurationOnlyMode extends AbstractTrackingMode
public function canEditEnd(): bool
{
return false;
return true;
}
public function canEditDuration(): bool
@@ -53,24 +53,30 @@ final class DurationOnlyMode extends AbstractTrackingMode
public function canSeeBeginAndEndTimes(): bool
{
return false;
return true;
}
public function getEditTemplate(): string
{
return 'timesheet/edit-default.html.twig';
}
public function create(Timesheet $timesheet, ?Request $request = null): void
{
parent::create($timesheet, $request);
if (null === $timesheet->getBegin()) {
$timesheet->setBegin(new DateTime('now', $this->getTimezone($timesheet)));
}
$newBegin = clone $timesheet->getBegin();
$this->rounding->roundBegin($timesheet);
// this prevents the problem that "now" is being ignored in modify()
$beginTime = $this->configuration->getTimesheetDefaultBeginTime();
$beginTime = (new DateTime($this->configuration->getTimesheetDefaultBeginTime(), $newBegin->getTimezone()))->format('H:i:s');
$newBegin->modify($beginTime);
if (null !== $timesheet->getEnd()) {
$this->rounding->roundEnd($timesheet);
$timesheet->setBegin($newBegin);
parent::create($timesheet, $request);
if (null !== $timesheet->getDuration()) {
$this->rounding->roundDuration($timesheet);
}
}
}
}

View File

@@ -53,4 +53,9 @@ final class PunchInOutMode implements TrackingModeInterface
{
return true;
}
public function getEditTemplate(): string
{
return 'timesheet/edit-default.html.twig';
}
}

View File

@@ -58,6 +58,13 @@ interface TrackingModeInterface
*/
public function canUpdateTimesWithAPI(): bool;
/**
* Returns the edit template path for this tracking mode for regular user mode.
*
* @return string
*/
public function getEditTemplate(): string;
/**
* Whether the real begin and end times are shown in the user timesheet.
*

View File

@@ -15,23 +15,12 @@ use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
final class TrackingModeService
{
/**
* @var TrackingModeInterface[]
*/
private $modes = [];
/**
* @var SystemConfiguration
*/
private $configuration;
/**
* @param SystemConfiguration $configuration
* @param TrackingModeInterface[] $modes
*/
public function __construct(SystemConfiguration $configuration, iterable $modes)
public function __construct(private SystemConfiguration $configuration, private iterable $modes)
{
$this->configuration = $configuration;
$this->modes = $modes;
}
/**

View File

@@ -1,56 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Entity\User;
use App\Security\CurrentUser;
use DateTimeZone;
/**
* @codeCoverageIgnore
* @deprecated will be removed with 2.0
*/
class UserDateTimeFactory extends DateTimeFactory
{
/**
* @var CurrentUser
*/
private $user;
/**
* @var bool
*/
private $initializedFromUser = false;
public function __construct(CurrentUser $user)
{
parent::__construct(null);
$this->user = $user;
}
public function getTimezone(): DateTimeZone
{
if ($this->initializedFromUser === false) {
@trigger_error('UserDateTimeFactory is deprecated and will be removed with 2.0, use DateTimeFactory instead', E_USER_DEPRECATED);
$timezone = date_default_timezone_get();
$user = $this->user->getUser();
if ($user instanceof User) {
$timezone = $user->getTimezone();
}
$timezone = new DateTimeZone($timezone);
parent::setTimezone($timezone);
$this->initializedFromUser = true;
}
return parent::getTimezone();
}
}

View File

@@ -12,8 +12,12 @@ namespace App\Timesheet;
/**
* A static helper class for re-usable functionality.
*/
class Util
final class Util
{
private function __construct()
{
}
/**
* Calculates the rate for a hourly rate and a given duration in seconds.
*
@@ -23,9 +27,8 @@ class Util
*/
public static function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = (float) ($hourlyRate * ($seconds / 3600));
$rate = round($rate, 4);
$rate = $hourlyRate * ($seconds / 3600);
return $rate;
return round($rate, 4);
}
}