monthly budget, monthly report, unified report calculation (#2684)

This commit is contained in:
Kevin Papst
2021-08-06 18:38:41 +02:00
committed by GitHub
parent 21f785638c
commit cefd747e91
196 changed files with 5031 additions and 2167 deletions

View File

@@ -10,9 +10,16 @@
namespace App\Activity;
use App\Entity\Activity;
use App\Event\ActivityBudgetStatisticEvent;
use App\Event\ActivityStatisticEvent;
use App\Model\ActivityBudgetStatisticModel;
use App\Model\ActivityStatistic;
use App\Repository\ActivityRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
@@ -20,21 +27,176 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
*/
class ActivityStatisticService
{
private $repository;
private $activityRepository;
private $timesheetRepository;
private $dispatcher;
public function __construct(ActivityRepository $activityRepository, EventDispatcherInterface $dispatcher)
public function __construct(ActivityRepository $activityRepository, TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher)
{
$this->repository = $activityRepository;
$this->activityRepository = $activityRepository;
$this->timesheetRepository = $timesheetRepository;
$this->dispatcher = $dispatcher;
}
public function getActivityStatistics(Activity $activity): ActivityStatistic
/**
* WARNING: this method does not respect the budget type. Your results will always be wither the "full lifetime data" or the "selected date-range".
*
* @param Activity $activity
* @param DateTime|null $begin
* @param DateTime|null $end
* @return ActivityStatistic
*/
public function getActivityStatistics(Activity $activity, ?DateTime $begin = null, ?DateTime $end = null): ActivityStatistic
{
$statistic = $this->repository->getActivityStatistics($activity);
$event = new ActivityStatisticEvent($activity, $statistic);
$statistics = $this->getBudgetStatistic([$activity], $begin, $end);
$event = new ActivityStatisticEvent($activity, array_pop($statistics), $begin, $end);
$this->dispatcher->dispatch($event);
return $statistic;
return $event->getStatistic();
}
public function getBudgetStatisticModel(Activity $activity, DateTime $today): ActivityBudgetStatisticModel
{
$stats = new ActivityBudgetStatisticModel($activity);
$stats->setStatisticTotal($this->getActivityStatistics($activity));
$begin = null;
$end = $today;
if ($activity->isMonthlyBudget()) {
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
}
$stats->setStatistic($this->getActivityStatistics($activity, $begin, $end));
$event = new ActivityBudgetStatisticEvent([$stats], $begin, $end);
$this->dispatcher->dispatch($event);
return $stats;
}
/**
* @param Activity[] $activities
* @param DateTime $today
* @return ActivityBudgetStatisticModel[]
*/
public function getBudgetStatisticModelForActivities(array $activities, DateTime $today): array
{
$models = [];
$monthly = [];
$allTime = [];
foreach ($activities as $activity) {
$models[$activity->getId()] = new ActivityBudgetStatisticModel($activity);
if ($activity->isMonthlyBudget()) {
$monthly[] = $activity;
} else {
$allTime[] = $activity;
}
}
$statisticsTotal = $this->getBudgetStatistic($activities);
foreach ($statisticsTotal as $id => $statistic) {
$models[$id]->setStatisticTotal($statistic);
}
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = null;
$end = $today;
if (\count($monthly) > 0) {
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
$statistics = $this->getBudgetStatistic($monthly, $begin, $end);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
if (\count($allTime) > 0) {
// display the budget at the end of the selected period and not the total sum of all times (do not include times in the future)
$statistics = $this->getBudgetStatistic($allTime, null, $today);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
$event = new ActivityBudgetStatisticEvent($models, $begin, $end);
$this->dispatcher->dispatch($event);
return $models;
}
/**
* @param Activity[] $activities
* @param DateTime|null $begin
* @param DateTime|null $end
* @return array<int, ActivityStatistic>
*/
private function getBudgetStatistic(array $activities, ?DateTime $begin = null, ?DateTime $end = null): array
{
$statistics = [];
foreach ($activities as $activity) {
$statistics[$activity->getId()] = new ActivityStatistic();
}
$qb = $this->createStatisticQueryBuilder($activities, $begin, $end);
$result = $qb->getQuery()->getResult();
if (null !== $result) {
foreach ($result as $resultRow) {
$statistic = $statistics[$resultRow['id']];
$statistic->setDuration($statistic->getDuration() + $resultRow['duration']);
$statistic->setRate($statistic->getRate() + $resultRow['rate']);
$statistic->setInternalRate($statistic->getInternalRate() + $resultRow['internalRate']);
$statistic->setCounter($statistic->getCounter() + $resultRow['counter']);
if ($resultRow['billable']) {
$statistic->setDurationBillable($resultRow['duration']);
$statistic->setRateBillable($resultRow['rate']);
$statistic->setInternalRateBillable($resultRow['internalRate']);
$statistic->setCounterBillable($resultRow['counter']);
}
}
}
return $statistics;
}
private function createStatisticQueryBuilder(array $activities, DateTime $begin = null, ?DateTime $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.activity) AS id')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('COUNT(t.id) as counter')
->addSelect('t.billable as billable')
->andWhere($qb->expr()->isNotNull('t.end'))
->groupBy('id')
->addGroupBy('billable')
->andWhere($qb->expr()->in('t.activity', ':activity'))
->setParameter('activity', $activities)
;
if ($begin !== null) {
$qb
->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
;
}
if ($end !== null) {
$qb
->andWhere($qb->expr()->lte('t.begin', ':end'))
->setParameter('end', $end, Types::DATETIME_MUTABLE)
;
}
return $qb;
}
}

View File

@@ -124,6 +124,7 @@ final class ActivityController extends AbstractController
$rates = [];
$teams = null;
$defaultTeam = null;
$now = $this->getDateTimeFactory()->createDateTime();
if ($this->isGranted('edit', $activity)) {
if ($this->isGranted('create_team')) {
@@ -133,7 +134,7 @@ final class ActivityController extends AbstractController
}
if ($this->isGranted('budget', $activity)) {
$stats = $statisticService->getActivityStatistics($activity);
$stats = $statisticService->getBudgetStatisticModel($activity, $now);
}
if ($this->isGranted('permissions', $activity) || $this->isGranted('details', $activity) || $this->isGranted('view_team')) {
@@ -146,7 +147,7 @@ final class ActivityController extends AbstractController
'rates' => $rates,
'team' => $defaultTeam,
'teams' => $teams,
'now' => $this->getDateTimeFactory()->createDateTime(),
'now' => $now,
]);
}

View File

@@ -283,6 +283,7 @@ final class CustomerController extends AbstractController
$teams = null;
$projects = null;
$rates = [];
$now = $this->getDateTimeFactory()->createDateTime();
if ($this->isGranted('edit', $customer)) {
if ($this->isGranted('create_team')) {
@@ -296,7 +297,7 @@ final class CustomerController extends AbstractController
}
if ($this->isGranted('budget', $customer)) {
$stats = $statisticService->getCustomerStatistics($customer);
$stats = $statisticService->getBudgetStatisticModel($customer, $now);
}
if ($this->isGranted('comments', $customer)) {
@@ -321,7 +322,7 @@ final class CustomerController extends AbstractController
'teams' => $teams,
'customer_now' => new \DateTime('now', $timezone),
'rates' => $rates,
'now' => $this->getDateTimeFactory()->createDateTime(),
'now' => $now,
]);
}

View File

@@ -19,8 +19,8 @@ use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetStatisticService;
use App\User\UserService;
use App\Utils\LocaleSettings;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
@@ -48,20 +48,20 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}", name="user_profile", methods={"GET"})
* @Security("is_granted('view', profile)")
*/
public function indexAction(User $profile, TimesheetRepository $repository, LocaleSettings $localeSettings): Response
public function indexAction(User $profile, TimesheetRepository $repository, TimesheetStatisticService $statisticService): Response
{
$userStats = $repository->getUserStatistics($profile);
$userStats = $repository->getUserStatistics($profile, false);
$firstEntry = $statisticService->findFirstRecordDate($profile);
$begin = $userStats->getFirstEntry() ?? $this->getDateTimeFactory()->getStartOfMonth();
$begin = $firstEntry ?? $this->getDateTimeFactory()->getStartOfMonth();
$end = $this->getDateTimeFactory()->getEndOfMonth();
$monthlyStats = $repository->getMonthlyStats($begin, $end, $profile);
arsort($monthlyStats);
$viewVars = [
'tab' => 'charts',
'user' => $profile,
'stats' => $userStats,
'years' => $monthlyStats,
'firstTimesheet' => $firstEntry,
'workMonths' => $statisticService->getMonthlyStats($begin, $end, [$profile])[0]
];
return $this->render('user/stats.html.twig', $viewVars);

View File

@@ -304,6 +304,7 @@ final class ProjectController extends AbstractController
$comments = null;
$teams = null;
$rates = [];
$now = $this->getDateTimeFactory()->createDateTime();
if ($this->isGranted('edit', $project)) {
if ($this->isGranted('create_team')) {
@@ -313,7 +314,7 @@ final class ProjectController extends AbstractController
}
if ($this->isGranted('budget', $project)) {
$stats = $statisticService->getProjectStatistics($project);
$stats = $statisticService->getBudgetStatisticModel($project, $now);
}
if ($this->isGranted('comments', $project)) {
@@ -337,7 +338,7 @@ final class ProjectController extends AbstractController
'team' => $defaultTeam,
'teams' => $teams,
'rates' => $rates,
'now' => $this->getDateTimeFactory()->createDateTime(),
'now' => $now,
]);
}

View File

@@ -0,0 +1,60 @@
<?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\Controller\Reporting;
use App\Controller\AbstractController;
use App\Form\Model\DateRange;
use App\Project\ProjectStatisticService;
use App\Reporting\ProjectDateRange\ProjectDateRangeForm;
use App\Reporting\ProjectDateRange\ProjectDateRangeQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class ProjectDateRangeController extends AbstractController
{
/**
* @Route(path="/reporting/project_daterange", name="report_project_daterange", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_project')")
*/
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectDaterangeQuery($dateFactory->getStartOfMonth(), $user);
$form = $this->createForm(ProjectDateRangeForm::class, $query, [
'timezone' => $user->getTimezone()
]);
$form->submit($request->query->all(), false);
$dateRange = new DateRange(true);
$dateRange->setBegin($query->getMonth());
$dateRange->setEnd($dateFactory->getEndOfMonth($dateRange->getBegin()));
$projects = $service->findProjectsForDateRange($query, $dateRange);
$entries = $service->getBudgetStatisticModelForProjectsByDateRange($projects, $dateRange->getBegin(), $dateRange->getEnd(), $dateRange->getEnd());
$byCustomer = [];
foreach ($entries as $entry) {
$customer = $entry->getProject()->getCustomer();
if (!isset($byCustomer[$customer->getId()])) {
$byCustomer[$customer->getId()] = ['customer' => $customer, 'projects' => []];
}
$byCustomer[$customer->getId()]['projects'][] = $entry;
}
return $this->render('reporting/project_daterange.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'queryEnd' => $dateRange->getEnd(),
]);
}
}

View File

