Release 2.0.20 (#4028)

* max duration per entry increased from 8 to 10 hours
* fixes #3981 - clickable area in dropdown too small
* fixes #4008 - duplicate activities in project-details report
* headers and summary styling in project-details report
* show billable stats in project-details report
* added new invoice variable for entry.duration_format
This commit is contained in:
Kevin Papst
2023-05-15 22:30:37 +02:00
committed by GitHub
parent 04422f3530
commit 29624354a2
28 changed files with 248 additions and 174 deletions

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.0.19';
public const VERSION = '2.0.20';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 20019;
public const VERSION_ID = 20020;
/**
* The software name
*/

View File

@@ -305,7 +305,7 @@ final class Configuration implements ConfigurationInterface
->defaultValue(0)
->end()
->integerNode('long_running_duration')
->defaultValue(480)
->defaultValue(600)
->end()
->booleanNode('require_activity')
->defaultTrue()

View File

@@ -72,6 +72,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.total_plain' => $rate,
'entry.currency' => $currency,
'entry.duration' => $item->getDuration(),
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => (int) ($item->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin),

View File

@@ -17,8 +17,7 @@ class Timesheet
/**
* For unified access, used in frontend.
*
* @return int
* @see getDuration()
*/
public function getValue(): int
{
@@ -27,8 +26,7 @@ class Timesheet
/**
* For unified access, used in frontend.
*
* @return int
* @see getTotalDuration()
*/
public function getDuration(): int
{
@@ -47,8 +45,7 @@ class Timesheet
/**
* For unified access, used in frontend.
*
* @return float
* @see getTotalRate()
*/
public function getRate(): float
{
@@ -67,8 +64,7 @@ class Timesheet
/**
* For unified access, used in frontend.
*
* @return float
* @see getTotalInternalRate()
*/
public function getInternalRate(): float
{

View File

@@ -29,21 +29,26 @@ final class UserYear
public function getDuration(): int
{
$duration = 0;
foreach ($this->year->getMonths() as $month) {
$duration += $month->getDuration();
}
return $this->year->getDuration();
}
return $duration;
public function getBillableDuration(): int
{
return $this->year->getBillableDuration();
}
public function getRate(): float
{
$rate = 0;
foreach ($this->year->getMonths() as $month) {
$rate += $month->getRate();
}
return $this->year->getRate();
}
return $rate;
public function getBillableRate(): float
{
return $this->year->getBillableRate();
}
public function getInternalRate(): float
{
return $this->year->getInternalRate();
}
}

View File

@@ -9,14 +9,12 @@
namespace App\Model\Statistic;
final class Year extends Timesheet
final class Year
{
/**
* @var Month[]
*/
private array $months = [];
private int $billableDuration = 0;
private float $billableRate = 0.00;
public function __construct(private string $year)
{
@@ -51,23 +49,58 @@ final class Year extends Timesheet
return array_values($this->months);
}
public function getBillableDuration(): int
public function getDuration(): int
{
return $this->billableDuration;
$duration = 0;
foreach ($this->months as $month) {
$duration += $month->getDuration();
}
return $duration;
}
public function setBillableDuration(int $billableDuration): void
public function getBillableDuration(): int
{
$this->billableDuration = $billableDuration;
$duration = 0;
foreach ($this->months as $month) {
$duration += $month->getBillableDuration();
}
return $duration;
}
public function getRate(): float
{
$rate = 0.0;
foreach ($this->months as $month) {
$rate += $month->getRate();
}
return $rate;
}
public function getBillableRate(): float
{
return $this->billableRate;
$rate = 0.0;
foreach ($this->months as $month) {
$rate += $month->getBillableRate();
}
return $rate;
}
public function setBillableRate(float $billableRate): void
public function getInternalRate(): float
{
$this->billableRate = $billableRate;
$rate = 0.0;
foreach ($this->months as $month) {
$rate += $month->getInternalRate();
}
return $rate;
}
}

View File

@@ -28,6 +28,7 @@ use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\ActivityRepository;
use App\Repository\Loader\ProjectLoader;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
@@ -35,7 +36,6 @@ use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
@@ -43,7 +43,13 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
*/
class ProjectStatisticService
{
public function __construct(private ProjectRepository $repository, private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher, private UserRepository $userRepository)
public function __construct(
private ProjectRepository $projectRepository,
private ActivityRepository $activityRepository,
private TimesheetRepository $timesheetRepository,
private EventDispatcherInterface $dispatcher,
private UserRepository $userRepository
)
{
}
@@ -74,7 +80,7 @@ class ProjectStatisticService
$lastChange = clone $query->getLastChange();
$now = new DateTime('now', $lastChange->getTimezone());
$qb2 = $this->repository->createQueryBuilder('t1');
$qb2 = $this->projectRepository->createQueryBuilder('t1');
$qb2
->select('1')
->from(Timesheet::class, 't')
@@ -82,7 +88,7 @@ class ProjectStatisticService
->andWhere($qb2->expr()->gte('t.begin', ':begin'))
;
$qb = $this->repository->createQueryBuilder('p');
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p, c')
->leftJoin('p.customer', 'c')
@@ -99,13 +105,13 @@ class ProjectStatisticService
->setParameter('begin', $lastChange, Types::DATETIME_MUTABLE)
;
$this->repository->addPermissionCriteria($qb, $user);
$this->projectRepository->addPermissionCriteria($qb, $user);
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
@@ -121,7 +127,7 @@ class ProjectStatisticService
$begin = $dateRange->getBegin();
$end = $dateRange->getEnd();
$qb = $this->repository->createQueryBuilder('p');
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p')
->leftJoin('p.customer', 'c')
@@ -144,7 +150,7 @@ class ProjectStatisticService
;
if (!$query->isIncludeNoWork()) {
$qb2 = $this->repository->createQueryBuilder('t1');
$qb2 = $this->projectRepository->createQueryBuilder('t1');
$qb2
->select('1')
->from(Timesheet::class, 't')
@@ -183,13 +189,13 @@ class ProjectStatisticService
->setParameter('customer', $query->getCustomer());
}
$this->repository->addPermissionCriteria($qb, $user);
$this->projectRepository->addPermissionCriteria($qb, $user);
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
@@ -398,18 +404,17 @@ class ProjectStatisticService
// fetch stats grouped by ACTIVITY for all time
$qb1 = clone $qb;
$qb1
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
->addSelect('a as activity')
->addGroupBy('a')
->addSelect('IDENTITY(t.activity) as activity')
->addGroupBy('t.activity')
;
/** @var array<ActivityStatistic> $activities */
/** @var array<string, ActivityStatistic> $activities */
$activities = [];
foreach ($qb1->getQuery()->getResult() as $tmp) {
$activityId = $tmp['activity']->getId();
/** @var array{"duration": int, "rate": float, "internalRate": float, "count": int, "billable": bool, "activity": int} $tmp */
foreach ($qb1->getQuery()->getArrayResult() as $tmp) {
$activityId = $tmp['activity'];
if (!\array_key_exists($activityId, $activities)) {
$activity = new ActivityStatistic();
$activity->setActivity($tmp['activity']);
$activities[$activityId] = $activity;
} else {
$activity = $activities[$activityId];
@@ -423,10 +428,27 @@ class ProjectStatisticService
if ($tmp['billable']) {
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
$activity->setInternalRateBillable($activity->getInternalRateBillable() + $tmp['internalRate']);
}
}
foreach ($activities as $activity) {
/** @var array<string, Activity> $activityIdToActivity */
$activityIdToActivity = [];
if (\count($activities) > 0) {
// prepare activities for later use
$qbActivity = $this->activityRepository->createQueryBuilder('a');
$qbActivity->select('a')->where($qbActivity->expr()->in('a.id', array_keys($activities)));
/** @var array<int, Activity> $activityResults */
$activityResults = $qbActivity->getQuery()->getResult();
foreach ($activityResults as $item) {
$activityIdToActivity[$item->getId()] = $item;
}
}
foreach ($activities as $activityId => $activity) {
$activity->setActivity($activityIdToActivity[$activityId]);
$model->addActivity($activity);
}
// ---------------------------------------------------
@@ -450,7 +472,9 @@ class ProjectStatisticService
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
/** @var array<int, UserStatistic> $users */
$users = [];
foreach ($qb2->getQuery()->getResult() as $user) {
/** @var array<int, User> $userResult */
$userResult = $qb2->getQuery()->getResult();
foreach ($userResult as $user) {
$users[$user->getId()] = new UserStatistic($user);
}
@@ -533,41 +557,33 @@ class ProjectStatisticService
$tmp->setMonth(new Month($i));
}
$years[$year['year']] = $tmp;
} else {
$tmp = $years[$year['year']];
}
$tmp->setTotalRate($tmp->getTotalRate() + $year['rate']);
$tmp->setTotalInternalRate($tmp->getTotalInternalRate() + $year['internalRate']);
$tmp->setTotalDuration($tmp->getTotalDuration() + $year['duration']);
if ($year['billable']) {
$tmp->setBillableDuration($tmp->getBillableDuration() + $year['duration']);
$tmp->setBillableRate($tmp->getBillableRate() + $year['rate']);
}
}
/** @var array<string, array<string, ActivityStatistic>> $yearActivities */
$yearActivities = [];
foreach ($years as $yearName => $yearStat) {
// fetch yearly stats grouped by ACTIVITY and YEAR
$qb2 = clone $qb;
$qb2
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
->addSelect('a as activity')
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('YEAR(t.date) as year')
->andWhere('YEAR(t.date) = :year')
->setParameter('year', $yearName)
->addGroupBy('year')
->addGroupBy('a')
->addGroupBy('t.activity')
;
foreach ($qb2->getQuery()->getResult() as $tmp) {
$activityId = $tmp['activity']->getId();
/** @var array<int, array{"duration": int, "rate": float, "internalRate": float, "count": int, "billable": bool, "activity": int, "year": int}> $statsTmp */
$statsTmp = $qb2->getQuery()->getArrayResult();
foreach ($statsTmp as $tmp) {
$activityId = $tmp['activity'];
if (!\array_key_exists($yearName, $yearActivities)) {
$yearActivities[$yearName] = [];
}
if (!\array_key_exists($activityId, $yearActivities[$yearName])) {
$activity = new ActivityStatistic();
$activity->setActivity($tmp['activity']);
$activity->setActivity($activityIdToActivity[$activityId]);
$yearActivities[$yearName][$activityId] = $activity;
} else {
$activity = $yearActivities[$yearName][$activityId];
@@ -580,14 +596,14 @@ class ProjectStatisticService
if ($tmp['billable']) {
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
$activity->setInternalRateBillable($activity->getInternalRateBillable() + $tmp['internalRate']);
}
$model->addYearActivity($tmp['year'], $activity);
}
}
foreach ($yearActivities as $year => $yearlyActivities) {
foreach ($yearlyActivities as $activity) {
$model->addYearActivity($year, $activity);
foreach ($yearlyActivities as $activityId => $activityStatistic) {
$model->addYearActivity($year, $activityStatistic);
}
}
@@ -631,7 +647,7 @@ class ProjectStatisticService
$user = $query->getUser();
$today = clone $query->getToday();
$qb = $this->repository->createQueryBuilder('p');
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p')
->leftJoin('p.customer', 'c')
@@ -675,13 +691,13 @@ class ProjectStatisticService
);
}
$this->repository->addPermissionCriteria($qb, $user);
$this->projectRepository->addPermissionCriteria($qb, $user);
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;

View File

@@ -95,7 +95,9 @@ final class ProjectDetailsModel
$users[$id] = $userStat;
}
$userStat->setDuration($userStat->getDuration() + $userYear->getDuration());
$userStat->setDurationBillable($userStat->getDurationBillable() + $userYear->getBillableDuration());
$userStat->setRate($userStat->getRate() + $userYear->getRate());
$userStat->setRateBillable($userStat->getRateBillable() + $userYear->getBillableRate());
}
}