monthly budget, monthly report, unified report calculation (#2684)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
60
src/Controller/Reporting/ProjectDateRangeController.php
Normal file
60
src/Controller/Reporting/ProjectDateRangeController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
116
src/Entity/BudgetTrait.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
*/
|
||||
|
||||
34
src/Entity/EntityWithBudget.php
Normal file
34
src/Entity/EntityWithBudget.php
Normal 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;
|
||||
}
|
||||
@@ -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[]
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
64
src/Event/ActivityBudgetStatisticEvent.php
Normal file
64
src/Event/ActivityBudgetStatisticEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
64
src/Event/ProjectBudgetStatisticEvent.php
Normal file
64
src/Event/ProjectBudgetStatisticEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
42
src/Form/Type/BudgetType.php
Normal file
42
src/Form/Type/BudgetType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
47
src/Migrations/Version20210719123928.php
Normal file
47
src/Migrations/Version20210719123928.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
39
src/Migrations/Version20210802152259.php
Normal file
39
src/Migrations/Version20210802152259.php
Normal 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');
|
||||
}
|
||||
}
|
||||
53
src/Migrations/Version20210802152814.php
Normal file
53
src/Migrations/Version20210802152814.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
40
src/Migrations/Version20210802160837.php
Normal file
40
src/Migrations/Version20210802160837.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
src/Model/ActivityBudgetStatisticModel.php
Normal file
31
src/Model/ActivityBudgetStatisticModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
198
src/Model/BudgetStatisticModel.php
Normal file
198
src/Model/BudgetStatisticModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/Model/BudgetStatisticModelInterface.php
Normal file
30
src/Model/BudgetStatisticModelInterface.php
Normal 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;
|
||||
}
|
||||
31
src/Model/CustomerBudgetStatisticModel.php
Normal file
31
src/Model/CustomerBudgetStatisticModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
109
src/Model/DailyStatistic.php
Normal file
109
src/Model/DailyStatistic.php
Normal 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;
|
||||
}
|
||||
}
|
||||
139
src/Model/MonthlyStatistic.php
Normal file
139
src/Model/MonthlyStatistic.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/Model/ProjectBudgetStatisticModel.php
Normal file
31
src/Model/ProjectBudgetStatisticModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
19
src/Model/Statistic/BudgetStatistic.php
Normal file
19
src/Model/Statistic/BudgetStatistic.php
Normal 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
|
||||
{
|
||||
}
|
||||
47
src/Model/Statistic/StatisticDate.php
Normal file
47
src/Model/Statistic/StatisticDate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
66
src/Reporting/ProjectDateRange/ProjectDateRangeForm.php
Normal file
66
src/Reporting/ProjectDateRange/ProjectDateRangeForm.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
83
src/Reporting/ProjectDateRange/ProjectDateRangeQuery.php
Normal file
83
src/Reporting/ProjectDateRange/ProjectDateRangeQuery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
247
src/Timesheet/TimesheetStatisticService.php
Normal file
247
src/Timesheet/TimesheetStatisticService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user