@@ -43,6 +43,7 @@ final class ProjectDetailsController extends AbstractController
}
return $this->render('reporting/project_details.html.twig', [
'project' => $query->getProject(),
'project_view' => $projectView,
'project_details' => $projectDetails,
'form' => $form->createView(),

View File

@@ -17,7 +17,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class InactiveProjectController extends AbstractController
final class ProjectInactiveController extends AbstractController
{
/**
* @Route(path="/reporting/project_inactive", name="report_project_inactive", methods={"GET","POST"})
@@ -27,6 +27,7 @@ final class InactiveProjectController extends AbstractController
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$now = $dateFactory->createDateTime();
$query = new ProjectInactiveQuery($dateFactory->createDateTime('-1 year'), $user);
$form = $this->createForm(ProjectInactiveForm::class, $query, [
@@ -35,7 +36,7 @@ final class InactiveProjectController extends AbstractController
$form->submit($request->query->all(), false);
$projects = $service->findInactiveProjects($query);
$entries = $service->getProjectView($user, $projects, $query->getLastChange());
$entries = $service->getProjectView($user, $projects, $now);
$byCustomer = [];
foreach ($entries as $entry) {
@@ -51,7 +52,8 @@ final class InactiveProjectController extends AbstractController
'form' => $form->createView(),
'title' => 'report_inactive_project',
'tableName' => 'inactive_project_reporting',
'now' => $this->getDateTimeFactory()->createDateTime(),
'now' => $now,
'skipColumns' => ['today', 'week', 'month', 'projectStart', 'projectEnd', 'comment'],
]);
}
}

View File

@@ -49,8 +49,7 @@ final class ProjectViewController extends AbstractController
'form' => $form->createView(),
'title' => 'report_project_view',
'tableName' => 'project_view_reporting',
'now' => $this->getDateTimeFactory()->createDateTime(),
'showDurations' => true,
'now' => $dateFactory->createDateTime(),
]);
}
}

View File

@@ -10,12 +10,18 @@
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Model\Statistic\Day;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Model\Statistic\StatisticDate;
use App\Reporting\MonthByUser;
use App\Reporting\MonthByUserForm;
use App\Reporting\WeekByUser;
use App\Reporting\WeekByUserForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetStatisticService;
use DateTime;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -29,14 +35,17 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
*/
final class ReportByUserController extends AbstractController
{
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
private $statisticService;
private $projectRepository;
private $activityRepository;
public function __construct(TimesheetRepository $timesheetRepository)
public function __construct(TimesheetRepository $timesheetRepository, TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
{
$this->timesheetRepository = $timesheetRepository;
$this->statisticService = $statisticService;
$this->projectRepository = $projectRepository;
$this->activityRepository = $activityRepository;
}
private function canSelectUser(): bool
@@ -60,7 +69,6 @@ final class ReportByUserController extends AbstractController
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$localeFormats = $this->getLocaleFormats($request->getLocale());
$canChangeUser = $this->canSelectUser();
$values = new MonthByUser();
@@ -71,7 +79,6 @@ final class ReportByUserController extends AbstractController
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]);
$form->submit($request->query->all(), false);
@@ -102,15 +109,14 @@ final class ReportByUserController extends AbstractController
$nextMonth = clone $start;
$nextMonth->modify('+1 month');
$data = $this->timesheetRepository->getDailyStats($selectedUser, $start, $end);
$rows = $this->prepareReportData($data);
$data = $this->prepareReport($start, $end, $selectedUser);
return $this->render('reporting/report_by_user.html.twig', [
'report_title' => 'report_user_month',
'box_id' => 'user-month-reporting-box',
'form' => $form->createView(),
'days' => $data,
'rows' => $rows,
'rows' => $data,
'days' => new DailyStatistic($start, $end, $selectedUser),
'user' => $selectedUser,
'current' => $start,
'next' => $nextMonth,
@@ -129,7 +135,6 @@ final class ReportByUserController extends AbstractController
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$localeFormats = $this->getLocaleFormats($request->getLocale());
$canChangeUser = $this->canSelectUser();
$values = new WeekByUser();
@@ -140,7 +145,6 @@ final class ReportByUserController extends AbstractController
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]);
$form->submit($request->query->all(), false);
@@ -159,7 +163,6 @@ final class ReportByUserController extends AbstractController
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
$selectedUser = $values->getUser();
$previous = clone $start;
@@ -168,15 +171,14 @@ final class ReportByUserController extends AbstractController
$next = clone $start;
$next->modify('+1 week');
$data = $this->timesheetRepository->getDailyStats($selectedUser, $start, $end);
$rows = $this->prepareReportData($data);
$data = $this->prepareReport($start, $end, $selectedUser);
return $this->render('reporting/report_by_user.html.twig', [
'report_title' => 'report_user_week',
'box_id' => 'user-week-reporting-box',
'form' => $form->createView(),
'days' => $data,
'rows' => $rows,
'days' => new DailyStatistic($start, $end, $selectedUser),
'rows' => $data,
'user' => $selectedUser,
'current' => $start,
'next' => $next,
@@ -184,50 +186,52 @@ final class ReportByUserController extends AbstractController
]);
}
/**
* @param Day[] $data
* @return array
*/
private function prepareReportData(array $data): array
private function prepareReport(DateTime $begin, DateTime $end, User $user): array
{
$days = [];
$data = $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
foreach ($data as $day) {
$days[$day->getDay()->format('Ymd')] = ['date' => $day->getDay(), 'duration' => 0];
$data = array_pop($data);
$projectIds = [];
$activityIds = [];
foreach ($data as $projectId => $projectValues) {
$projectIds[$projectId] = $projectId;
$dailyProjectStatistic = new DailyStatistic($begin, $end, $user);
foreach ($projectValues['activities'] as $activityId => $activityValues) {
$activityIds[$activityId] = $activityId;
if (!isset($data[$projectId]['duration'])) {
$data[$projectId]['duration'] = 0;
}
if (!isset($data[$projectId]['activities'][$activityId]['duration'])) {
$data[$projectId]['activities'][$activityId]['duration'] = 0;
}
/** @var StatisticDate $day */
foreach ($activityValues['days']->getDays() as $day) {
$statDay = $dailyProjectStatistic->getDayByDateTime($day->getDate());
$statDay->setTotalDuration($statDay->getTotalDuration() + $day->getDuration());
$data[$projectId]['duration'] = $data[$projectId]['duration'] + $day->getDuration();
$data[$projectId]['activities'][$activityId]['duration'] = $data[$projectId]['activities'][$activityId]['duration'] + $day->getDuration();
}
}
$data[$projectId]['days'] = $dailyProjectStatistic;
}
$rows = [];
$activities = $this->activityRepository->findByIds($activityIds);
foreach ($activities as $activity) {
$activityIds[$activity->getId()] = $activity;
}
foreach ($data as $day) {
$dayId = $day->getDay()->format('Ymd');
foreach ($day->getDetails() as $id => $detail) {
$projectId = $detail['project']->getId();
if (!\array_key_exists($projectId, $rows)) {
$rows[$projectId] = [
'project' => $detail['project'],
'duration' => 0,
'days' => $days,
'activities' => [],
];
}
$rows[$projectId]['duration'] += $detail['duration'];
$rows[$projectId]['days'][$dayId]['duration'] += $detail['duration'];
$activityId = $detail['activity']->getId();
if (!\array_key_exists($activityId, $rows[$projectId]['activities'])) {
$rows[$projectId]['activities'][$activityId] = [
'activity' => $detail['activity'],
'duration' => 0,
'days' => $days,
];
}
$rows[$projectId]['activities'][$activityId]['duration'] += $detail['duration'];
$rows[$projectId]['activities'][$activityId]['days'][$dayId]['duration'] += $detail['duration'];
foreach ($data as $projectId => $projectValues) {
foreach ($projectValues['activities'] as $activityId => $activityValues) {
$data[$projectId]['activities'][$activityId]['activity'] = $activityIds[$activityId];
}
}
return $rows;
$projects = $this->projectRepository->findByIds($projectIds);
foreach ($projects as $project) {
$data[$project->getId()]['project'] = $project;
}
return $data;
}
}

View File

@@ -11,8 +11,8 @@ namespace App\Controller\Reporting;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Model\Statistic\Day;
use App\Model\Statistic\Year;
use App\Model\DailyStatistic;
use App\Model\MonthlyStatistic;
use App\Reporting\MonthlyUserList;
use App\Reporting\MonthlyUserListForm;
use App\Reporting\WeeklyUserList;
@@ -22,6 +22,7 @@ use App\Reporting\YearlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -34,13 +35,7 @@ use Symfony\Component\Routing\Annotation\Route;
*/
final class ReportUsersListController extends AbstractController
{
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
/**
* @var UserRepository
*/
private $userRepository;
public function __construct(TimesheetRepository $timesheetRepository, UserRepository $userRepository)
@@ -56,11 +51,10 @@ final class ReportUsersListController extends AbstractController
* @return Response
* @throws Exception
*/
public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration): Response
public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$localeFormats = $this->getLocaleFormats($request->getLocale());
$query = new UserQuery();
$query->setCurrentUser($currentUser);
@@ -71,15 +65,12 @@ final class ReportUsersListController extends AbstractController
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
}
$rows = [];
$values = new YearlyUserList();
$values->setDate(clone $defaultDate);
$form = $this->createForm(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]);
$form->submit($request->query->all(), false);
@@ -93,123 +84,49 @@ final class ReportUsersListController extends AbstractController
}
$start = $values->getDate();
$end = $this->getDateTimeFactory()->createEndOfFinancialYear($start);
// there is a potential edge case bug for financial years:
// the last month will be skipped, if the financial year started on a different day than the first
$end = $dateTimeFactory->createEndOfFinancialYear($start);
$months = [];
$totals = [];
foreach ($allUsers as $user) {
$rows[] = [
'years' => $this->timesheetRepository->getMonthlyStats($start, $end, $user),
'user' => $user
];
$monthStats = [];
$hasData = true;
if (!empty($allUsers)) {
$monthStats = $statisticService->getMonthlyStats($start, $end, $allUsers);
}
if (isset($rows[0])) {
/** @var Year $year */
foreach ($rows[0]['years'] as $year) {
foreach ($year->getMonths() as $month) {
$date = new \DateTime();
$date->setDate((int) $year->getYear(), $month->getMonthNumber(), 1);
$date->setTime(0, 0, 0);
$months[$date->format('Ym')] = $date;
}
}
foreach ($rows as $row) {
foreach ($row['years'] as $year) {
foreach ($year->getMonths() as $month) {
$date = new \DateTime();
$date->setDate((int) $year->getYear(), $month->getMonthNumber(), 1);
$totalsId = $date->format('Ym');
if (!isset($totals[$totalsId])) {
$totals[$totalsId] = 0;
}
$totals[$totalsId] += $month->getTotalDuration();
}
}
}
if (empty($monthStats)) {
$monthStats = [new MonthlyStatistic($start, $end, $currentUser)];
$hasData = false;
}
/*
foreach ($allUsers as $user) {
$rows[] = [
'days' => $this->timesheetRepository->getDailyStats($user, $start, $end),
'user' => $user
];
}
$userYears = [];
if (isset($rows[0])) {
foreach ($rows[0]['days'] as $day) {
$months[$day->getDay()->format('Ym')] = $day->getDay();
}
foreach ($rows as $row) {
$userYear = ['user' => $row['user']];
foreach ($row['days'] as $day) {
$yearId = $day->getDay()->format('Y');
$monthId = $day->getDay()->format('m');
$totalsId = $yearId.$monthId;
if (!array_key_exists('years', $userYear)) {
$userYear['years'] = [];
}
if (!array_key_exists($yearId, $userYear['years'])) {
$userYear['years'][$yearId] = ['year' => $yearId];
}
if (!array_key_exists('months', $userYear['years'][$yearId])) {
$userYear['years'][$yearId]['months'] = [];
}
if (!array_key_exists($monthId, $userYear['years'][$yearId]['months'])) {
$userYear['years'][$yearId]['months'][$monthId] = ['month' => $monthId, 'totalDuration' => 0];
}
if (!array_key_exists($totalsId, $totals)) {
$totals[$totalsId] = 0;
}
$totals[$totalsId] += $day->getTotalDuration();
$userYear['years'][$yearId]['months'][$monthId]['totalDuration'] += $day->getTotalDuration();;
}
$userYears[] = $userYear;
}
}
$rows = $userYears;
*/
return $this->render('reporting/report_user_list_monthly.html.twig', [
'report_title' => 'report_yearly_users',
'box_id' => 'yearly-user-list-reporting-box',
'form' => $form->createView(),
'rows' => $rows,
'months' => $months,
'totals' => $totals,
'stats' => $monthStats,
'hasData' => $hasData,
]);
}
/**
* @Route(path="/monthly_users_list", name="report_monthly_users", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function monthlyUsersList(Request $request): Response
public function monthlyUsersList(Request $request, TimesheetStatisticService $statisticService): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$localeFormats = $this->getLocaleFormats($request->getLocale());
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query);
$rows = [];
$values = new MonthlyUserList();
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]);
$form->submit($request->query->all(), false);
@@ -234,71 +151,50 @@ final class ReportUsersListController extends AbstractController
$next = clone $start;
$next->modify('+1 month');
foreach ($allUsers as $user) {
$rows[] = [
'days' => $this->timesheetRepository->getDailyStats($user, $start, $end),
'user' => $user
];
$dayStats = [];
$hasData = true;
if (!empty($allUsers)) {
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
}
$days = [];
$totals = [];
if (isset($rows[0])) {
/** @var Day $day */
foreach ($rows[0]['days'] as $day) {
$days[$day->getDay()->format('Ymd')] = $day->getDay();
}
foreach ($rows as $row) {
foreach ($row['days'] as $day) {
$totalsId = $day->getDay()->format('Ymd');
if (!isset($totals[$totalsId])) {
$totals[$totalsId] = 0;
}
$totals[$totalsId] += $day->getTotalDuration();
}
}
if (empty($dayStats)) {
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return $this->render('reporting/report_user_list.html.twig', [
'report_title' => 'report_monthly_users',
'box_id' => 'monthly-user-list-reporting-box',
'form' => $form->createView(),
'rows' => $rows,
'days' => $days,
'totals' => $totals,
'current' => $start,
'next' => $next,
'previous' => $previous,
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_month',
'stats' => $dayStats,
'hasData' => $hasData,
]);
}
/**
* @Route(path="/weekly_users_list", name="report_weekly_users", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function weeklyUsersList(Request $request): Response
public function weeklyUsersList(Request $request, TimesheetStatisticService $statisticService): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$localeFormats = $this->getLocaleFormats($request->getLocale());
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query);
$rows = [];
$values = new WeeklyUserList();
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeeklyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]);
$form->submit($request->query->all(), false);
@@ -320,42 +216,29 @@ final class ReportUsersListController extends AbstractController
$next = clone $start;
$next->modify('+1 week');
foreach ($allUsers as $user) {
$rows[] = [
'days' => $this->timesheetRepository->getDailyStats($user, $start, $end),
'user' => $user
];
$dayStats = [];
$hasData = true;
if (!empty($allUsers)) {
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
}
$days = [];
$totals = [];
if (isset($rows[0])) {
/** @var Day $day */
foreach ($rows[0]['days'] as $day) {
$days[$day->getDay()->format('Ymd')] = $day->getDay();
}
foreach ($rows as $row) {
foreach ($row['days'] as $day) {
$totalsId = $day->getDay()->format('Ymd');
if (!isset($totals[$totalsId])) {
$totals[$totalsId] = 0;
}
$totals[$totalsId] += $day->getTotalDuration();
}
}
if (empty($dayStats)) {
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return $this->render('reporting/report_user_list.html.twig', [
'report_title' => 'report_weekly_users',
'box_id' => 'weekly-user-list-reporting-box',
'form' => $form->createView(),
'rows' => $rows,
'days' => $days,
'totals' => $totals,
'current' => $start,
'next' => $next,
'previous' => $previous,
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_week',
'stats' => $dayStats,
'hasData' => $hasData,
]);
}
}

View File

@@ -10,9 +10,17 @@
namespace App\Customer;
use App\Entity\Customer;
use App\Entity\Project;
use App\Event\CustomerStatisticEvent;
use App\Model\CustomerBudgetStatisticModel;
use App\Model\CustomerStatistic;
use App\Repository\CustomerRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
@@ -21,20 +29,120 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class CustomerStatisticService
{
private $repository;
private $timesheetRepository;
private $dispatcher;
public function __construct(CustomerRepository $customerRepository, EventDispatcherInterface $dispatcher)
public function __construct(CustomerRepository $customerRepository, TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher)
{
$this->repository = $customerRepository;
$this->timesheetRepository = $timesheetRepository;
$this->dispatcher = $dispatcher;
}
public function getCustomerStatistics(Customer $customer): CustomerStatistic
/**
* WARNING: this method does not respect the budget type. Your results will always be wither the "full lifetime data" or the "selected date-range".
*
* @param Customer $customer
* @param DateTime|null $begin
* @param DateTime|null $end
* @return CustomerStatistic
*/
public function getCustomerStatistics(Customer $customer, ?DateTime $begin = null, ?DateTime $end = null): CustomerStatistic
{
$statistic = $this->repository->getCustomerStatistics($customer);
$event = new CustomerStatisticEvent($customer, $statistic);
$statistics = $this->getBudgetStatistic([$customer], $begin, $end);
$event = new CustomerStatisticEvent($customer, array_pop($statistics), $begin, $end);
$this->dispatcher->dispatch($event);
return $statistic;
return $event->getStatistic();
}
public function getBudgetStatisticModel(Customer $customer, DateTime $today): CustomerBudgetStatisticModel
{
$stats = new CustomerBudgetStatisticModel($customer);
$stats->setStatisticTotal($this->getCustomerStatistics($customer));
$begin = null;
$end = $today;
if ($customer->isMonthlyBudget()) {
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
}
$stats->setStatistic($this->getCustomerStatistics($customer, $begin, $end));
return $stats;
}
/**
* @param Customer[] $customers
* @param DateTime|null $begin
* @param DateTime|null $end
* @return array<int, CustomerStatistic>
*/
private function getBudgetStatistic(array $customers, ?DateTime $begin = null, ?DateTime $end = null): array
{
$statistics = [];
foreach ($customers as $customer) {
$statistics[$customer->getId()] = new CustomerStatistic();
}
$qb = $this->createStatisticQueryBuilder($customers, $begin, $end);
$result = $qb->getQuery()->getResult();
if (null !== $result) {
foreach ($result as $resultRow) {
$statistic = $statistics[$resultRow['id']];
$statistic->setDuration($statistic->getDuration() + $resultRow['duration']);
$statistic->setRate($statistic->getRate() + $resultRow['rate']);
$statistic->setInternalRate($statistic->getInternalRate() + $resultRow['internalRate']);
$statistic->setCounter($statistic->getCounter() + $resultRow['counter']);
if ($resultRow['billable']) {
$statistic->setDurationBillable($resultRow['duration']);
$statistic->setRateBillable($resultRow['rate']);
$statistic->setInternalRateBillable($resultRow['internalRate']);
$statistic->setCounterBillable($resultRow['counter']);
}
}
}
return $statistics;
}
private function createStatisticQueryBuilder(array $customers, DateTime $begin = null, ?DateTime $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(p.customer) AS id')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('COUNT(t.id) as counter')
->addSelect('t.billable as billable')
->andWhere($qb->expr()->isNotNull('t.end'))
->groupBy('id')
->addGroupBy('billable')
->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $customers)
;
if ($begin !== null) {
$qb
->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
;
}
if ($end !== null) {
$qb
->andWhere($qb->expr()->lte('t.begin', ':end'))
->setParameter('end', $end, Types::DATETIME_MUTABLE)
;
}
return $qb;
}
}

View File

@@ -50,8 +50,11 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "color", "visible", "comment"})
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()")
*/
class Activity implements EntityWithMetaFields
class Activity implements EntityWithMetaFields, EntityWithBudget
{
use BudgetTrait;
use ColorTrait;
/**
* Internal ID
*
@@ -120,40 +123,6 @@ class Activity implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $visible = true;
// keep the traits here, for placing the column at the "correct" position
use ColorTrait;
/**
* The total monetary budget, will be zero if unconfigured.
*
* @var float
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\Range(min=0.00, max=900000000000.00)
* @Assert\NotNull()
*/
private $budget = 0.00;
/**
* The time budget in seconds, will be zero if unconfigured.
*
* @var int
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\Range(min=0, max=2145600000)
* @Assert\NotNull()
*/
private $timeBudget = 0;
/**
* Meta fields
*
@@ -258,40 +227,6 @@ class Activity implements EntityWithMetaFields
return $this->visible;
}
public function setBudget(float $budget): Activity
{
$this->budget = $budget;
return $this;
}
public function getBudget(): float
{
return $this->budget;
}
public function hasBudget(): bool
{
return $this->budget > 0.00;
}
public function setTimeBudget(int $seconds): Activity
{
$this->timeBudget = $seconds;
return $this;
}
public function getTimeBudget(): int
{
return $this->timeBudget;
}
public function hasTimeBudget(): bool
{
return $this->timeBudget > 0;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/

116
src/Entity/BudgetTrait.php Normal file
View File

@@ -0,0 +1,116 @@
<?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\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
trait BudgetTrait
{
/**
* The total monetary budget, will be zero if unconfigured.
*
* @var float
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity", "Project_Entity", "Customer_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\Range(min=0.00, max=900000000000.00)
* @Assert\NotNull()
*/
private $budget = 0.00;
/**
* The time budget in seconds, will be zero if unconfigured.
*
* @var int
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity", "Project_Entity", "Customer_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\Range(min=0, max=2145600000)
* @Assert\NotNull()
*/
private $timeBudget = 0;
/**
* The type of budget:
* - null = default / full time
* - month = monthly budget
*
* @var string
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity", "Project_Entity", "Customer_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="budget_type", type="string", length=10, nullable=true)
*/
private $budgetType;
public function setBudget(float $budget): void
{
$this->budget = $budget;
}
public function getBudget(): float
{
return $this->budget;
}
public function hasBudget(): bool
{
return $this->budget > 0.00;
}
public function setTimeBudget(int $seconds): void
{
$this->timeBudget = $seconds;
}
public function getTimeBudget(): int
{
return $this->timeBudget;
}
public function hasTimeBudget(): bool
{
return $this->timeBudget > 0;
}
public function setBudgetType(?string $budgetType = null): void
{
if ($budgetType !== null && !\in_array($budgetType, ['month'])) {
throw new \InvalidArgumentException('Unknown budget type: ' . $budgetType);
}
$this->budgetType = $budgetType;
}
public function getBudgetType(): ?string
{
return $this->budgetType;
}
public function isMonthlyBudget(): bool
{
return $this->hasBudgets() && $this->budgetType === 'month';
}
public function hasBudgets(): bool
{
return ($this->hasTimeBudget() || $this->hasBudget());
}
}

View File

@@ -30,10 +30,13 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "color", "visible", "teams", "comment"})
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Customer implements EntityWithMetaFields
class Customer implements EntityWithMetaFields, EntityWithBudget
{
public const DEFAULT_CURRENCY = 'EUR';
use BudgetTrait;
use ColorTrait;
/**
* @var int|null
*
@@ -251,40 +254,6 @@ class Customer implements EntityWithMetaFields
* @Assert\Length(max=64)
*/
private $timezone;
// keep the trait include exactly here, for placing the column at the correct position
use ColorTrait;
/**
* The total monetary budget, will be zero if not configured.
*
* @var float
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\Range(min=0.00, max=900000000000.00)
* @Assert\NotNull()
*/
private $budget = 0.00;
/**
* The time budget in seconds, will be zero if not configured.
*
* @var int
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\Range(min=0, max=2145600000)
* @Assert\NotNull()
*/
private $timeBudget = 0;
/**
* Meta fields
*
@@ -528,40 +497,6 @@ class Customer implements EntityWithMetaFields
return $this->timezone;
}
public function setBudget(float $budget): Customer
{
$this->budget = $budget;
return $this;
}
public function getBudget(): float
{
return $this->budget;
}
public function hasBudget(): bool
{
return $this->budget > 0.00;
}
public function setTimeBudget(int $seconds): Customer
{
$this->timeBudget = $seconds;
return $this;
}
public function getTimeBudget(): int
{
return $this->timeBudget;
}
public function hasTimeBudget(): bool
{
return $this->timeBudget > 0;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/

View File

@@ -0,0 +1,34 @@
<?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\Entity;
/**
* @internal
*/
interface EntityWithBudget
{
public function setBudget(float $budget): void;
public function getBudget(): float;
public function hasBudget(): bool;
public function setTimeBudget(int $seconds): void;
public function getTimeBudget(): int;
public function hasTimeBudget(): bool;
public function isMonthlyBudget(): bool;
public function getBudgetType(): ?string;
public function setBudgetType(?string $budgetType = null): void;
}

View File

@@ -52,8 +52,11 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()")
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Project implements EntityWithMetaFields
class Project implements EntityWithMetaFields, EntityWithBudget
{
use BudgetTrait;
use ColorTrait;
/**
* Internal ID
*
@@ -192,40 +195,6 @@ class Project implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $visible = true;
// keep the trait include exactly here, for placing the column at the correct position
use ColorTrait;
/**
* The total monetary budget, will be zero if not configured.
*
* @var float
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\Range(min=0.00, max=900000000000.00)
* @Assert\NotNull()
*/
private $budget = 0.00;
/**
* The time budget in seconds, will be zero if not configured.
*
* @var int
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\Range(min=0, max=2145600000)
* @Assert\NotNull()
*/
private $timeBudget = 0;
/**
* Meta fields
*
@@ -422,40 +391,6 @@ class Project implements EntityWithMetaFields
return $this;
}
public function setBudget(float $budget): Project
{
$this->budget = $budget;
return $this;
}
public function getBudget(): float
{
return $this->budget;
}
public function hasBudget(): bool
{
return $this->budget > 0.00;
}
public function setTimeBudget(int $seconds): Project
{
$this->timeBudget = $seconds;
return $this;
}
public function getTimeBudget(): int
{
return $this->timeBudget;
}
public function hasTimeBudget(): bool
{
return $this->timeBudget > 0;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/

View File

@@ -30,6 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Index(columns={"start_time"}),
* @ORM\Index(columns={"start_time","end_time"}),
* @ORM\Index(columns={"start_time","end_time","user"}),
* @ORM\Index(columns={"date_tz","user"}),
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
@@ -108,6 +109,16 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* Reflects the date in the users timezone (not in UTC).
* This value is automatically set through the begin column and ONLY used in statistic queries.
*
* @var \DateTime
*
* @ORM\Column(name="date_tz", type="date", nullable=false)
* @Assert\NotNull()
*/
private $date;
/**
* @var DateTime
*
@@ -360,6 +371,8 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
{
$this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
// make sure that the original date is always
$this->date = new DateTime($begin->format('Y-m-d 00:00:00'), new DateTimeZone('UTC'));
return $this;
}
@@ -588,8 +601,8 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
}
/**
* BE WARNED: this method should NOT be used.
* It was ONLY introduced for the command "kimai:import-v1".
* BE WARNED: this method should NOT be used from outside.
* It is reserved for some very rare use-cases.
*
* @internal
* @param string $timezone

View File

@@ -0,0 +1,64 @@
<?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\Event;
use App\Model\ActivityBudgetStatisticModel;
final class ActivityBudgetStatisticEvent
{
private $models;
private $begin;
private $end;
/**
* @param ActivityBudgetStatisticModel[] $models
* @param \DateTime|null $begin
* @param \DateTime|null $end
*/
public function __construct(array $models, ?\DateTime $begin = null, ?\DateTime $end = null)
{
$this->models = $models;
$this->begin = $begin;
$this->end = $end;
}
public function getModel(int $activityId): ?ActivityBudgetStatisticModel
{
if (isset($this->models[$activityId])) {
return $this->models[$activityId];
}
foreach ($this->models as $model) {
if ($model->getActivity()->getId() === $activityId) {
return $model;
}
}
return null;
}
/**
* @return ActivityBudgetStatisticModel[]
*/
public function getModels(): array
{
return $this->models;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

@@ -15,15 +15,29 @@ use App\Model\ActivityStatistic;
final class ActivityStatisticEvent extends AbstractActivityEvent
{
private $statistic;
private $begin;
private $end;
public function __construct(Activity $activity, ActivityStatistic $statistic)
public function __construct(Activity $activity, ActivityStatistic $statistic, \DateTime $begin = null, \DateTime $end = null)
{
parent::__construct($activity);
$this->statistic = $statistic;
$this->begin = $begin;
$this->end = $end;
}
public function getStatistic(): ActivityStatistic
{
return $this->statistic;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

@@ -15,15 +15,29 @@ use App\Model\CustomerStatistic;
final class CustomerStatisticEvent extends AbstractCustomerEvent
{
private $statistic;
private $begin;
private $end;
public function __construct(Customer $customer, CustomerStatistic $statistic)
public function __construct(Customer $customer, CustomerStatistic $statistic, \DateTime $begin = null, \DateTime $end = null)
{
parent::__construct($customer);
$this->statistic = $statistic;
$this->begin = $begin;
$this->end = $end;
}
public function getStatistic(): CustomerStatistic
{
return $this->statistic;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

@@ -0,0 +1,64 @@
<?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\Event;
use App\Model\ProjectBudgetStatisticModel;
final class ProjectBudgetStatisticEvent
{
private $models;
private $begin;
private $end;
/**
* @param ProjectBudgetStatisticModel[] $models
* @param \DateTime|null $begin
* @param \DateTime|null $end
*/
public function __construct(array $models, ?\DateTime $begin = null, ?\DateTime $end = null)
{
$this->models = $models;
$this->begin = $begin;
$this->end = $end;
}
public function getModel(int $projectId): ?ProjectBudgetStatisticModel
{
if (isset($this->models[$projectId])) {
return $this->models[$projectId];
}
foreach ($this->models as $model) {
if ($model->getProject()->getId() === $projectId) {
return $model;
}
}
return null;
}
/**
* @return ProjectBudgetStatisticModel[]
*/
public function getModels(): array
{
return $this->models;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

@@ -15,15 +15,29 @@ use App\Model\ProjectStatistic;
final class ProjectStatisticEvent extends AbstractProjectEvent
{
private $statistic;
private $begin;
private $end;
public function __construct(Project $project, ProjectStatistic $statistic)
public function __construct(Project $project, ProjectStatistic $statistic, \DateTime $begin = null, \DateTime $end = null)
{
parent::__construct($project);
$this->statistic = $statistic;
$this->begin = $begin;
$this->end = $end;
}
public function getStatistic(): ProjectStatistic
{
return $this->statistic;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

@@ -64,8 +64,14 @@ class CustomerSubscriber extends AbstractActionsSubscriber
$event->addDivider();
}
if ($customer->isVisible() && $this->isGranted('create_project')) {
$event->addAction('create-project', ['icon' => 'create', 'url' => $this->path('admin_project_create_with_customer', ['customer' => $customer->getId()]), 'class' => 'modal-ajax-form']);
if (!$event->isView('customer_details')) {
if ($customer->isVisible() && $this->isGranted('create_project')) {
$event->addAction('create-project', [
'icon' => 'create',
'url' => $this->path('admin_project_create_with_customer', ['customer' => $customer->getId()]),
'class' => 'modal-ajax-form'
]);
}
}
if ($event->isIndexView() && $this->isGranted('delete', $customer)) {

View File

@@ -60,12 +60,21 @@ class ProjectSubscriber extends AbstractActionsSubscriber
$event->addDivider();
}
if ($project->isVisible() && $project->getCustomer()->isVisible() && $this->isGranted('create_activity')) {
$event->addAction('create-activity', ['icon' => 'create', 'url' => $this->path('admin_activity_create_with_project', ['project' => $project->getId()]), 'class' => 'modal-ajax-form']);
}
if (!$event->isView('project_details')) {
if ($project->isVisible() && $project->getCustomer()->isVisible() && $this->isGranted('create_activity')) {
$event->addAction('create-activity', [
'icon' => 'create',
'url' => $this->path('admin_activity_create_with_project', ['project' => $project->getId()]),
'class' => 'modal-ajax-form'
]);
}
if ($this->isGranted('edit', $project)) {
$event->addAction('copy', ['url' => $this->path('admin_project_duplicate', ['id' => $project->getId()])]);
if ($this->isGranted('edit', $project)) {
$event->addAction(
'copy',
['url' => $this->path('admin_project_duplicate', ['id' => $project->getId()])]
);
}
}
if (($event->isIndexView() || $event->isView('customer_details')) && $this->isGranted('delete', $project)) {

View File

@@ -9,6 +9,7 @@
namespace App\Export\Base;
use App\Activity\ActivityStatisticService;
use App\Entity\MetaTableTypeInterface;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
@@ -32,14 +33,9 @@ class HtmlRenderer
* @var Environment
*/
protected $twig;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var ProjectStatisticService
*/
private $projectStatisticService;
private $activityStatisticService;
/**
* @var string
*/
@@ -49,11 +45,12 @@ class HtmlRenderer
*/
private $template = 'default.html.twig';
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher, ProjectStatisticService $projectStatisticService)
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher, ProjectStatisticService $projectStatisticService, ActivityStatisticService $activityStatisticService)
{
$this->twig = $twig;
$this->dispatcher = $dispatcher;
$this->projectStatisticService = $projectStatisticService;
$this->activityStatisticService = $activityStatisticService;
}
/**
@@ -108,6 +105,7 @@ class HtmlRenderer
'query' => $query,
'summaries' => $summary,
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
'activity_budgets' => $this->calculateActivityBudget($timesheets, $query, $this->activityStatisticService),
// @deprecated since 1.3, will be removed with 2.0
'metaColumns' => $timesheetMetaFields,
'timesheetMetaFields' => $timesheetMetaFields,

View File

@@ -9,6 +9,7 @@
namespace App\Export\Base;
use App\Activity\ActivityStatisticService;
use App\Export\ExportItemInterface;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
@@ -25,24 +26,22 @@ trait RendererTrait
foreach ($exportItems as $exportItem) {
$customerId = 'none';
$customerName = '';
$currency = null;
$projectId = 'none';
$projectName = '';
$activityId = 'none';
$activityName = '';
$customer = null;
$project = null;
$activity = null;
$currency = null;
if (null !== $exportItem->getProject()) {
$customerId = $exportItem->getProject()->getCustomer()->getId();
$customerName = $exportItem->getProject()->getCustomer()->getName();
$projectId = $exportItem->getProject()->getId();
$projectName = $exportItem->getProject()->getName();
$currency = $exportItem->getProject()->getCustomer()->getCurrency();
if (null !== ($project = $exportItem->getProject())) {
$customer = $project->getCustomer();
$customerId = $customer->getId();
$projectId = $project->getId();
$currency = $customer->getCurrency();
}
if (null !== $exportItem->getActivity()) {
if (null !== ($activity = $exportItem->getActivity())) {
$activityId = $exportItem->getActivity()->getId();
$activityName = $exportItem->getActivity()->getName();
}
$id = $customerId . '_' . $projectId;
@@ -51,8 +50,8 @@ trait RendererTrait
if (!isset($summary[$id])) {
$summary[$id] = [
'customer' => $customerName,
'project' => $projectName,
'customer' => '',
'project' => '',
'activities' => [],
'currency' => $currency,
'rate' => 0,
@@ -61,6 +60,11 @@ trait RendererTrait
'type' => [],
'types' => [],
];
if ($project !== null) {
$summary[$id]['customer'] = $customer->getName();
$summary[$id]['project'] = $project->getName();
}
}
if (!isset($summary[$id]['type'][$type])) {
@@ -81,12 +85,16 @@ trait RendererTrait
if (!isset($summary[$id]['activities'][$activityId])) {
$summary[$id]['activities'][$activityId] = [
'activity' => $activityName,
'activity' => '',
'currency' => $currency,
'rate' => 0,
'rate_internal' => 0,
'duration' => 0,
];
if ($activity !== null) {
$summary[$id]['activities'][$activityId]['activity'] = $activity->getName();
}
}
$duration = $exportItem->getDuration();
@@ -130,17 +138,21 @@ trait RendererTrait
protected function calculateProjectBudget(array $exportItems, TimesheetQuery $query, ProjectStatisticService $projectStatisticService)
{
$summary = [];
$projects = [];
foreach ($exportItems as $exportItem) {
$customer = null;
$customerId = 'none';
$project = null;
$customerId = 'none';
$projectId = 'none';
if (null !== ($project = $exportItem->getProject())) {
$customer = $project->getCustomer();
$customerId = $customer->getId();
$projectId = $project->getId();
if ($project->hasBudgets()) {
$projects[] = $project;
}
}
$id = $customerId . '_' . $projectId;
@@ -152,17 +164,88 @@ trait RendererTrait
'time_left' => null,
'money_left' => null,
];
}
}
if (null !== $project && ($project->getTimeBudget() > 0 || $project->getBudget() > 0)) {
$projectStats = $projectStatisticService->getProjectStatistics($project, $query->getEnd());
$allBudgets = $projectStatisticService->getBudgetStatisticModelForProjects($projects, $query->getEnd());
if ($project->getTimeBudget() > 0) {
$summary[$id]['time_left'] = $project->getTimeBudget() - $projectStats->getRecordDuration();
}
if ($project->getBudget() > 0) {
$summary[$id]['money_left'] = $project->getBudget() - $projectStats->getRecordRate();
}
}
foreach ($allBudgets as $projectId => $statisticModel) {
$project = $statisticModel->getProject();
$id = $project->getCustomer()->getId() . '_' . $projectId;
if ($statisticModel->hasTimeBudget()) {
$summary[$id]['time_left'] = $statisticModel->getTimeBudgetOpen();
}
if ($statisticModel->hasBudget()) {
$summary[$id]['money_left'] = $statisticModel->getBudgetOpen();
}
}
return $summary;
}
/**
* @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query
* @param ActivityStatisticService $activityStatisticService
* @return array
*/
protected function calculateActivityBudget(array $exportItems, TimesheetQuery $query, ActivityStatisticService $activityStatisticService)
{
$summary = [];
$activities = [];
foreach ($exportItems as $exportItem) {
$customerId = 'none';
$projectId = 'none';
$customer = null;
$project = null;
$activity = null;
if (null === ($activity = $exportItem->getActivity())) {
continue;
}
if ($activity->isGlobal()) {
continue;
}
if ($activity->hasBudgets()) {
$activities[] = $activity;
}
if (null !== ($project = $exportItem->getProject())) {
$projectId = $project->getId();
$customerId = $project->getCustomer()->getId();
}
$id = $customerId . '_' . $projectId;
if (!isset($summary[$id])) {
$summary[$id] = [];
}
$activityId = $activity->getId();
if (!isset($summary[$id][$activityId])) {
$summary[$id][$activityId] = [
'time' => $activity->getTimeBudget(),
'money' => $activity->getBudget(),
'time_left' => null,
'money_left' => null,
];
}
}
$allBudgets = $activityStatisticService->getBudgetStatisticModelForActivities($activities, $query->getEnd());
foreach ($allBudgets as $activityId => $statisticModel) {
$project = $statisticModel->getActivity()->getProject();
$id = $project->getCustomer()->getId() . '_' . $project->getId();
if ($statisticModel->hasTimeBudget()) {
$summary[$id][$activityId]['time_left'] = $statisticModel->getTimeBudgetOpen();
}
if ($statisticModel->hasBudget()) {
$summary[$id][$activityId]['money_left'] = $statisticModel->getBudgetOpen();
}
}

View File

@@ -9,35 +9,29 @@
namespace App\Export\Renderer;
use App\Activity\ActivityStatisticService;
use App\Project\ProjectStatisticService;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
final class HtmlRendererFactory
{
/**
* @var Environment
*/
private $twig;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var ProjectStatisticService
*/
private $projectStatisticService;
private $activityStatisticService;
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher, ProjectStatisticService $projectStatisticService)
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher, ProjectStatisticService $projectStatisticService, ActivityStatisticService $activityStatisticService)
{
$this->twig = $twig;
$this->dispatcher = $dispatcher;
$this->projectStatisticService = $projectStatisticService;
$this->activityStatisticService = $activityStatisticService;
}
public function create(string $id, string $template): HtmlRenderer
{
$renderer = new HtmlRenderer($this->twig, $this->dispatcher, $this->projectStatisticService);
$renderer = new HtmlRenderer($this->twig, $this->dispatcher, $this->projectStatisticService, $this->activityStatisticService);
$renderer->setId($id);
$renderer->setTemplate($template);

View File

@@ -9,6 +9,7 @@
namespace App\Form;
use App\Form\Type\BudgetType;
use App\Form\Type\DurationType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\YesNoType;
@@ -38,6 +39,7 @@ trait EntityFormTrait
'icon' => 'clock',
'required' => false,
])
->add('budgetType', BudgetType::class)
;
}

View File

@@ -10,21 +10,21 @@
namespace App\Form\Extension;
use App\Entity\User;
use App\Security\CurrentUser;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
final class UserExtension extends AbstractTypeExtension
{
/**
* @var CurrentUser
* @var Security
*/
private $user;
private $security;
public function __construct(CurrentUser $user)
public function __construct(Security $security)
{
$this->user = $user;
$this->security = $security;
}
public static function getExtendedTypes(): iterable
@@ -37,6 +37,6 @@ final class UserExtension extends AbstractTypeExtension
$resolver->setDefined(['user']);
// null needs to be allowed, as there is no user for anonymous forms (like "forgot password" and "registration")
$resolver->setAllowedTypes('user', [User::class, 'null']);
$resolver->setDefault('user', $this->user->getUser());
$resolver->setDefault('user', $this->security->getUser());
}
}

View File

@@ -0,0 +1,42 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select the type of budget.
*/
class BudgetType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.budgetType',
'required' => false,
'choices' => [
'label.budgetType_month' => 'month',
],
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -9,7 +9,6 @@
namespace App\Form\Type;
use App\Utils\MomentFormatConverter;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormInterface;
@@ -48,7 +47,6 @@ final class MonthPickerType extends AbstractType
$view->vars['month'] = $date;
$view->vars['previousMonth'] = (clone $date)->modify('-1 month');
$view->vars['nextMonth'] = (clone $date)->modify('+1 month');
$view->vars['momentFormat'] = (new MomentFormatConverter())->convert($options['format']);
}
/**

View File

@@ -9,7 +9,6 @@
namespace App\Form\Type;
use App\Utils\MomentFormatConverter;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormInterface;
@@ -48,7 +47,6 @@ final class WeekPickerType extends AbstractType
$view->vars['week'] = $date;
$view->vars['previousWeek'] = (clone $date)->modify('-1 week');
$view->vars['nextWeek'] = (clone $date)->modify('+1 week');
$view->vars['momentFormat'] = (new MomentFormatConverter())->convert($options['format']);
}
/**

View File

@@ -11,8 +11,8 @@ declare(strict_types=1);
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* @version 1.15

View File

@@ -11,8 +11,8 @@ declare(strict_types=1);
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* @version 1.15

View File

@@ -11,8 +11,8 @@ declare(strict_types=1);
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* @version 1.15

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20210719123928 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the budget_type columns to customer, project and activity';
}
public function up(Schema $schema): void
{
$activities = $schema->getTable('kimai2_activities');
$activities->addColumn('budget_type', 'string', ['length' => 10, 'notnull' => false, 'default' => null]);
$customers = $schema->getTable('kimai2_customers');
$customers->addColumn('budget_type', 'string', ['length' => 10, 'notnull' => false, 'default' => null]);
$projects = $schema->getTable('kimai2_projects');
$projects->addColumn('budget_type', 'string', ['length' => 10, 'notnull' => false, 'default' => null]);
}
public function down(Schema $schema): void
{
$activities = $schema->getTable('kimai2_activities');
$activities->dropColumn('budget_type');
$customers = $schema->getTable('kimai2_customers');
$customers->dropColumn('budget_type');
$projects = $schema->getTable('kimai2_projects');
$projects->dropColumn('budget_type');
}
}

View File

@@ -11,10 +11,10 @@ declare(strict_types=1);
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
/**
* @version 1.15

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
/**
* @version 1.15
*/
final class Version20210802152259 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the date column to the timesheet table';
}
public function up(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->addColumn('date_tz', Types::DATE_MUTABLE, ['notnull' => false]);
}
public function down(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->dropColumn('date_tz');
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 1.15
*/
final class Version20210802152814 extends AbstractMigration
{
public function getDescription(): string
{
return 'Fills the new date column in the timesheet table';
}
public function up(Schema $schema): void
{
$fetch = $this->connection->prepare('SELECT id, start_time, timezone FROM kimai2_timesheet WHERE date_tz IS NULL');
$timezones = [];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$timezones[$tz] = new \DateTimeZone($tz);
}
foreach ($fetch->executeQuery()->iterateAssociative() as $row) {
if (!isset($timezones[$row['timezone']])) {
$timezones[$row['timezone']] = new \DateTimeZone($row['timezone']);
}
$date = new \DateTime($row['start_time'], $timezones['UTC']);
$date->setTimezone($timezones[$row['timezone']]);
$this->addSql('UPDATE kimai2_timesheet SET date_tz = ? WHERE id = ?', [$date->format('Y-m-d'), $row['id']]);
}
$fetch->free();
}
public function down(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->changeColumn('date_tz', ['notnull' => false]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 1.15
*/
final class Version20210802160837 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates the index on the new timesheet statistic date column';
}
public function up(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->changeColumn('date_tz', ['notnull' => true]);
$timesheet->addIndex(['date_tz', 'user'], 'IDX_4F60C6B1BDF467148D93D649');
}
public function down(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->changeColumn('date_tz', ['notnull' => false]);
$timesheet->dropIndex('IDX_4F60C6B1BDF467148D93D649');
}
}

View File

@@ -0,0 +1,31 @@
<?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\Model;
use App\Entity\Activity;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
* @method Activity getEntity()
*/
class ActivityBudgetStatisticModel extends BudgetStatisticModel
{
public function __construct(Activity $activity)
{
parent::__construct($activity);
}
public function getActivity(): Activity
{
return $this->getEntity();
}
}

View File

@@ -10,8 +10,9 @@
namespace App\Model;
use App\Entity\Activity;
use App\Model\Statistic\BudgetStatistic;
class ActivityStatistic extends TimesheetCountedStatistic implements \JsonSerializable
class ActivityStatistic extends BudgetStatistic implements \JsonSerializable
{
/**
* @var Activity

View File

@@ -0,0 +1,198 @@
<?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\Model;
use App\Entity\EntityWithBudget;
use App\Model\Statistic\BudgetStatistic;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
*/
class BudgetStatisticModel implements BudgetStatisticModelInterface
{
/**
* @var EntityWithBudget
*/
private $entity;
/**
* @var BudgetStatistic
*/
private $statistic;
/**
* @var BudgetStatistic
*/
private $statisticTotal;
public function __construct(EntityWithBudget $entity)
{
$this->entity = $entity;
}
public function getEntity(): EntityWithBudget
{
return $this->entity;
}
public function getStatistic(): ?BudgetStatistic
{
return $this->statistic;
}
public function setStatistic(BudgetStatistic $statistic)
{
$this->statistic = $statistic;
}
public function getStatisticTotal(): ?BudgetStatistic
{
return $this->statisticTotal;
}
public function setStatisticTotal(BudgetStatistic $statistic)
{
$this->statisticTotal = $statistic;
}
public function hasTimeBudget(): bool
{
return $this->entity->hasTimeBudget();
}
public function getTimeBudget(): int
{
return $this->entity->getTimeBudget();
}
public function isMonthlyBudget(): bool
{
return $this->entity->isMonthlyBudget();
}
public function getDurationBillable(): int
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0;
}
return $this->statistic->getDurationBillable();
}
if ($this->statisticTotal === null) {
return 0;
}
return $this->statisticTotal->getDurationBillable();
}
public function getTimeBudgetOpen(): int
{
$value = $this->getTimeBudget() - $this->getTimeBudgetSpent();
return $value > 0 ? $value : 0;
}
public function getTimeBudgetSpent(): int
{
return $this->getDurationBillable();
}
public function hasBudget(): bool
{
return $this->entity->hasBudget();
}
public function getBudget(): float
{
return $this->entity->getBudget();
}
public function getBudgetOpen(): float
{
$value = $this->getBudget() - $this->getBudgetSpent();
return $value > 0 ? $value : 0;
}
public function getBudgetSpent(): float
{
return $this->getRateBillable();
}
public function getRateBillable(): float
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0.00;
}
return $this->statistic->getRateBillable();
}
if ($this->statisticTotal === null) {
return 0.00;
}
return $this->statisticTotal->getRateBillable();
}
public function getRate(): float
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0.00;
}
return $this->statistic->getRate();
}
if ($this->statisticTotal === null) {
return 0.00;
}
return $this->statisticTotal->getRate();
}
public function getDuration(): int
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0;
}
return $this->statistic->getDuration();
}
if ($this->statisticTotal === null) {
return 0;
}
return $this->statisticTotal->getDuration();
}
public function getInternalRate(): float
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0.00;
}
return $this->statistic->getInternalRate();
}
if ($this->statisticTotal === null) {
return 0.00;
}
return $this->statisticTotal->getInternalRate();
}
}

View File

@@ -0,0 +1,30 @@
<?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\Model;
/**
* @internal do not use in plugins, no BC promise given!
*/
interface BudgetStatisticModelInterface
{
public function isMonthlyBudget(): bool;
public function hasTimeBudget(): bool;
public function getTimeBudget(): int;
public function getTimeBudgetSpent(): int;
public function hasBudget(): bool;
public function getBudget(): float;
public function getBudgetSpent(): float;
}

View File

@@ -0,0 +1,31 @@
<?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\Model;
use App\Entity\Customer;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
* @method Customer getEntity()
*/
class CustomerBudgetStatisticModel extends BudgetStatisticModel
{
public function __construct(Customer $customer)
{
parent::__construct($customer);
}
public function getCustomer(): Customer
{
return $this->getEntity();
}
}

View File

@@ -9,7 +9,9 @@
namespace App\Model;
class CustomerStatistic extends TimesheetCountedStatistic
use App\Model\Statistic\BudgetStatistic;
class CustomerStatistic extends BudgetStatistic
{
/**
* @var int
@@ -20,11 +22,20 @@ class CustomerStatistic extends TimesheetCountedStatistic
*/
private $projectAmount = 0;
/**
* @deprecated since 1.15 - will be removed with 2.0
* @return int
*/
public function getActivityAmount(): int
{
return $this->activityAmount;
}
/**
* @deprecated since 1.15 - will be removed with 2.0
* @param int $activityAmount
* @return $this
*/
public function setActivityAmount(int $activityAmount): CustomerStatistic
{
$this->activityAmount = $activityAmount;
@@ -32,11 +43,20 @@ class CustomerStatistic extends TimesheetCountedStatistic
return $this;
}
/**
* @deprecated since 1.15 - will be removed with 2.0
* @return int
*/
public function getProjectAmount(): int
{
return $this->projectAmount;
}
/**
* @deprecated since 1.15 - will be removed with 2.0
* @param int $projectAmount
* @return $this
*/
public function setProjectAmount(int $projectAmount): CustomerStatistic
{
$this->projectAmount = $projectAmount;

View File

@@ -0,0 +1,109 @@
<?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\Model;
use App\Entity\User;
use App\Model\Statistic\StatisticDate;
use DateTime;
use DateTimeInterface;
final class DailyStatistic
{
/**
* @var array<string, StatisticDate>
*/
private $days = [];
private $begin;
private $end;
private $user;
public function __construct(DateTime $begin, DateTime $end, User $user)
{
$this->begin = clone $begin;
$this->end = clone $end;
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
private function setupDays(): void
{
if (!empty($this->days)) {
return;
}
$tmp = clone $this->begin;
$tmp->setTime(0, 0, 0);
while ($tmp < $this->end) {
$id = $tmp->format('Y-m-d');
$this->days[$id] = new StatisticDate(clone $tmp);
$tmp->modify('+1 day');
}
}
/**
* @return StatisticDate[]
*/
public function getDays(): array
{
$this->setupDays();
return array_values($this->days);
}
public function getDayByDateTime(\DateTimeInterface $date): ?StatisticDate
{
return $this->getDay($date->format('Y'), $date->format('m'), $date->format('d'));
}
public function getDayByReportDate(string $date): ?StatisticDate
{
$this->setupDays();
if (!isset($this->days[$date])) {
return null;
}
return $this->days[$date];
}
public function getDay(string $year, string $month, string $day): ?StatisticDate
{
if ((int) $month < 10) {
$month = '0' . (int) $month;
}
if ((int) $day < 10) {
$day = '0' . (int) $day;
}
$date = $year . '-' . $month . '-' . $day;
return $this->getDayByReportDate($date);
}
/**
* @return DateTimeInterface[]
*/
public function getDateTimes(): array
{
$this->setupDays();
$all = [];
foreach ($this->days as $id => $day) {
$all[] = $day->getDate();
}
return $all;
}
}

View File

@@ -0,0 +1,139 @@
<?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\Model;
use App\Entity\User;
use App\Model\Statistic\StatisticDate;
use DateTime;
use DateTimeInterface;
final class MonthlyStatistic
{
/**
* @var array<string, array<int, StatisticDate>>
*/
private $years = [];
private $begin;
private $end;
private $user;
public function __construct(DateTime $begin, DateTime $end, User $user)
{
$this->begin = clone $begin;
$this->end = clone $end;
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
private function setupYears(): void
{
if (!empty($this->years)) {
return;
}
$years = [];
$begin = clone $this->begin;
$begin->setTime(0, 0, 0);
$tmp = clone $begin;
$day = (int) $begin->format('d');
while ($tmp < $this->end) {
$curYear = $tmp->format('Y');
if (!isset($years[$curYear])) {
$year = [];
for ($i = 1; $i < 13; $i++) {
$date = clone $begin;
// financial years do NOT start at the first of the month, do not reset day to 1
$date->setDate((int) $curYear, $i, $day);
$date->setTime(0, 0, 0);
if ($date < $begin || $date > $this->end) {
continue;
}
$year[$i] = new StatisticDate($date);
}
$years[$curYear] = $year;
}
$tmp->modify('+1 month');
}
$this->years = $years;
}
/**
* @return string[]
*/
public function getYears(): array
{
$this->setupYears();
return array_keys($this->years);
}
public function getYear(string $year): ?array
{
$this->setupYears();
if (!isset($this->years[$year])) {
return null;
}
return $this->years[$year];
}
/**
* @return StatisticDate[]
*/
public function getMonths(): array
{
$this->setupYears();
$all = [];
foreach ($this->years as $number => $months) {
foreach ($months as $monthNumber => $statisticDate) {
$all[] = $statisticDate;
}
}
return $all;
}
public function getMonth(string $year, string $month): ?StatisticDate
{
$this->setupYears();
$month = (int) $month;
if (!isset($this->years[$year]) || !isset($this->years[$year][$month])) {
return null;
}
return $this->years[$year][$month];
}
/**
* @return DateTimeInterface[]
*/
public function getDateTimes(): array
{
$this->setupYears();
$all = [];
foreach ($this->years as $number => $months) {
foreach ($months as $monthNumber => $statisticDate) {
$all[] = $statisticDate->getDate();
}
}
return $all;
}
}

View File

@@ -0,0 +1,31 @@
<?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\Model;
use App\Entity\Project;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
* @method Project getEntity()
*/
class ProjectBudgetStatisticModel extends BudgetStatisticModel
{
public function __construct(Project $project)
{
parent::__construct($project);
}
public function getProject(): Project
{
return $this->getEntity();
}
}

View File

@@ -9,18 +9,29 @@
namespace App\Model;
class ProjectStatistic extends TimesheetCountedStatistic
use App\Model\Statistic\BudgetStatistic;
class ProjectStatistic extends BudgetStatistic
{
/**
* @var int
*/
private $activityAmount = 0;
/**
* @deprecated since 1.15 - will be removed with 2.0
* @return int
*/
public function getActivityAmount(): int
{
return $this->activityAmount;
}
/**
* @deprecated since 1.15 - will be removed with 2.0
* @param int $activityAmount
* @return $this
*/
public function setActivityAmount(int $activityAmount): ProjectStatistic
{
$this->activityAmount = $activityAmount;

View File

@@ -0,0 +1,19 @@
<?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\Model\Statistic;
use App\Model\TimesheetCountedStatistic;
/**
* @final
*/
class BudgetStatistic extends TimesheetCountedStatistic
{
}

View File

@@ -0,0 +1,47 @@
<?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\Model\Statistic;
final class StatisticDate extends Timesheet
{
private $date;
private $billableDuration = 0;
private $billableRate = 0.00;
public function __construct(\DateTimeInterface $date)
{
$this->date = clone $date;
}
public function getDate(): \DateTimeInterface
{
return $this->date;
}
public function getBillableDuration(): int
{
return $this->billableDuration;
}
public function setBillableDuration(int $billableDuration): void
{
$this->billableDuration = $billableDuration;
}
public function getBillableRate(): float
{
return $this->billableRate;
}
public function setBillableRate(float $billableRate): void
{
$this->billableRate = $billableRate;
}
}

View File

@@ -22,7 +22,7 @@ class Timesheet
*/
public function getValue(): int
{
return $this->totalDuration;
return $this->getDuration();
}
/**
@@ -37,7 +37,7 @@ class Timesheet
public function getTotalDuration(): int
{
return $this->totalDuration;
return $this->getDuration();
}
public function setTotalDuration(int $totalDuration): void
@@ -57,7 +57,7 @@ class Timesheet
public function getTotalRate(): float
{
return $this->totalRate;
return $this->getRate();
}
public function setTotalRate(float $totalRate): void
@@ -77,7 +77,7 @@ class Timesheet
public function getTotalInternalRate(): float
{
return $this->totalInternalRate;
return $this->getInternalRate();
}
public function setTotalInternalRate(float $totalInternalRate): void

View File

@@ -11,22 +11,50 @@ namespace App\Model;
class TimesheetCountedStatistic implements \JsonSerializable
{
private $recordAmount = 0;
private $counter = 0;
private $recordDuration = 0;
private $recordRate = 0.0;
private $recordInternalRate = 0.0;
private $recordAmountBillable = 0;
private $counterBillable = 0;
private $recordDurationBillable = 0;
private $recordRateBillable = 0.0;
private $internalRateBillable = 0.0;
/**
* For unified access, used in frontend.
*
* @return int
*/
public function getCounter(): int
{
return $this->counter;
}
public function setCounter(int $counter): void
{
$this->counter = $counter;
}
public function getCounterBillable(): int
{
return $this->counterBillable;
}
public function setCounterBillable(int $counter): void
{
$this->counterBillable = $counter;
}
/**
* Returns the total amount of included timesheet records.
*
* @return int
* @deprecated since 1.15 use getCounter() instead
*/
public function getRecordAmount()
{
return $this->recordAmount;
return $this->getCounter();
}
/**
@@ -35,7 +63,7 @@ class TimesheetCountedStatistic implements \JsonSerializable
*/
public function setRecordAmount($recordAmount)
{
$this->recordAmount = (int) $recordAmount;
$this->setCounter((int) $recordAmount);
return $this;
}
@@ -47,7 +75,12 @@ class TimesheetCountedStatistic implements \JsonSerializable
*/
public function getValue(): int
{
return $this->recordDuration;
return $this->getDuration();
}
public function setDuration(int $duration): void
{
$this->recordDuration = $duration;
}
/**
@@ -65,9 +98,9 @@ class TimesheetCountedStatistic implements \JsonSerializable
*
* @return int
*/
public function getRecordDuration()
public function getRecordDuration(): int
{
return $this->recordDuration;
return $this->getDuration();
}
/**
@@ -76,7 +109,7 @@ class TimesheetCountedStatistic implements \JsonSerializable
*/
public function setRecordDuration($recordDuration)
{
$this->recordDuration = (int) $recordDuration;
$this->setDuration((int) $recordDuration);
return $this;
}
@@ -96,9 +129,14 @@ class TimesheetCountedStatistic implements \JsonSerializable
*
* @return float
*/
public function getRecordRate()
public function getRecordRate(): float
{
return $this->recordRate;
return $this->getRate();
}
public function setRate(float $rate): void
{
$this->recordRate = $rate;
}
/**
@@ -107,40 +145,66 @@ class TimesheetCountedStatistic implements \JsonSerializable
*/
public function setRecordRate($recordRate)
{
$this->recordRate = (float) $recordRate;
$this->setRate((float) $recordRate);
return $this;
}
/**
* @deprecated since 1.15 use getInternalRate() instead
*/
public function getRecordInternalRate(): float
{
return $this->getInternalRate();
}
/**
* Returns the total internal rate of all included timesheet records.
*
* @return float
*/
public function getRecordInternalRate()
public function getInternalRate(): float
{
return $this->recordInternalRate;
}
public function getInternalRateBillable(): float
{
return $this->internalRateBillable;
}
public function setInternalRateBillable(float $internalRateBillable): void
{
$this->internalRateBillable = $internalRateBillable;
}
/**
* @param float $recordInternalRate
* @return $this
*/
public function setRecordInternalRate($recordInternalRate)
{
$this->recordInternalRate = (float) $recordInternalRate;
$this->setInternalRate((float) $recordInternalRate);
return $this;
}
public function setInternalRate(float $internalRate): void
{
$this->recordInternalRate = $internalRate;
}
/**
* @deprecated since 1.15 use getCounterBillable() instead
*/
public function getRecordAmountBillable(): int
{
return $this->recordAmountBillable;
return $this->getCounterBillable();
}
public function setRecordAmountBillable(int $recordAmount): void
{
$this->recordAmountBillable = $recordAmount;
$this->setCounterBillable($recordAmount);
}
public function getDurationBillable(): int
@@ -171,8 +235,8 @@ class TimesheetCountedStatistic implements \JsonSerializable
'rate' => $this->recordRate,
'rate_billable' => $this->recordRateBillable,
'rate_internal' => $this->recordInternalRate,
'amount' => $this->recordAmount,
'amount_billable' => $this->recordAmountBillable,
'amount' => $this->counter,
'amount_billable' => $this->counterBillable,
];
}
}

View File

@@ -107,6 +107,9 @@ class TimesheetStatistic
$this->amountThisMonth = (float) $amountThisMonth;
}
/**
* @deprecated since 1.15 use TimesheetStatisticService::findFirstRecordDate() instead, will be removed with 2.0
*/
public function getFirstEntry(): ?\DateTime
{
return $this->firstEntry;

View File

@@ -31,10 +31,10 @@ class UserStatistic extends TimesheetCountedStatistic
public function addValuesFromMonth(Month $month): void
{
$this->setRecordDuration($this->getRecordDuration() + $month->getTotalDuration());
$this->setDuration($this->getDuration() + $month->getDuration());
$this->setDurationBillable($this->getDurationBillable() + $month->getBillableDuration());
$this->setRecordRate($this->getRate() + $month->getTotalRate());
$this->setRate($this->getRate() + $month->getRate());
$this->setRateBillable($this->getRateBillable() + $month->getBillableRate());
$this->setRecordInternalRate($this->getRecordInternalRate() + $month->getTotalInternalRate());
$this->setInternalRate($this->getInternalRate() + $month->getInternalRate());
}
}

View File

@@ -13,17 +13,22 @@ use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\ProjectBudgetStatisticEvent;
use App\Event\ProjectStatisticEvent;
use App\Form\Model\DateRange;
use App\Model\ActivityStatistic;
use App\Model\ProjectBudgetStatisticModel;
use App\Model\ProjectStatistic;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
use App\Model\UserStatistic;
use App\Reporting\ProjectDateRange\ProjectDateRangeQuery;
use App\Reporting\ProjectDetails\ProjectDetailsModel;
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\Loader\ProjectLoader;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
@@ -51,13 +56,21 @@ class ProjectStatisticService
$this->userRepository = $userRepository;
}
public function getProjectStatistics(Project $project, ?DateTime $end = null): ProjectStatistic
/**
* WARNING: this method does not respect the budget type. Your results will always be wither the "full lifetime data" or the "selected date-range".
*
* @param Project $project
* @param DateTime|null $begin
* @param DateTime|null $end
* @return ProjectStatistic
*/
public function getProjectStatistics(Project $project, ?DateTime $begin = null, ?DateTime $end = null): ProjectStatistic
{
$statistic = $this->repository->getProjectStatistics($project, null, $end);
$event = new ProjectStatisticEvent($project, $statistic);
$statistics = $this->getBudgetStatistic([$project], $begin, $end);
$event = new ProjectStatisticEvent($project, array_pop($statistics), $begin, $end);
$this->dispatcher->dispatch($event);
return $statistic;
return $event->getStatistic();
}
/**
@@ -97,7 +110,253 @@ class ProjectStatisticService
$this->repository->addPermissionCriteria($qb, $user);
return $qb->getQuery()->getResult();
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager());
$loader->loadResults($projects);
return $projects;
}
/**
* @param ProjectDateRangeQuery $query
* @return Project[]
*/
public function findProjectsForDateRange(ProjectDateRangeQuery $query, DateRange $dateRange): array
{
$user = $query->getUser();
$begin = $dateRange->getBegin();
$end = $dateRange->getEnd();
$qb = $this->repository->createQueryBuilder('p');
$qb
->select('p')
->andWhere($qb->expr()->eq('p.visible', true))
->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':begin'),
$qb->expr()->isNull('p.end')
)
)
)
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
->setParameter('end', $end, Types::DATETIME_MUTABLE)
;
if ($query->isOnlyWithRecords()) {
$qb2 = $this->repository->createQueryBuilder('t1');
$qb2
->select('1')
->from(Timesheet::class, 't')
->andWhere('p = t.project')
->andWhere($qb2->expr()->between('t.begin', ':begin', ':end'))
;
$qb->andWhere($qb->expr()->exists($qb2));
}
if (!$query->isIncludeNoBudget()) {
$qb
->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('p.budget', 0.0),
$qb->expr()->gt('p.timeBudget', 0)
)
)
;
}
if ($query->getCustomer() !== null) {
$qb->andWhere($qb->expr()->eq('p.customer', ':customer'))
->setParameter('customer', $query->getCustomer());
}
$this->repository->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());
$loader->loadResults($projects);
return $projects;
}
public function getBudgetStatisticModel(Project $project, DateTime $today): ProjectBudgetStatisticModel
{
$stats = new ProjectBudgetStatisticModel($project);
$stats->setStatisticTotal($this->getProjectStatistics($project));
$begin = null;
$end = $today;
if ($project->isMonthlyBudget()) {
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
}
$stats->setStatistic($this->getProjectStatistics($project, $begin, $end));
$event = new ProjectBudgetStatisticEvent([$stats], $begin, $end);
$this->dispatcher->dispatch($event);
return $stats;
}
/**
* @param Project[] $projects
* @param DateTime $today
* @return ProjectBudgetStatisticModel[]
*/
public function getBudgetStatisticModelForProjects(array $projects, DateTime $today): array
{
$models = [];
$monthly = [];
$allTime = [];
foreach ($projects as $project) {
$models[$project->getId()] = new ProjectBudgetStatisticModel($project);
if ($project->isMonthlyBudget()) {
$monthly[] = $project;
} else {
$allTime[] = $project;
}
}
$statisticsTotal = $this->getBudgetStatistic($projects);
foreach ($statisticsTotal as $id => $statistic) {
$models[$id]->setStatisticTotal($statistic);
}
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = null;
$end = $today;
if (\count($monthly) > 0) {
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
$statistics = $this->getBudgetStatistic($monthly, $begin, $end);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
if (\count($allTime) > 0) {
// display the budget at the end of the selected period and not the total sum of all times (do not include times in the future)
$statistics = $this->getBudgetStatistic($allTime, null, $today);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
$event = new ProjectBudgetStatisticEvent($models, $begin, $end);
$this->dispatcher->dispatch($event);
return $models;
}
/**
* @param Project[] $projects
* @param DateTime $begin
* @param DateTime $end
* @param DateTime|null $totalsEnd
* @return ProjectBudgetStatisticModel[]
*/
public function getBudgetStatisticModelForProjectsByDateRange(array $projects, DateTime $begin, DateTime $end, ?DateTime $totalsEnd = null): array
{
$models = [];
foreach ($projects as $project) {
$models[$project->getId()] = new ProjectBudgetStatisticModel($project);
}
$statisticsTotal = $this->getBudgetStatistic($projects, null, $totalsEnd);
foreach ($statisticsTotal as $projectId => $statistic) {
$models[$projectId]->setStatisticTotal($statistic);
}
$statistics = $this->getBudgetStatistic($projects, $begin, $end);
foreach ($statistics as $projectId => $statistic) {
$models[$projectId]->setStatistic($statistic);
}
$event = new ProjectBudgetStatisticEvent($models, $begin, $end);
$this->dispatcher->dispatch($event);
return $models;
}
/**
* @param Project[] $projects
* @param DateTime|null $begin
* @param DateTime|null $end
* @return array<int, ProjectStatistic>
*/
public function getBudgetStatistic(array $projects, ?DateTime $begin = null, ?DateTime $end = null): array
{
$statistics = [];
foreach ($projects as $project) {
$statistics[$project->getId()] = new ProjectStatistic();
}
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('COUNT(t.id) as counter')
->addSelect('t.billable as billable')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere($qb->expr()->isNotNull('t.end'))
->groupBy('id')
->addGroupBy('billable')
->setParameter('project', array_keys($statistics))
;
if ($begin !== null) {
$qb
->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
;
}
if ($end !== null) {
$qb
->andWhere($qb->expr()->lte('t.begin', ':end'))
->setParameter('end', $end, Types::DATETIME_MUTABLE)
;
}
$result = $qb->getQuery()->getResult();
if (null !== $result) {
foreach ($result as $resultRow) {
$statistic = $statistics[$resultRow['id']];
$statistic->setDuration($statistic->getDuration() + $resultRow['duration']);
$statistic->setRate($statistic->getRate() + $resultRow['rate']);
$statistic->setInternalRate($statistic->getInternalRate() + $resultRow['internalRate']);
$statistic->setCounter($statistic->getCounter() + $resultRow['counter']);
if ($resultRow['billable']) {
$statistic->setDurationBillable($resultRow['duration']);
$statistic->setRateBillable($resultRow['rate']);
$statistic->setInternalRateBillable($resultRow['internalRate']);
$statistic->setCounterBillable($resultRow['counter']);
}
}
}
return $statistics;
}
/**
@@ -106,7 +365,9 @@ class ProjectStatisticService
*/
public function getProjectsDetails(ProjectDetailsQuery $query): ProjectDetailsModel
{
$model = new ProjectDetailsModel($query->getProject());
$project = $query->getProject();
$model = new ProjectDetailsModel($project);
$model->setBudgetStatisticModel($this->getBudgetStatisticModel($project, $query->getToday()));
$years = [];
$qb = $this->timesheetRepository->createQueryBuilder('t');
@@ -132,7 +393,7 @@ class ProjectStatisticService
$activity->setRecordRate($tmp['rate']);
$activity->setRecordDuration($tmp['duration']);
$activity->setRecordInternalRate($tmp['internalRate']);
$activity->setRecordAmount($tmp['count']);
$activity->setCounter($tmp['count']);
$model->addActivity($activity);
}
// ---------------------------------------------------
@@ -140,8 +401,8 @@ class ProjectStatisticService
// fetch stats grouped by YEAR, MONTH and USER
$qb1 = clone $qb;
$qb1
->addSelect('YEAR(t.begin) as year')
->addSelect('MONTH(t.begin) as month')
->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.date) as month')
->addSelect('IDENTITY(t.user) as user')
->addGroupBy('year')
->addGroupBy('month')
@@ -151,33 +412,35 @@ class ProjectStatisticService
$userMonths = $qb1->getQuery()->getResult();
$userIds = array_unique(array_column($userMonths, 'user'));
$qb2 = $this->userRepository->createQueryBuilder('u');
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
$users = [];
foreach ($qb2->getQuery()->getResult() as $user) {
$users[$user->getId()] = new UserStatistic($user);
}
foreach ($userMonths as $tmp) {
$user = $users[$tmp['user']]->getUser();
$year = $model->getUserYear($tmp['year'], $user);
if ($year === null) {
$year = new Year($tmp['year']);
$model->setUserYear($year, $user);
if (!empty($userIds)) {
$qb2 = $this->userRepository->createQueryBuilder('u');
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
$users = [];
foreach ($qb2->getQuery()->getResult() as $user) {
$users[$user->getId()] = new UserStatistic($user);
}
foreach ($userMonths as $tmp) {
$user = $users[$tmp['user']]->getUser();
$year = $model->getUserYear($tmp['year'], $user);
if ($year === null) {
$year = new Year($tmp['year']);
$model->setUserYear($year, $user);
}
$month = new Month($tmp['month']);
$month->setTotalRate($tmp['rate']);
$month->setTotalDuration($tmp['duration']);
$month->setTotalInternalRate($tmp['internalRate']);
$year->setMonth($month);
$users[$tmp['user']]->addValuesFromMonth($month);
}
$month = new Month($tmp['month']);
$month->setTotalRate($tmp['rate']);
$month->setTotalDuration($tmp['duration']);
$month->setTotalInternalRate($tmp['internalRate']);
$year->setMonth($month);
$users[$tmp['user']]->addValuesFromMonth($month);
}
// ---------------------------------------------------
// fetch stats grouped by YEARS
$qb1 = clone $qb;
$qb1
->addSelect('YEAR(t.begin) as year')
->addSelect('YEAR(t.date) as year')
->addGroupBy('year')
;
foreach ($qb1->getQuery()->getResult() as $year) {
@@ -192,8 +455,8 @@ class ProjectStatisticService
$qb2
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
->addSelect('a as activity')
->addSelect('YEAR(t.begin) as year')
->andWhere('YEAR(t.begin) = :year')
->addSelect('YEAR(t.date) as year')
->andWhere('YEAR(t.date) = :year')
->setParameter('year', $year['year'])
->addGroupBy('year')
->addGroupBy('a')
@@ -204,7 +467,7 @@ class ProjectStatisticService
$activity->setRecordRate($tmp['rate']);
$activity->setRecordDuration($tmp['duration']);
$activity->setRecordInternalRate($tmp['internalRate']);
$activity->setRecordAmount($tmp['count']);
$activity->setCounter($tmp['count']);
$model->addYearActivity($tmp['year'], $activity);
}
}
@@ -214,8 +477,8 @@ class ProjectStatisticService
// fetch stats grouped by MONTH and YEAR
$qb1 = clone $qb;
$qb1
->addSelect('YEAR(t.begin) as year')
->addSelect('MONTH(t.begin) as month')
->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.date) as month')
->addGroupBy('year')
->addGroupBy('month')
;
@@ -279,44 +542,58 @@ class ProjectStatisticService
$this->repository->addPermissionCriteria($qb, $user);
return $qb->getQuery()->getResult();
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager());
$loader->loadResults($projects);
return $projects;
}
/**
* @param User $user
* @param Project[] $projects
* @param DateTime|null $today
* @param DateTime $today
* @return ProjectViewModel[]
*/
public function getProjectView(User $user, array $projects, ?DateTime $today = null): array
public function getProjectView(User $user, array $projects, DateTime $today): array
{
$factory = DateTimeFactory::createByUser($user);
if (null === $today) {
$today = $factory->createDateTime();
}
$today = clone $today;
$begin = $factory->getStartOfWeek($today);
$end = $factory->getEndOfWeek($today);
$startMonth = (clone $begin)->modify('first day of this month');
$endMonth = (clone $begin)->modify('last day of this month');
$startOfWeek = $factory->getStartOfWeek($today);
$endOfWeek = $factory->getEndOfWeek($today);
$startMonth = (clone $startOfWeek)->modify('first day of this month');
$endMonth = (clone $startOfWeek)->modify('last day of this month');
$projectViews = [];
foreach ($projects as $project) {
$projectViews[$project->getId()] = new ProjectViewModel($project);
}
$budgetStats = $this->getBudgetStatisticModelForProjects($projects, $today);
foreach ($budgetStats as $model) {
$projectViews[$model->getProject()->getId()]->setBudgetStatisticModel($model);
}
$projectIds = array_keys($projectViews);
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, COUNT(t.id) as amount, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate, MAX(t.begin) as lastRecord')
->andWhere($qb->expr()->in('t.project', ':project'))
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
$tplQb
->select('IDENTITY(t.project) AS id')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($tplQb->expr()->in('t.project', ':project'))
->groupBy('t.project')
->setParameter('project', array_values($projectIds))
;
$qb = clone $tplQb;
$qb->addSelect('MAX(t.date) as lastRecord');
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$projectViews[$row['id']]->setDurationTotal($row['duration']);
@@ -329,14 +606,10 @@ class ProjectStatisticService
}
// values for today
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb = clone $tplQb;
$qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) = :starting_date')
->groupBy('t.project')
->setParameter('starting_date', $today->format('Y-m-d'))
->setParameter('project', array_values($projectIds))
->andWhere('DATE(t.date) = :start_date')
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -345,15 +618,11 @@ class ProjectStatisticService
}
// values for the current week
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb = clone $tplQb;
$qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) BETWEEN :start_date AND :end_date')
->groupBy('t.project')
->setParameter('start_date', $begin->format('Y-m-d'))
->setParameter('end_date', $end->format('Y-m-d'))
->setParameter('project', array_values($projectIds))
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -362,15 +631,11 @@ class ProjectStatisticService
}
// values for the current month
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb = clone $tplQb;
$qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) BETWEEN :start_month AND :end_month')
->groupBy('t.project')
->setParameter('start_month', $startMonth)
->setParameter('end_month', $endMonth)
->setParameter('project', array_values($projectIds))
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -379,14 +644,10 @@ class ProjectStatisticService
}
// values for all time (not exported)
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb = clone $tplQb;
$qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('t.exported = :exported')
->groupBy('t.project')
->setParameter('exported', false, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
;
$result = $qb->getQuery()->getScalarResult();
@@ -396,16 +657,12 @@ class ProjectStatisticService
}
// values for all time (not exported and billable)
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb = clone $tplQb;
$qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('t.exported = :exported')
->andWhere('t.billable = :billable')
->groupBy('t.project')
->setParameter('exported', false, Types::BOOLEAN)
->setParameter('billable', true, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
;
$result = $qb->getQuery()->getScalarResult();
@@ -415,14 +672,11 @@ class ProjectStatisticService
}
// values for all time (none billable)
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb = clone $tplQb;
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('t.billable = :billable')
->groupBy('t.project')
->setParameter('billable', true, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
;
$result = $qb->getQuery()->getScalarResult();

View File

@@ -12,7 +12,6 @@ namespace App\Reporting;
use App\Form\Type\MonthPickerType;
use App\Form\Type\UserType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -37,7 +36,6 @@ class MonthByUserForm extends AbstractType
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'format' => $options['format'],
]);
if ($options['include_user']) {
@@ -54,7 +52,6 @@ class MonthByUserForm extends AbstractType
'data_class' => MonthByUser::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'format' => DateType::HTML5_FORMAT,
'include_user' => false,
'csrf_protection' => false,
'method' => 'GET',

View File

@@ -11,7 +11,6 @@ namespace App\Reporting;
use App\Form\Type\MonthPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -36,7 +35,6 @@ class MonthlyUserListForm extends AbstractType
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'format' => $options['format'],
]);
}
@@ -49,7 +47,6 @@ class MonthlyUserListForm extends AbstractType
'data_class' => MonthlyUserList::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'format' => DateType::HTML5_FORMAT,
'csrf_protection' => false,
'method' => 'GET',
]);

View File

@@ -0,0 +1,66 @@
<?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\Reporting\ProjectDateRange;
use App\Form\Type\CustomerType;
use App\Form\Type\MonthPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectDateRangeForm extends AbstractType
{
/**
* Simplify cross linking between pages by removing the block prefix.
*
* @return null|string
*/
public function getBlockPrefix()
{
return null;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('customer', CustomerType::class, [
'required' => false,
'label' => false,
'width' => false,
]);
$builder->add('month', MonthPickerType::class, [
'label' => false,
'view_timezone' => $options['timezone'],
'model_timezone' => $options['timezone'],
]);
$builder->add('includeNoBudget', CheckboxType::class, [
'required' => false,
'label' => 'label.includeNoBudget',
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProjectDateRangeQuery::class,
'timezone' => date_default_timezone_get(),
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,83 @@
<?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\Reporting\ProjectDateRange;
use App\Entity\Customer;
use App\Entity\User;
final class ProjectDateRangeQuery
{
/**
* @var \DateTime
*/
private $month;
/**
* @var User|null
*/
private $user;
/**
* @var Customer|null
*/
private $customer;
private $includeNoBudget = false;
private $onlyWithRecords = false;
public function __construct(\DateTime $month, User $user)
{
$this->month = clone $month;
$this->user = $user;
}
public function isIncludeNoBudget(): bool
{
return $this->includeNoBudget;
}
public function setIncludeNoBudget(bool $includeNoBudget): void
{
$this->includeNoBudget = $includeNoBudget;
}
public function isOnlyWithRecords(): bool
{
return $this->onlyWithRecords;
}
public function setOnlyWithRecords(bool $onlyWithRecords): void
{
$this->onlyWithRecords = $onlyWithRecords;
}
public function getUser(): ?User
{
return $this->user;
}
public function getMonth(): \DateTime
{
return $this->month;
}
public function setMonth(\DateTime $month): void
{
$this->month = $month;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Reporting\ProjectDetails;
use App\Entity\Project;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Model\BudgetStatisticModel;
use App\Model\Statistic\UserYear;
use App\Model\Statistic\Year;
use App\Model\UserStatistic;
@@ -38,6 +39,10 @@ final class ProjectDetailsModel
* @var ActivityStatistic[]
*/
private $activities = [];
/**
* @var BudgetStatisticModel
*/
private $budgetStatisticModel;
public function __construct(Project $project)
{
@@ -155,4 +160,14 @@ final class ProjectDetailsModel
{
$this->years = $years;
}
public function getBudgetStatisticModel(): ?BudgetStatisticModel
{
return $this->budgetStatisticModel;
}
public function setBudgetStatisticModel(BudgetStatisticModel $budgetStatisticModel): void
{
$this->budgetStatisticModel = $budgetStatisticModel;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Reporting\ProjectView;
use App\Entity\Project;
use App\Model\BudgetStatisticModelInterface;
use DateTime;
final class ProjectViewModel
@@ -27,7 +28,14 @@ final class ProjectViewModel
private $notBilledRate = 0.00;
private $billableDuration = 0;
private $billableRate = 0.00;
/**
* @var \DateTime|null
*/
private $lastRecord;
/**
* @var BudgetStatisticModelInterface
*/
private $budgetStatisticModel;
public function __construct(Project $project)
{
@@ -168,4 +176,14 @@ final class ProjectViewModel
{
$this->lastRecord = $lastRecord;
}
public function getBudgetStatisticModel(): BudgetStatisticModelInterface
{
return $this->budgetStatisticModel;
}
public function setBudgetStatisticModel(BudgetStatisticModelInterface $budgetStatisticModel): void
{
$this->budgetStatisticModel = $budgetStatisticModel;
}
}

View File

@@ -56,6 +56,7 @@ final class ReportingService
$event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project'));
}
if ($this->security->isGranted('budget_project')) {
$event->addReport(new Report('daterange_projects', 'report_project_daterange', 'report_project_daterange', 'project'));
$event->addReport(new Report('inactive_projects', 'report_project_inactive', 'report_inactive_project', 'project'));
}

View File

@@ -12,7 +12,6 @@ namespace App\Reporting;
use App\Form\Type\UserType;
use App\Form\Type\WeekPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -37,7 +36,6 @@ class WeekByUserForm extends AbstractType
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'format' => $options['format'],
]);
if ($options['include_user']) {
@@ -54,7 +52,6 @@ class WeekByUserForm extends AbstractType
'data_class' => WeekByUser::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'format' => DateType::HTML5_FORMAT,
'include_user' => false,
'csrf_protection' => false,
'method' => 'GET',

View File

@@ -11,7 +11,6 @@ namespace App\Reporting;
use App\Form\Type\WeekPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -36,7 +35,6 @@ class WeeklyUserListForm extends AbstractType
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'format' => $options['format'],
]);
}
@@ -49,7 +47,6 @@ class WeeklyUserListForm extends AbstractType
'data_class' => WeeklyUserList::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'format' => DateType::HTML5_FORMAT,
'csrf_protection' => false,
'method' => 'GET',
]);

View File

@@ -11,7 +11,6 @@ namespace App\Reporting;
use App\Form\Type\YearPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -36,7 +35,6 @@ class YearlyUserListForm extends AbstractType
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'format' => $options['format'],
'show_range' => true,
]);
}
@@ -50,7 +48,6 @@ class YearlyUserListForm extends AbstractType
'data_class' => YearlyUserList::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'format' => DateType::HTML5_FORMAT,
'csrf_protection' => false,
'method' => 'GET',
]);

View File

@@ -61,6 +61,26 @@ class ActivityRepository extends EntityRepository
return $this->findBy(['project' => $project]);
}
/**
* @param int[] $activityIds
* @return Activity[]
*/
public function findByIds(array $activityIds)
{
$qb = $this->createQueryBuilder('a');
$qb
->where($qb->expr()->in('a.id', ':id'))
->setParameter('id', $activityIds)
;
$activities = $qb->getQuery()->getResult();
$loader = new ActivityLoader($qb->getEntityManager());
$loader->loadResults($activities);
return $activities;
}
/**
* @param Activity $activity
* @throws ORMException
@@ -87,7 +107,8 @@ class ActivityRepository extends EntityRepository
}
/**
* Retrieves statistics for one activity.
* @deprecated since 1.15 use ActivityStatisticService::getActivityStatistics() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param Activity $activity
* @return ActivityStatistic
@@ -110,7 +131,7 @@ class ActivityRepository extends EntityRepository
$stats = new ActivityStatistic();
if (null !== $timesheetResult) {
$stats->setRecordAmount($timesheetResult['amount']);
$stats->setCounter($timesheetResult['amount']);
$stats->setRecordDuration($timesheetResult['duration']);
$stats->setRecordRate($timesheetResult['rate']);
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
@@ -194,6 +215,7 @@ class ActivityRepository extends EntityRepository
/**
* @deprecated since 1.1 - use getQueryBuilderForFormType() instead - will be removed with 2.0
* @codeCoverageIgnore
*/
public function builderForEntityType($activity, $project)
{

View File

@@ -39,7 +39,7 @@ class CustomerRepository extends EntityRepository
* @param null $lockVersion
* @return Customer|null
*/
public function find($id, $lockMode = null, $lockVersion = null)
public function find($id, $lockMode = null, $lockVersion = null): ?Customer
{
/** @var Customer|null $customer */
$customer = parent::find($id, $lockMode, $lockVersion);
@@ -56,28 +56,31 @@ class CustomerRepository extends EntityRepository
/**
* @param Customer $customer
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveCustomer(Customer $customer)
public function saveCustomer(Customer $customer): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($customer);
$entityManager->flush();
}
/**
* @param null|bool $visible
* @return int
*/
public function countCustomer($visible = null)
public function countCustomer(bool $visible = false): int
{
if (null !== $visible) {
if ($visible) {
return $this->count(['visible' => (bool) $visible]);
}
return $this->count([]);
}
/**
* @deprecated since 1.15 use CustomerStatisticService::getCustomerStatistics() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param Customer $customer
* @return CustomerStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getCustomerStatistics(Customer $customer): CustomerStatistic
{
$stats = new CustomerStatistic();
@@ -114,7 +117,7 @@ class CustomerRepository extends EntityRepository
$stats->setRecordAmountBillable($resultRow['amount']);
}
}
$stats->setRecordAmount($amount);
$stats->setCounter($amount);
$stats->setRecordDuration($duration);
$stats->setRecordRate($rate);
$stats->setRecordInternalRate($rateInternal);
@@ -189,6 +192,7 @@ class CustomerRepository extends EntityRepository
/**
* @deprecated since 1.1 - use getQueryBuilderForFormType() instead - will be removed with 2.0
* @codeCoverageIgnore
*/
public function builderForEntityType($customer)
{

View File

@@ -14,14 +14,11 @@ use Doctrine\ORM\EntityManagerInterface;
final class ActivityLoader implements LoaderInterface
{
/**
* @var ActivityIdLoader
*/
private $loader;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new ActivityIdLoader($entityManager);
$this->entityManager = $entityManager;
}
/**
@@ -33,6 +30,7 @@ final class ActivityLoader implements LoaderInterface
return $activity->getId();
}, $activities);
$this->loader->loadResults($ids);
$loader = new ActivityIdLoader($this->entityManager);
$loader->loadResults($ids);
}
}

View File

@@ -14,14 +14,11 @@ use Doctrine\ORM\EntityManagerInterface;
final class CustomerLoader implements LoaderInterface
{
/**
* @var CustomerIdLoader
*/
private $loader;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new CustomerIdLoader($entityManager);
$this->entityManager = $entityManager;
}
/**
@@ -33,6 +30,7 @@ final class CustomerLoader implements LoaderInterface
return $customer->getId();
}, $customers);
$this->loader->loadResults($ids);
$loader = new CustomerIdLoader($this->entityManager);
$loader->loadResults($ids);
}
}

View File

@@ -14,14 +14,11 @@ use Doctrine\ORM\EntityManagerInterface;
final class InvoiceLoader implements LoaderInterface
{
/**
* @var InvoiceIdLoader
*/
private $loader;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new InvoiceIdLoader($entityManager);
$this->entityManager = $entityManager;
}
/**
@@ -33,6 +30,7 @@ final class InvoiceLoader implements LoaderInterface
return $invoice->getId();
}, $invoices);
$this->loader->loadResults($ids);
$loader = new InvoiceIdLoader($this->entityManager);
$loader->loadResults($ids);
}
}

View File

@@ -14,14 +14,11 @@ use Doctrine\ORM\EntityManagerInterface;
final class ProjectLoader implements LoaderInterface
{
/**
* @var ProjectIdLoader
*/
private $loader;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new ProjectIdLoader($entityManager);
$this->entityManager = $entityManager;
}
/**
@@ -33,6 +30,7 @@ final class ProjectLoader implements LoaderInterface
return $project->getId();
}, $projects);
$this->loader->loadResults($ids);
$loader = new ProjectIdLoader($this->entityManager);
$loader->loadResults($ids);
}
}

View File

@@ -45,5 +45,13 @@ final class TeamIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'projects')
->from(Team::class, 't')
->leftJoin('t.projects', 'projects')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -14,14 +14,11 @@ use Doctrine\ORM\EntityManagerInterface;
final class TeamLoader implements LoaderInterface
{
/**
* @var TeamIdLoader
*/
private $loader;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new TeamIdLoader($entityManager);
$this->entityManager = $entityManager;
}
/**
@@ -33,6 +30,7 @@ final class TeamLoader implements LoaderInterface
return $team->getId();
}, $teams);
$this->loader->loadResults($ids);
$loader = new TeamIdLoader($this->entityManager);
$loader->loadResults($ids);
}
}

View File

@@ -14,14 +14,13 @@ use Doctrine\ORM\EntityManagerInterface;
final class TimesheetLoader implements LoaderInterface
{
/**
* @var TimesheetIdLoader
*/
private $loader;
private $entityManager;
private $hydrateFullTree;
public function __construct(EntityManagerInterface $entityManager, bool $hydrateFullTree = false)
{
$this->loader = new TimesheetIdLoader($entityManager, $hydrateFullTree);
$this->entityManager = $entityManager;
$this->hydrateFullTree = $hydrateFullTree;
}
/**
@@ -33,6 +32,7 @@ final class TimesheetLoader implements LoaderInterface
return $timesheet->getId();
}, $timesheets);
$this->loader->loadResults($ids);
$loader = new TimesheetIdLoader($this->entityManager, $this->hydrateFullTree);
$loader->loadResults($ids);
}
}

View File

@@ -14,14 +14,11 @@ use Doctrine\ORM\EntityManagerInterface;
final class UserLoader implements LoaderInterface
{
/**
* @var UserIdLoader
*/
private $loader;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new UserIdLoader($entityManager);
$this->entityManager = $entityManager;
}
/**
@@ -33,6 +30,7 @@ final class UserLoader implements LoaderInterface
return $user->getId();
}, $users);
$this->loader->loadResults($ids);
$loader = new UserIdLoader($this->entityManager);
$loader->loadResults($ids);
}
}

View File

@@ -54,6 +54,26 @@ class ProjectRepository extends EntityRepository
return $project;
}
/**
* @param int[] $projectIds
* @return Project[]
*/
public function findByIds(array $projectIds)
{
$qb = $this->createQueryBuilder('p');
$qb
->where($qb->expr()->in('p.id', ':id'))
->setParameter('id', $projectIds)
;
$projects = $qb->getQuery()->getResult();
$loader = new ProjectLoader($qb->getEntityManager());
$loader->loadResults($projects);
return $projects;
}
/**
* @param Project $project
* @throws ORMException
@@ -79,6 +99,16 @@ class ProjectRepository extends EntityRepository
return $this->count([]);
}
/**
* @deprecated since 1.15 use ProjectStatisticService::getProjectStatistics() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param Project $project
* @param DateTime|null $begin
* @param DateTime|null $end
* @return ProjectStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getProjectStatistics(Project $project, ?DateTime $begin = null, ?DateTime $end = null): ProjectStatistic
{
$qb = $this->getEntityManager()->createQueryBuilder();
@@ -103,7 +133,7 @@ class ProjectRepository extends EntityRepository
$stats = new ProjectStatistic();
if (null !== $timesheetResult) {
$stats->setRecordAmount($timesheetResult['amount']);
$stats->setCounter($timesheetResult['amount']);
$stats->setRecordDuration($timesheetResult['duration']);
$stats->setRecordRate($timesheetResult['rate']);
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
@@ -196,6 +226,7 @@ class ProjectRepository extends EntityRepository
/**
* @deprecated since 1.1 - use getQueryBuilderForFormType() istead - will be removed with 2.0
* @codeCoverageIgnore
*/
public function builderForEntityType($project, $customer)
{

View File

@@ -28,7 +28,6 @@ use App\Repository\Query\TimesheetQuery;
use DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -46,8 +45,17 @@ class TimesheetRepository extends EntityRepository
public const STATS_QUERY_USER = 'users';
public const STATS_QUERY_AMOUNT = 'amount';
public const STATS_QUERY_ACTIVE = 'active';
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getMonthlyStats() instead - will be removed with 2.0
*/
public const STATS_QUERY_MONTHLY = 'monthly';
/**
* Fetches the raw data of an timesheet, to allow comparison eg. of submitted and previously stored data.
*
* @param Timesheet $id
* @return array
*/
public function getRawData(Timesheet $id): array
{
$qb = $this->createQueryBuilder('t');
@@ -56,17 +64,18 @@ class TimesheetRepository extends EntityRepository
't.rate',
't.duration',
't.hourlyRate',
't.billable',
'IDENTITY(p.customer) as customer',
'IDENTITY(t.project) as project',
'IDENTITY(t.activity) as activity',
'IDENTITY(t.user) as user'
])
->leftJoin(Project::class, 'p', Join::WITH, 'p.id = t.project')
->andWhere('t.id = :id')
->andWhere($qb->expr()->eq('t.id', ':id'))
->setParameter('id', $id)
;
return $qb->getQuery()->getSingleResult(AbstractQuery::HYDRATE_ARRAY);
return $qb->getQuery()->getOneOrNullResult();
}
/**
@@ -124,6 +133,7 @@ class TimesheetRepository extends EntityRepository
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*/
public function add(Timesheet $timesheet, int $maxRunningEntries)
{
@@ -194,13 +204,15 @@ class TimesheetRepository extends EntityRepository
}
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*
* @param Timesheet $entry
* @param bool $flush
* @return bool
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/
public function stopRecording(Timesheet $entry, bool $flush = true)
{
@@ -264,39 +276,26 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param string $select
* @param User $user
* @return int
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryThisMonth($select, User $user)
{
try {
$timezone = new \DateTimeZone($user->getTimezone());
$begin = new DateTime('first day of this month 00:00:00', $timezone);
$end = new DateTime('last day of this month 23:59:59', $timezone);
return $this->queryTimeRange($select, $begin, $end, $user);
} catch (\Exception $ex) {
}
return 0;
}
/**
* @param string $select
* @param string|string[] $select
* @param DateTime|null $begin
* @param DateTime|null $end
* @param User|null $user
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryTimeRange(string $select, ?DateTime $begin, ?DateTime $end, ?User $user)
protected function queryTimeRange($select, ?DateTime $begin, ?DateTime $end, ?User $user)
{
$selects = $select;
if (!\is_array($select)) {
$selects = [$select];
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select($select)
->from(Timesheet::class, 't');
$qb->from(Timesheet::class, 't');
foreach ($selects as $s) {
$qb->addSelect($s);
}
if (!empty($begin)) {
$qb
@@ -315,36 +314,75 @@ class TimesheetRepository extends EntityRepository
->setParameter('user', $user);
}
if (\is_array($select)) {
return $qb->getQuery()->getOneOrNullResult();
}
$result = $qb->getQuery()->getSingleScalarResult();
return empty($result) ? 0 : $result;
}
public function getUserStatistics(User $user): TimesheetStatistic
/**
* @param User $user
* @param bool $bcSafe will be removed with 2.0
* @return TimesheetStatistic
*/
public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic
{
$durationTotal = $this->getStatistic(self::STATS_QUERY_DURATION, null, null, $user);
$recordsTotal = $this->getStatistic(self::STATS_QUERY_AMOUNT, null, null, $user);
$rateTotal = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user);
$amountMonth = $this->queryThisMonth('COALESCE(SUM(t.rate), 0)', $user);
$durationMonth = $this->queryThisMonth('COALESCE(SUM(t.duration), 0)', $user);
$firstEntry = $this->getEntityManager()
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
->getSingleScalarResult();
$allTimeData = $this->queryTimeRange([
'COALESCE(SUM(t.duration), 0) as duration',
'COALESCE(SUM(t.rate), 0) as rate',
'COUNT(t.id) as amount'
], null, null, $user);
$timezone = new \DateTimeZone($user->getTimezone());
$begin = new DateTime('first day of this month 00:00:00', $timezone);
$end = new DateTime('last day of this month 23:59:59', $timezone);
$monthData = $this->queryTimeRange(
[
'COALESCE(SUM(t.rate), 0) as rate',
'COALESCE(SUM(t.duration), 0) as duration'
],
$begin,
$end,
$user
);
$stats = new TimesheetStatistic();
$stats->setAmountTotal($rateTotal);
$stats->setDurationTotal($durationTotal);
$stats->setAmountThisMonth($amountMonth);
$stats->setDurationThisMonth($durationMonth);
$stats->setFirstEntry(new DateTime($firstEntry, new \DateTimeZone($user->getTimezone())));
$stats->setRecordsTotal($recordsTotal);
if ($bcSafe) {
$firstEntry = $this->getEntityManager()
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user AND 1=12')
->setParameter('user', $user)
->getSingleScalarResult();
$timezone = new \DateTimeZone($user->getTimezone());
if ($firstEntry !== null) {
$stats->setFirstEntry(new DateTime($firstEntry, $timezone));
} else {
@trigger_error(
'TimesheetStatistic::getFirstEntry() returns a wrong result for users without record and will be removed with 2.0',
E_USER_DEPRECATED
);
$stats->setFirstEntry(new DateTime('now', $timezone));
}
}
$stats->setAmountTotal($allTimeData['rate']);
$stats->setDurationTotal($allTimeData['duration']);
$stats->setAmountThisMonth($monthData['rate']);
$stats->setDurationThisMonth($monthData['duration']);
$stats->setRecordsTotal($allTimeData['amount']);
return $stats;
}
/**
* Returns an array of Year statistics.
* @deprecated since 1.15 - use TimesheetStatisticService::getMonthlyStats() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param DateTime $begin
* @param DateTime $end
@@ -353,6 +391,8 @@ class TimesheetRepository extends EntityRepository
*/
public function getMonthlyStats(DateTime $begin, DateTime $end, ?User $user = null): array
{
@trigger_error('TimesheetRepository::getMonthlyStats() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
/** @var Year[] $years */
$years = [];
@@ -404,12 +444,25 @@ class TimesheetRepository extends EntityRepository
return $years;
}
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getMonthlyStats() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param User|null $user
* @param DateTime|null $begin
* @param DateTime|null $end
* @param bool|null $billable
* @return QueryBuilder
*/
private function getMonthlyStatsQuery(User $user = null, ?DateTime $begin = null, ?DateTime $end = null, ?bool $billable = null): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Timesheet::class, 't');
$qb->select('COALESCE(SUM(t.rate), 0) as rate, COALESCE(SUM(t.duration), 0) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year');
$qb->select('COALESCE(SUM(t.rate), 0) as rate');
$qb->addSelect('COALESCE(SUM(t.duration), 0) as duration');
$qb->addSelect('MONTH(t.date) as month');
$qb->addSelect('YEAR(t.date) as year');
if (!empty($begin)) {
$qb->andWhere($qb->expr()->gte('t.begin', ':from'));
@@ -465,8 +518,8 @@ class TimesheetRepository extends EntityRepository
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->select('t, p, a, c')
->from(Timesheet::class, 't')
->andWhere($qb->expr()->isNotNull('t.end'))
->from(Timesheet::class, 't')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or)
->orderBy('t.begin', 'DESC')
->setParameter('begin', $begin)
@@ -581,6 +634,9 @@ class TimesheetRepository extends EntityRepository
}
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getDailyStatistics() instead
* @codeCoverageIgnore
*
* @param User|null $user
* @param DateTime $begin
* @param DateTime $end
@@ -646,6 +702,9 @@ class TimesheetRepository extends EntityRepository
}
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*
* @param User $user
* @param int $hardLimit
* @param bool $flush
@@ -653,7 +712,6 @@ class TimesheetRepository extends EntityRepository
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/
public function stopActiveEntries(User $user, int $hardLimit, bool $flush = true)
{
@@ -753,8 +811,7 @@ class TimesheetRepository extends EntityRepository
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
// faster then using "distinct id", as the user field is a separate (and smaller) index
->select($qb->expr()->count('t.user'))
->select($qb->expr()->count('t.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();

View File

@@ -11,7 +11,6 @@ namespace App\Repository;
use App\Widget\Type\Counter;
use App\Widget\Type\SimpleStatisticChart;
use App\Widget\Type\YearChart;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
@@ -342,46 +341,6 @@ class WidgetRepository
'user' => false,
'type' => Counter::class,
],
'userRecapThisYear' => [
'title' => 'stats.yourWorkingHours',
'query' => TimesheetRepository::STATS_QUERY_MONTHLY,
'user' => true,
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'color' => '',
'icon' => '',
'type' => YearChart::class,
],
'userRecapLastYear' => [
'title' => 'stats.yourWorkingHours',
'query' => TimesheetRepository::STATS_QUERY_MONTHLY,
'user' => true,
'begin' => '01 january last year 00:00:00',
'end' => '31 december last year 23:59:59',
'color' => 'rgba(0,115,183,0.7)|#3b8bba',
'icon' => '',
'type' => YearChart::class,
],
'userRecapTwoYears' => [
'title' => 'stats.yourWorkingHours',
'query' => TimesheetRepository::STATS_QUERY_MONTHLY,
'user' => true,
'begin' => '01 january last year 00:00:00',
'end' => '31 december this year 23:59:59',
'color' => 'rgba(0,115,183,0.6)|#3b8bba;rgba(233,233,233,0.8)|#ccc',
'icon' => '',
'type' => YearChart::class,
],
'userRecapThreeYears' => [
'title' => 'stats.yourWorkingHours',
'query' => TimesheetRepository::STATS_QUERY_MONTHLY,
'user' => true,
'begin' => '2 years ago first day of january 00:00:00',
'end' => 'this year last day of december 23:59:59',
'color' => 'rgba(0,115,183,0.4)|#3b8bba;rgba(233,233,233,0.7)|#ccc;rgba(210,214,222,0.9)|#c1c7d1',
'icon' => '',
'type' => YearChart::class,
],
];
}
}

View File

@@ -48,6 +48,8 @@ final class CurrentUser
return null;
}
@trigger_error('CurrentUser is deprecated and will be removed with 2.0, use DI or at worst Symfony\Component\Security\Core\Security instead', E_USER_DEPRECATED);
$this->user = $user;
return $this->user;

View File

@@ -48,9 +48,14 @@ class DateTimeFactory
return $this->timezone;
}
public function getStartOfMonth(): DateTime
public function getStartOfMonth(?DateTime $date = null): DateTime
{
$date = $this->createDateTime('first day of this month');
if (null === $date) {
$date = $this->createDateTime();
}
$date = clone $date;
$date->modify('first day of this month');
$date->setTime(0, 0, 0);
return $date;
@@ -81,7 +86,7 @@ class DateTimeFactory
public function getEndOfWeek(?DateTime $date = null): DateTime
{
if (null === $date) {
$date = $this->createDateTime('now');
$date = $this->createDateTime();
}
$lastDay = $this->startOnSunday ? 6 : 7;
@@ -89,9 +94,14 @@ class DateTimeFactory
return $this->createWeekDateTime($date->format('o'), $date->format('W'), $lastDay, 23, 59, 59);
}
public function getEndOfMonth(): DateTime
public function getEndOfMonth(?DateTime $date = null): DateTime
{
$date = $this->createDateTime('last day of this month');
if (null === $date) {
$date = $this->createDateTime();
}
$date = clone $date;
$date = $date->modify('last day of this month');
$date->setTime(23, 59, 59);
return $date;

View File

@@ -0,0 +1,247 @@
<?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\Model\DailyStatistic;
use App\Model\MonthlyStatistic;
use App\Repository\TimesheetRepository;
use DateTime;
final class TimesheetStatisticService
{
/**
* @var TimesheetRepository
*/
private $repository;
public function __construct(TimesheetRepository $repository)
{
$this->repository = $repository;
}
/**
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return DailyStatistic[]
*/
public function getDailyStatistics(DateTime $begin, DateTime $end, array $users): array
{
/** @var DailyStatistic[] $stats */
$stats = [];
foreach ($users as $user) {
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = new DailyStatistic($begin, $end, $user);
}
}
$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('t.billable as billable')
->addSelect('IDENTITY(t.user) as user')
->addSelect('DAY(t.date) as day')
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->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('year')
->addGroupBy('month')
->addGroupBy('day')
->addGroupBy('user')
->addGroupBy('billable')
;
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$day = $stats[$row['user']]->getDay($row['year'], $row['month'], $row['day']);
if ($day === null) {
// timezone differences
continue;
}
$day->setTotalDuration($day->getTotalDuration() + (int) $row['duration']);
$day->setTotalRate($day->getTotalRate() + (float) $row['rate']);
$day->setTotalInternalRate($day->getTotalInternalRate() + (float) $row['internalRate']);
if ($row['billable']) {
$day->setBillableRate((float) $row['rate']);
$day->setBillableDuration((int) $row['duration']);
}
}
return array_values($stats);
}
/**
* @internal only for core development
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return array<int, DailyStatistic[]>
*/
public function getDailyStatisticsGrouped(DateTime $begin, DateTime $end, array $users): array
{
/** @var DailyStatistic[] $stats */
$stats = [];
$usersById = [];
foreach ($users as $user) {
$usersById[$user->getId()] = $user;
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = [];
}
}
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.rate), 0.0) as rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('t.billable as billable')
->addSelect('IDENTITY(t.user) as user')
->addSelect('IDENTITY(t.project) as project')
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('DATE(t.date) as date')
->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('date')
->addGroupBy('project')
->addGroupBy('activity')
->addGroupBy('user')
->addGroupBy('billable')
;
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$uid = $row['user'];
$pid = $row['project'];
$aid = $row['activity'];
if (!isset($stats[$uid][$pid])) {
$stats[$uid][$pid] = ['project' => $pid, 'activities' => []];
}
if (!isset($stats[$uid][$pid]['activities'][$aid])) {
$stats[$uid][$pid]['activities'][$aid] = ['activity' => $aid, 'days' => new DailyStatistic($begin, $end, $usersById[$uid])];
}
/** @var DailyStatistic $days */
$days = $stats[$uid][$pid]['activities'][$aid]['days'];
$day = $days->getDayByReportDate($row['date']);
if ($day === null) {
// timezone differences
continue;
}
$day->setTotalDuration($day->getTotalDuration() + (int) $row['duration']);
$day->setTotalRate($day->getTotalRate() + (float) $row['rate']);
$day->setTotalInternalRate($day->getTotalInternalRate() + (float) $row['internalRate']);
if ($row['billable']) {
$day->setBillableRate((float) $row['rate']);
$day->setBillableDuration((int) $row['duration']);
}
}
return $stats;
}
public function findFirstRecordDate(User $user): ?DateTime
{
$result = $this->repository->createQueryBuilder('t')
->select('MIN(t.begin)')
->where('t.user = :user')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
if ($result === null) {
return null;
}
return new DateTime($result, new \DateTimeZone($user->getTimezone()));
}
/**
* Returns an array of Year statistics.
*
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return MonthlyStatistic[]
*/
public function getMonthlyStats(DateTime $begin, DateTime $end, array $users): array
{
/** @var MonthlyStatistic[] $stats */
$stats = [];
foreach ($users as $user) {
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = new MonthlyStatistic($begin, $end, $user);
}
}
$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('t.billable as billable')
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->addSelect('IDENTITY(t.user) as user')
->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('year')
->addGroupBy('month')
->addGroupBy('user')
->addGroupBy('billable')
;
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$month = $stats[$row['user']]->getMonth($row['year'], $row['month']);
if ($month === null) {
// might happen for the last month, which is accidentally queried due to timezone differences
continue;
}
$month->setTotalDuration($month->getTotalDuration() + (int) $row['duration']);
$month->setTotalRate($month->getTotalRate() + (float) $row['rate']);
$month->setTotalInternalRate($month->getTotalInternalRate() + (float) $row['internalRate']);
if ($row['billable']) {
$month->setBillableRate((float) $row['rate']);
$month->setBillableDuration((int) $row['duration']);
}
}
return array_values($stats);
}
}

View File

@@ -37,6 +37,7 @@ class UserDateTimeFactory extends DateTimeFactory
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();

View File

@@ -27,6 +27,7 @@ class Extensions extends AbstractExtension
public function getFilters()
{
return [
new TwigFilter('report_date', [$this, 'formatReportDate']),
new TwigFilter('docu_link', [$this, 'documentationLink']),
new TwigFilter('multiline_indent', [$this, 'multilineIndent']),
new TwigFilter('color', [$this, 'color']),
@@ -48,6 +49,11 @@ class Extensions extends AbstractExtension
];
}
public function formatReportDate(\DateTime $dateTime): string
{
return $dateTime->format('Y-m-d');
}
public function getIsoDayByName(string $weekDay): int
{
$key = array_search(

View File

@@ -13,11 +13,13 @@ use App\Activity\ActivityStatisticService;
use App\Configuration\SystemConfiguration;
use App\Customer\CustomerStatisticService;
use App\Entity\Timesheet;
use App\Model\BudgetStatisticModel;
use App\Project\ProjectStatisticService;
use App\Repository\TimesheetRepository;
use App\Timesheet\RateServiceInterface;
use App\Utils\Duration;
use App\Utils\LocaleHelper;
use DateTime;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -59,12 +61,13 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
return;
}
if ($this->context->getViolations()->count() > 0) {
// we can only work with stopped entries
if (null === $timesheet->getEnd() || null === $timesheet->getUser()) {
return;
}
// we can only work with stopped entries
if (null === $timesheet->getEnd() || null === $timesheet->getUser() || null === $timesheet->getProject()) {
// budgets need only be calculated for billable records
if (!$timesheet->isBillable()) {
return;
}
@@ -86,9 +89,10 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
if ($timesheet->getId() !== null) {
$rawData = $this->timesheetRepository->getRawData($timesheet);
// if an existing entry was updated, but duration and rate were not changed: do not validate
// this could for example happen if overbooking config was recently activated
if ($duration === $rawData['duration'] && $rate === $rawData['rate']) {
// if an existing entry was updated, but "duration", "rate" and "billable" were not changed:
// do not validate! this could for example happen when export flag is changed OR if "prevent overbooking"
// config was recently activated and this is an old entry
if ($duration === $rawData['duration'] && $rate === $rawData['rate'] && $timesheet->isBillable() === $rawData['billable']) {
return;
}
@@ -97,115 +101,61 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
$projectId = (int) $rawData['project'];
$customerId = (int) $rawData['customer'];
if (null !== $timesheet->getActivity() && $activityId === $timesheet->getActivity()->getId()) {
$activityDuration -= $rawData['duration'];
$activityRate -= $rawData['rate'];
}
// only subtract the previously logged data in case the record was billable
// if it wasn't billable, then its values are not included in the statistic models used later on
if ($rawData['billable']) {
if (null !== $timesheet->getActivity() && $activityId === $timesheet->getActivity()->getId()) {
$activityDuration -= $rawData['duration'];
$activityRate -= $rawData['rate'];
}
if ($projectId === $timesheet->getProject()->getId()) {
$projectDuration -= $rawData['duration'];
$projectRate -= $rawData['rate'];
}
if (null !== $timesheet->getProject()) {
if ($projectId === $timesheet->getProject()->getId()) {
$projectDuration -= $rawData['duration'];
$projectRate -= $rawData['rate'];
}
if ($customerId === $timesheet->getProject()->getCustomer()->getId()) {
$customerDuration -= $rawData['duration'];
$customerRate -= $rawData['rate'];
if ($customerId === $timesheet->getProject()->getCustomer()->getId()) {
$customerDuration -= $rawData['duration'];
$customerRate -= $rawData['rate'];
}
}
}
}
if (null !== $timesheet->getActivity() && $this->checkActivity($constraint, $timesheet, $activityDuration, $activityRate)) {
return;
$now = new DateTime('now', $timesheet->getBegin()->getTimezone());
if (null !== ($activity = $timesheet->getActivity()) && $activity->hasBudgets()) {
$stat = $this->activityStatisticService->getBudgetStatisticModel($activity, $now);
$this->checkBudgets($constraint, $stat, $timesheet, $activityDuration, $activityRate, 'activity');
}
if ($this->checkProject($constraint, $timesheet, $projectDuration, $projectRate)) {
return;
}
if ($this->checkCustomer($constraint, $timesheet, $customerDuration, $customerRate)) {
return;
if (null !== ($project = $timesheet->getProject())) {
if ($project->hasBudgets()) {
$stat = $this->projectStatisticService->getBudgetStatisticModel($project, $now);
$this->checkBudgets($constraint, $stat, $timesheet, $projectDuration, $projectRate, 'project');
}
if (null !== ($customer = $project->getCustomer()) && $customer->hasBudgets()) {
$stat = $this->customerStatisticService->getBudgetStatisticModel($customer, $now);
$this->checkBudgets($constraint, $stat, $timesheet, $customerDuration, $customerRate, 'customer');
}
}
}
private function checkActivity(TimesheetBudgetUsed $constraint, Timesheet $timesheet, int $duration, float $rate): bool
private function checkBudgets(TimesheetBudgetUsed $constraint, BudgetStatisticModel $stat, Timesheet $timesheet, int $duration, float $rate, string $field): bool
{
$activity = $timesheet->getActivity();
$fullRate = ($stat->getBudgetSpent() + $rate);
if (!$activity->hasBudget() && !$activity->hasTimeBudget()) {
return false;
}
$stat = $this->activityStatisticService->getActivityStatistics($activity);
$fullRate = ($stat->getRecordRate() + $rate);
if ($activity->hasBudget() && $fullRate > $activity->getBudget()) {
$this->addBudgetViolation($constraint, $timesheet, 'activity', $activity->getBudget(), $stat->getRecordRate());
if ($stat->hasBudget() && $fullRate > $stat->getBudget()) {
$this->addBudgetViolation($constraint, $timesheet, $field, $stat->getBudget(), $stat->getBudgetSpent());
return true;
}
$fullDuration = ($stat->getRecordDuration() + $duration);
$fullDuration = ($stat->getTimeBudgetSpent() + $duration);
if ($activity->hasTimeBudget() && $fullDuration > $activity->getTimeBudget()) {
$this->addTimeBudgetViolation($constraint, 'activity', $activity->getTimeBudget(), $stat->getRecordDuration());
return true;
}
return false;
}
private function checkProject(TimesheetBudgetUsed $constraint, Timesheet $timesheet, int $duration, float $rate): bool
{
$project = $timesheet->getProject();
if (!$project->hasBudget() && !$project->hasTimeBudget()) {
return false;
}
$stat = $this->projectStatisticService->getProjectStatistics($project);
$fullRate = ($stat->getRecordRate() + $rate);
if ($project->hasBudget() && $fullRate > $project->getBudget()) {
$this->addBudgetViolation($constraint, $timesheet, 'project', $project->getBudget(), $stat->getRecordRate());
return true;
}
$fullDuration = ($stat->getRecordDuration() + $duration);
if ($project->hasTimeBudget() && $fullDuration > $project->getTimeBudget()) {
$this->addTimeBudgetViolation($constraint, 'project', $project->getTimeBudget(), $stat->getRecordDuration());
return true;
}
return false;
}
private function checkCustomer(TimesheetBudgetUsed $constraint, Timesheet $timesheet, int $duration, float $rate): bool
{
$customer = $timesheet->getProject()->getCustomer();
if (!$customer->hasBudget() && !$customer->hasTimeBudget()) {
return false;
}
$stat = $this->customerStatisticService->getCustomerStatistics($customer);
$fullRate = ($stat->getRecordRate() + $rate);
if ($customer->hasBudget() && $fullRate > $customer->getBudget()) {
$this->addBudgetViolation($constraint, $timesheet, 'customer', $customer->getBudget(), $stat->getRecordRate());
return true;
}
$fullDuration = ($stat->getRecordDuration() + $duration);
if ($customer->hasTimeBudget() && $fullDuration > $customer->getTimeBudget()) {
$this->addTimeBudgetViolation($constraint, 'customer', $customer->getTimeBudget(), $stat->getRecordDuration());
if ($stat->hasTimeBudget() && $fullDuration > $stat->getTimeBudget()) {
$this->addTimeBudgetViolation($constraint, $field, $stat->getTimeBudget(), $stat->getTimeBudgetSpent());
return true;
}

View File

@@ -114,11 +114,12 @@ class SimpleStatisticChart extends SimpleWidget implements UserWidget
}
try {
$user = null;
if (true === $this->queryWithUser) {
return $this->repository->getStatistic($this->query, $begin, $end, $this->user);
} else {
return $this->repository->getStatistic($this->query, $begin, $end, null);
$user = $this->user;
}
return $this->repository->getStatistic($this->query, $begin, $end, $user);
} catch (\Exception $ex) {
throw new WidgetException(
'Failed loading widget data: ' . $ex->getMessage()

View File

@@ -13,17 +13,22 @@ use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\Project\ProjectStatisticService;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Loader\TeamLoader;
use Doctrine\ORM\EntityManagerInterface;
class UserTeamProjects extends SimpleWidget implements AuthorizedWidget, UserWidget
{
private $statisticService;
private $entityManager;
public function __construct(ProjectStatisticService $statisticService)
public function __construct(ProjectStatisticService $statisticService, EntityManagerInterface $entityManager)
{
$this->setId('UserTeamProjects');
$this->setTitle('label.my_team_projects');
$this->setOption('id', '');
$this->statisticService = $statisticService;
$this->entityManager = $entityManager;
}
public function getOptions(array $options = []): array
@@ -42,32 +47,35 @@ class UserTeamProjects extends SimpleWidget implements AuthorizedWidget, UserWid
$options = $this->getOptions($options);
/** @var User $user */
$user = $options['user'];
$projects = [];
$now = new \DateTime('now', new \DateTimeZone($user->getTimezone()));
$loader = new TeamLoader($this->entityManager);
$loader->loadResults($user->getTeams()->toArray());
$teamProjects = [];
$projects = [];
/** @var Team $team */
foreach ($user->getTeams() as $team) {
/** @var Project $project */
foreach ($team->getProjects() as $project) {
if (!$project->isVisibleAtDate($now)) {
continue;
if (!isset($projects[$project->getId()])) {
$teamProjects[$project->getId()] = $project;
}
$projects[$project->getId()] = $project;
}
}
$stats = [];
$loader = new ProjectLoader($this->entityManager);
$loader->loadResults($teamProjects);
foreach ($projects as $id => $project) {
if ($project->getBudget() > 0 || $project->getTimeBudget() > 0) {
$stats[] = [
'project' => $project,
'stats' => $this->statisticService->getProjectStatistics($project),
];
foreach ($teamProjects as $id => $project) {
if (!$project->isVisibleAtDate($now) || !$project->hasBudgets()) {
continue;
}
$projects[$project->getId()] = $project;
}
return $stats;
return $this->statisticService->getBudgetStatisticModelForProjects($projects, $now);
}
/**