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

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

2
.gitignore vendored
View File

@@ -19,7 +19,7 @@ php.ini
public/avatars/*.png public/avatars/*.png
templates/invoice/renderer/.~lock* templates/invoice/renderer/.~lock*
translations/branding.en.xlf translations/branding.*.xlf
/var/data/* /var/data/*
!/var/data/.gitkeep !/var/data/.gitkeep

View File

@@ -10,14 +10,17 @@ Perform EACH version specific task between your version and the new one, otherwi
## [1.15](https://github.com/kevinpapst/kimai2/releases/tag/1.15) ## [1.15](https://github.com/kevinpapst/kimai2/releases/tag/1.15)
Updating the database might take quite a while, depending on the amount of timesheet entries and speed of your database server (~1 minute per 100k records).
**ATTENTION** **ATTENTION**
- This release bumps the minimum required [PHP version to 7.3](https://www.kimai.org/blog/2021/php8-support-php72-dropped/) - This release bumps the minimum required [PHP version to 7.3](https://www.kimai.org/blog/2021/php8-support-php72-dropped/)
- Self-registration is disabled by default - Self-registration is disabled by default
- Self-registration now always requires email confirmation - Self-registration now always requires email confirmation
- All plugins that use own databases need to be updated as well - All plugins that use own databases need to be updated as well
- Removed the YearChart widget and the related configs named `userRecapThisYear`, `userRecapLastYear`, `userRecapTwoYears`, `userRecapThreeYears`
**LDAP & SAML** **LDAP & SAML**
- SAML users need to activate it by setting the new `kimai.saml.activate: true` config - SAML users need to activate SAML by setting the new `kimai.saml.activate: true` config key
- LDAP and SAML users need to remove the complete `security` section from their `local.yaml` - LDAP and SAML users need to remove the complete `security` section from their `local.yaml`
- Please verify your config with the [LDAP](https://www.kimai.org/documentation/ldap.html) and [SAML](https://www.kimai.org/documentation/saml.html) documentation - Please verify your config with the [LDAP](https://www.kimai.org/documentation/ldap.html) and [SAML](https://www.kimai.org/documentation/saml.html) documentation
@@ -25,7 +28,7 @@ Perform EACH version specific task between your version and the new one, otherwi
PHP 8 compatibility forced to upgrade MANY libraries, including but not limited to: PHP 8 compatibility forced to upgrade MANY libraries, including but not limited to:
- Removed FOSUserBundle and hslavich/oneloginsaml - Removed FOSUserBundle and hslavich/oneloginsaml
- Doctrine Migrations, whose new major version forces the plugin updates - Doctrine Migrations, whose new major version forces plugin updates
- Gedmo v3 (which include BC breaks in definitions) - Gedmo v3 (which include BC breaks in definitions)
- Doctrine DBAL and others, which required PHP 7.3 as well - Doctrine DBAL and others, which required PHP 7.3 as well

View File

@@ -1,6 +1,7 @@
.box { .box {
/* used on detail pages for: customer, project and activity */ /* used on detail pages for: customer, project and activity */
.box-body.no-padding div.comment { .box-body.no-padding div.comment,
.box-body.no-padding .box-padding {
padding: 10px; padding: 10px;
} }

View File

@@ -31,7 +31,7 @@
padding-left: 10px; padding-left: 10px;
} }
@media (max-width: $screen-md-min) { @media (max-width: $screen-md-max) {
.content-header h1 { .content-header h1 {
font-size: 1.5em; font-size: 1.5em;
} }

View File

@@ -15,6 +15,12 @@
} }
} }
@media (min-width: $screen-lg-min) {
.content {
padding: 20px;
}
}
td { td {
&.timesheet-description { &.timesheet-description {
ul { ul {

View File

@@ -1,4 +1,4 @@
@media (max-width: $screen-sm-min) { @media (max-width: $screen-sm-max) {
.main-header .logo { .main-header .logo {
display: none; display: none;
} }

View File

@@ -73,18 +73,6 @@
} }
} }
} }
@media (max-width: $screen-sm-min) {
.navbar-custom-menu>.navbar-nav>li>.dropdown-menu {
right: 0;
}
.navbar-nav .user-menu {
margin-right: 0;
}
.navbar-nav .user-menu.open .dropdown-menu > li > a {
padding: 5px 25px 5px 25px
}
}
@keyframes ticktac-blink { @keyframes ticktac-blink {
0% { opacity: 1; } 0% { opacity: 1; }
5% { opacity: 0.95; } 5% { opacity: 0.95; }
@@ -130,7 +118,23 @@ body.fixed {
} }
} }
@media (max-width: $screen-sm-min) { @media (max-width: $screen-sm-max) {
.navbar-custom-menu>.navbar-nav>li>.dropdown-menu {
right: 0;
}
body.fixed {
.navbar-nav {
.user-menu {
margin-right: 0;
}
}
}
.navbar-nav .user-menu.open .dropdown-menu > li > a {
padding: 5px 25px 5px 25px
}
}
@media (max-width: $screen-sm-max) {
.ticktac-single { .ticktac-single {
padding-left: 5px; padding-left: 5px;
padding-right: 5px; padding-right: 5px;
@@ -141,7 +145,7 @@ body.fixed {
} }
} }
@media (max-width: $screen-md-min) { @media (max-width: $screen-md-max) {
.ticktac-single { .ticktac-single {
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;

View File

@@ -10,7 +10,7 @@ $skins:
@each $skinColor, $navHighlight, $tickTacColor, $startButton, $stopButton in $skins { @each $skinColor, $navHighlight, $tickTacColor, $startButton, $stopButton in $skins {
@each $prefix in (".skin-#{$skinColor}", ".skin-#{$skinColor}-light") { @each $prefix in (".skin-#{$skinColor}", ".skin-#{$skinColor}-light") {
@media (max-width: $screen-sm-min) { @media (max-width: $screen-sm-max) {
#{$prefix} { #{$prefix} {
.main-header .navbar .dropdown-menu li.divider { .main-header .navbar .dropdown-menu li.divider {
background-color: #ccc; background-color: #ccc;

View File

@@ -5,10 +5,6 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
.toolbar-pad {
padding: 10px;
}
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.toolbar { .toolbar {
// @deprecated since 1.2 - using the navbar-form is deprecated and will be removed with 2.0 // @deprecated since 1.2 - using the navbar-form is deprecated and will be removed with 2.0
@@ -27,7 +23,4 @@
} }
} }
} }
.toolbar-pad {
padding: 10px 0;
}
} }

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
"build/app.65f97645.js" "build/app.65f97645.js"
], ],
"css": [ "css": [
"build/app.beac1734.css" "build/app.047d688b.css"
] ]
}, },
"invoice": { "invoice": {

View File

@@ -1,5 +1,5 @@
{ {
"build/app.css": "build/app.beac1734.css", "build/app.css": "build/app.047d688b.css",
"build/app.js": "build/app.65f97645.js", "build/app.js": "build/app.65f97645.js",
"build/invoice.css": "build/invoice.ff32661a.css", "build/invoice.css": "build/invoice.ff32661a.css",
"build/invoice.js": "build/invoice.19f36eca.js", "build/invoice.js": "build/invoice.19f36eca.js",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Form\Model\DateRange;
use App\Project\ProjectStatisticService;
use App\Reporting\ProjectDateRange\ProjectDateRangeForm;
use App\Reporting\ProjectDateRange\ProjectDateRangeQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class ProjectDateRangeController extends AbstractController
{
/**
* @Route(path="/reporting/project_daterange", name="report_project_daterange", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_project')")
*/
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectDaterangeQuery($dateFactory->getStartOfMonth(), $user);
$form = $this->createForm(ProjectDateRangeForm::class, $query, [
'timezone' => $user->getTimezone()
]);
$form->submit($request->query->all(), false);
$dateRange = new DateRange(true);
$dateRange->setBegin($query->getMonth());
$dateRange->setEnd($dateFactory->getEndOfMonth($dateRange->getBegin()));
$projects = $service->findProjectsForDateRange($query, $dateRange);
$entries = $service->getBudgetStatisticModelForProjectsByDateRange($projects, $dateRange->getBegin(), $dateRange->getEnd(), $dateRange->getEnd());
$byCustomer = [];
foreach ($entries as $entry) {
$customer = $entry->getProject()->getCustomer();
if (!isset($byCustomer[$customer->getId()])) {
$byCustomer[$customer->getId()] = ['customer' => $customer, 'projects' => []];
}
$byCustomer[$customer->getId()]['projects'][] = $entry;
}
return $this->render('reporting/project_daterange.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'queryEnd' => $dateRange->getEnd(),
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,8 @@ namespace App\Controller\Reporting;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController; use App\Controller\AbstractController;
use App\Model\Statistic\Day; use App\Model\DailyStatistic;
use App\Model\Statistic\Year; use App\Model\MonthlyStatistic;
use App\Reporting\MonthlyUserList; use App\Reporting\MonthlyUserList;
use App\Reporting\MonthlyUserListForm; use App\Reporting\MonthlyUserListForm;
use App\Reporting\WeeklyUserList; use App\Reporting\WeeklyUserList;
@@ -22,6 +22,7 @@ use App\Reporting\YearlyUserListForm;
use App\Repository\Query\UserQuery; use App\Repository\Query\UserQuery;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use Exception; use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -34,13 +35,7 @@ use Symfony\Component\Routing\Annotation\Route;
*/ */
final class ReportUsersListController extends AbstractController final class ReportUsersListController extends AbstractController
{ {
/**
* @var TimesheetRepository
*/
private $timesheetRepository; private $timesheetRepository;
/**
* @var UserRepository
*/
private $userRepository; private $userRepository;
public function __construct(TimesheetRepository $timesheetRepository, UserRepository $userRepository) public function __construct(TimesheetRepository $timesheetRepository, UserRepository $userRepository)
@@ -56,11 +51,10 @@ final class ReportUsersListController extends AbstractController
* @return Response * @return Response
* @throws Exception * @throws Exception
*/ */
public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration): Response public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService): Response
{ {
$currentUser = $this->getUser(); $currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory(); $dateTimeFactory = $this->getDateTimeFactory();
$localeFormats = $this->getLocaleFormats($request->getLocale());
$query = new UserQuery(); $query = new UserQuery();
$query->setCurrentUser($currentUser); $query->setCurrentUser($currentUser);
@@ -71,15 +65,12 @@ final class ReportUsersListController extends AbstractController
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear); $defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
} }
$rows = [];
$values = new YearlyUserList(); $values = new YearlyUserList();
$values->setDate(clone $defaultDate); $values->setDate(clone $defaultDate);
$form = $this->createForm(YearlyUserListForm::class, $values, [ $form = $this->createForm(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(), 'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(), 'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]); ]);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);
@@ -93,123 +84,49 @@ final class ReportUsersListController extends AbstractController
} }
$start = $values->getDate(); $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 = []; $monthStats = [];
$totals = []; $hasData = true;
foreach ($allUsers as $user) {
$rows[] = [ if (!empty($allUsers)) {
'years' => $this->timesheetRepository->getMonthlyStats($start, $end, $user), $monthStats = $statisticService->getMonthlyStats($start, $end, $allUsers);
'user' => $user
];
} }
if (isset($rows[0])) { if (empty($monthStats)) {
/** @var Year $year */ $monthStats = [new MonthlyStatistic($start, $end, $currentUser)];
foreach ($rows[0]['years'] as $year) { $hasData = false;
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();
}
}
}
} }
/*
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', [ return $this->render('reporting/report_user_list_monthly.html.twig', [
'report_title' => 'report_yearly_users', 'report_title' => 'report_yearly_users',
'box_id' => 'yearly-user-list-reporting-box', 'box_id' => 'yearly-user-list-reporting-box',
'form' => $form->createView(), 'form' => $form->createView(),
'rows' => $rows, 'stats' => $monthStats,
'months' => $months, 'hasData' => $hasData,
'totals' => $totals,
]); ]);
} }
/** /**
* @Route(path="/monthly_users_list", name="report_monthly_users", methods={"GET","POST"}) * @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(); $currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory(); $dateTimeFactory = $this->getDateTimeFactory();
$localeFormats = $this->getLocaleFormats($request->getLocale());
$query = new UserQuery(); $query = new UserQuery();
$query->setCurrentUser($currentUser); $query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query); $allUsers = $this->userRepository->getUsersForQuery($query);
$rows = [];
$values = new MonthlyUserList(); $values = new MonthlyUserList();
$values->setDate($dateTimeFactory->getStartOfMonth()); $values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthlyUserListForm::class, $values, [ $form = $this->createForm(MonthlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(), 'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(), 'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]); ]);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);
@@ -234,71 +151,50 @@ final class ReportUsersListController extends AbstractController
$next = clone $start; $next = clone $start;
$next->modify('+1 month'); $next->modify('+1 month');
foreach ($allUsers as $user) { $dayStats = [];
$rows[] = [ $hasData = true;
'days' => $this->timesheetRepository->getDailyStats($user, $start, $end),
'user' => $user if (!empty($allUsers)) {
]; $dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
} }
$days = []; if (empty($dayStats)) {
$totals = []; $dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
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();
}
}
} }
return $this->render('reporting/report_user_list.html.twig', [ return $this->render('reporting/report_user_list.html.twig', [
'report_title' => 'report_monthly_users', 'report_title' => 'report_monthly_users',
'box_id' => 'monthly-user-list-reporting-box', 'box_id' => 'monthly-user-list-reporting-box',
'form' => $form->createView(), 'form' => $form->createView(),
'rows' => $rows,
'days' => $days,
'totals' => $totals,
'current' => $start, 'current' => $start,
'next' => $next, 'next' => $next,
'previous' => $previous, '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"}) * @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(); $currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory(); $dateTimeFactory = $this->getDateTimeFactory();
$localeFormats = $this->getLocaleFormats($request->getLocale());
$query = new UserQuery(); $query = new UserQuery();
$query->setCurrentUser($currentUser); $query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query); $allUsers = $this->userRepository->getUsersForQuery($query);
$rows = [];
$values = new WeeklyUserList(); $values = new WeeklyUserList();
$values->setDate($dateTimeFactory->getStartOfWeek()); $values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeeklyUserListForm::class, $values, [ $form = $this->createForm(WeeklyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(), 'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(), 'start_date' => $values->getDate(),
'format' => $localeFormats->getDateTypeFormat(),
]); ]);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);
@@ -320,42 +216,29 @@ final class ReportUsersListController extends AbstractController
$next = clone $start; $next = clone $start;
$next->modify('+1 week'); $next->modify('+1 week');
foreach ($allUsers as $user) { $dayStats = [];
$rows[] = [ $hasData = true;
'days' => $this->timesheetRepository->getDailyStats($user, $start, $end),
'user' => $user if (!empty($allUsers)) {
]; $dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
} }
$days = []; if (empty($dayStats)) {
$totals = []; $dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
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();
}
}
} }
return $this->render('reporting/report_user_list.html.twig', [ return $this->render('reporting/report_user_list.html.twig', [
'report_title' => 'report_weekly_users', 'report_title' => 'report_weekly_users',
'box_id' => 'weekly-user-list-reporting-box', 'box_id' => 'weekly-user-list-reporting-box',
'form' => $form->createView(), 'form' => $form->createView(),
'rows' => $rows,
'days' => $days,
'totals' => $totals,
'current' => $start, 'current' => $start,
'next' => $next, 'next' => $next,
'previous' => $previous, 'previous' => $previous,
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_week',
'stats' => $dayStats,
'hasData' => $hasData,
]); ]);
} }
} }

View File

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

View File

@@ -50,8 +50,11 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "color", "visible", "comment"}) * @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "color", "visible", "comment"})
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()") * @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 * Internal ID
* *
@@ -120,40 +123,6 @@ class Activity implements EntityWithMetaFields
* @Assert\NotNull() * @Assert\NotNull()
*/ */
private $visible = true; 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 * Meta fields
* *
@@ -258,40 +227,6 @@ class Activity implements EntityWithMetaFields
return $this->visible; 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[] * @return Collection|MetaTableTypeInterface[]
*/ */

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

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

View File

@@ -30,10 +30,13 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "color", "visible", "teams", "comment"}) * @Exporter\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") * @ 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'; public const DEFAULT_CURRENCY = 'EUR';
use BudgetTrait;
use ColorTrait;
/** /**
* @var int|null * @var int|null
* *
@@ -251,40 +254,6 @@ class Customer implements EntityWithMetaFields
* @Assert\Length(max=64) * @Assert\Length(max=64)
*/ */
private $timezone; 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 * Meta fields
* *
@@ -528,40 +497,6 @@ class Customer implements EntityWithMetaFields
return $this->timezone; 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[] * @return Collection|MetaTableTypeInterface[]
*/ */

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
/**
* @internal
*/
interface EntityWithBudget
{
public function setBudget(float $budget): void;
public function getBudget(): float;
public function hasBudget(): bool;
public function setTimeBudget(int $seconds): void;
public function getTimeBudget(): int;
public function hasTimeBudget(): bool;
public function isMonthlyBudget(): bool;
public function getBudgetType(): ?string;
public function setBudgetType(?string $budgetType = null): void;
}

View File

@@ -52,8 +52,11 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()") * @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()")
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") * @ 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 * Internal ID
* *
@@ -192,40 +195,6 @@ class Project implements EntityWithMetaFields
* @Assert\NotNull() * @Assert\NotNull()
*/ */
private $visible = true; 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 * Meta fields
* *
@@ -422,40 +391,6 @@ class Project implements EntityWithMetaFields
return $this; 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[] * @return Collection|MetaTableTypeInterface[]
*/ */

View File

@@ -30,6 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Index(columns={"start_time"}), * @ORM\Index(columns={"start_time"}),
* @ORM\Index(columns={"start_time","end_time"}), * @ORM\Index(columns={"start_time","end_time"}),
* @ORM\Index(columns={"start_time","end_time","user"}), * @ORM\Index(columns={"start_time","end_time","user"}),
* @ORM\Index(columns={"date_tz","user"}),
* } * }
* ) * )
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository") * @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
@@ -108,6 +109,16 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
* @ORM\GeneratedValue(strategy="IDENTITY") * @ORM\GeneratedValue(strategy="IDENTITY")
*/ */
private $id; 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 * @var DateTime
* *
@@ -360,6 +371,8 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
{ {
$this->begin = $begin; $this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName(); $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; return $this;
} }
@@ -588,8 +601,8 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
} }
/** /**
* BE WARNED: this method should NOT be used. * BE WARNED: this method should NOT be used from outside.
* It was ONLY introduced for the command "kimai:import-v1". * It is reserved for some very rare use-cases.
* *
* @internal * @internal
* @param string $timezone * @param string $timezone

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Model\ActivityBudgetStatisticModel;
final class ActivityBudgetStatisticEvent
{
private $models;
private $begin;
private $end;
/**
* @param ActivityBudgetStatisticModel[] $models
* @param \DateTime|null $begin
* @param \DateTime|null $end
*/
public function __construct(array $models, ?\DateTime $begin = null, ?\DateTime $end = null)
{
$this->models = $models;
$this->begin = $begin;
$this->end = $end;
}
public function getModel(int $activityId): ?ActivityBudgetStatisticModel
{
if (isset($this->models[$activityId])) {
return $this->models[$activityId];
}
foreach ($this->models as $model) {
if ($model->getActivity()->getId() === $activityId) {
return $model;
}
}
return null;
}
/**
* @return ActivityBudgetStatisticModel[]
*/
public function getModels(): array
{
return $this->models;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Model\ProjectBudgetStatisticModel;
final class ProjectBudgetStatisticEvent
{
private $models;
private $begin;
private $end;
/**
* @param ProjectBudgetStatisticModel[] $models
* @param \DateTime|null $begin
* @param \DateTime|null $end
*/
public function __construct(array $models, ?\DateTime $begin = null, ?\DateTime $end = null)
{
$this->models = $models;
$this->begin = $begin;
$this->end = $end;
}
public function getModel(int $projectId): ?ProjectBudgetStatisticModel
{
if (isset($this->models[$projectId])) {
return $this->models[$projectId];
}
foreach ($this->models as $model) {
if ($model->getProject()->getId() === $projectId) {
return $model;
}
}
return null;
}
/**
* @return ProjectBudgetStatisticModel[]
*/
public function getModels(): array
{
return $this->models;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select the type of budget.
*/
class BudgetType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.budgetType',
'required' => false,
'choices' => [
'label.budgetType_month' => 'month',
],
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20210719123928 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the budget_type columns to customer, project and activity';
}
public function up(Schema $schema): void
{
$activities = $schema->getTable('kimai2_activities');
$activities->addColumn('budget_type', 'string', ['length' => 10, 'notnull' => false, 'default' => null]);
$customers = $schema->getTable('kimai2_customers');
$customers->addColumn('budget_type', 'string', ['length' => 10, 'notnull' => false, 'default' => null]);
$projects = $schema->getTable('kimai2_projects');
$projects->addColumn('budget_type', 'string', ['length' => 10, 'notnull' => false, 'default' => null]);
}
public function down(Schema $schema): void
{
$activities = $schema->getTable('kimai2_activities');
$activities->dropColumn('budget_type');
$customers = $schema->getTable('kimai2_customers');
$customers->dropColumn('budget_type');
$projects = $schema->getTable('kimai2_projects');
$projects->dropColumn('budget_type');
}
}

View File

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

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
/**
* @version 1.15
*/
final class Version20210802152259 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the date column to the timesheet table';
}
public function up(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->addColumn('date_tz', Types::DATE_MUTABLE, ['notnull' => false]);
}
public function down(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->dropColumn('date_tz');
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 1.15
*/
final class Version20210802152814 extends AbstractMigration
{
public function getDescription(): string
{
return 'Fills the new date column in the timesheet table';
}
public function up(Schema $schema): void
{
$fetch = $this->connection->prepare('SELECT id, start_time, timezone FROM kimai2_timesheet WHERE date_tz IS NULL');
$timezones = [];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$timezones[$tz] = new \DateTimeZone($tz);
}
foreach ($fetch->executeQuery()->iterateAssociative() as $row) {
if (!isset($timezones[$row['timezone']])) {
$timezones[$row['timezone']] = new \DateTimeZone($row['timezone']);
}
$date = new \DateTime($row['start_time'], $timezones['UTC']);
$date->setTimezone($timezones[$row['timezone']]);
$this->addSql('UPDATE kimai2_timesheet SET date_tz = ? WHERE id = ?', [$date->format('Y-m-d'), $row['id']]);
}
$fetch->free();
}
public function down(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->changeColumn('date_tz', ['notnull' => false]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 1.15
*/
final class Version20210802160837 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates the index on the new timesheet statistic date column';
}
public function up(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->changeColumn('date_tz', ['notnull' => true]);
$timesheet->addIndex(['date_tz', 'user'], 'IDX_4F60C6B1BDF467148D93D649');
}
public function down(Schema $schema): void
{
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->changeColumn('date_tz', ['notnull' => false]);
$timesheet->dropIndex('IDX_4F60C6B1BDF467148D93D649');
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\Activity;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
* @method Activity getEntity()
*/
class ActivityBudgetStatisticModel extends BudgetStatisticModel
{
public function __construct(Activity $activity)
{
parent::__construct($activity);
}
public function getActivity(): Activity
{
return $this->getEntity();
}
}

View File

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

View File

@@ -0,0 +1,198 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\EntityWithBudget;
use App\Model\Statistic\BudgetStatistic;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
*/
class BudgetStatisticModel implements BudgetStatisticModelInterface
{
/**
* @var EntityWithBudget
*/
private $entity;
/**
* @var BudgetStatistic
*/
private $statistic;
/**
* @var BudgetStatistic
*/
private $statisticTotal;
public function __construct(EntityWithBudget $entity)
{
$this->entity = $entity;
}
public function getEntity(): EntityWithBudget
{
return $this->entity;
}
public function getStatistic(): ?BudgetStatistic
{
return $this->statistic;
}
public function setStatistic(BudgetStatistic $statistic)
{
$this->statistic = $statistic;
}
public function getStatisticTotal(): ?BudgetStatistic
{
return $this->statisticTotal;
}
public function setStatisticTotal(BudgetStatistic $statistic)
{
$this->statisticTotal = $statistic;
}
public function hasTimeBudget(): bool
{
return $this->entity->hasTimeBudget();
}
public function getTimeBudget(): int
{
return $this->entity->getTimeBudget();
}
public function isMonthlyBudget(): bool
{
return $this->entity->isMonthlyBudget();
}
public function getDurationBillable(): int
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0;
}
return $this->statistic->getDurationBillable();
}
if ($this->statisticTotal === null) {
return 0;
}
return $this->statisticTotal->getDurationBillable();
}
public function getTimeBudgetOpen(): int
{
$value = $this->getTimeBudget() - $this->getTimeBudgetSpent();
return $value > 0 ? $value : 0;
}
public function getTimeBudgetSpent(): int
{
return $this->getDurationBillable();
}
public function hasBudget(): bool
{
return $this->entity->hasBudget();
}
public function getBudget(): float
{
return $this->entity->getBudget();
}
public function getBudgetOpen(): float
{
$value = $this->getBudget() - $this->getBudgetSpent();
return $value > 0 ? $value : 0;
}
public function getBudgetSpent(): float
{
return $this->getRateBillable();
}
public function getRateBillable(): float
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0.00;
}
return $this->statistic->getRateBillable();
}
if ($this->statisticTotal === null) {
return 0.00;
}
return $this->statisticTotal->getRateBillable();
}
public function getRate(): float
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0.00;
}
return $this->statistic->getRate();
}
if ($this->statisticTotal === null) {
return 0.00;
}
return $this->statisticTotal->getRate();
}
public function getDuration(): int
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0;
}
return $this->statistic->getDuration();
}
if ($this->statisticTotal === null) {
return 0;
}
return $this->statisticTotal->getDuration();
}
public function getInternalRate(): float
{
if ($this->isMonthlyBudget()) {
if ($this->statistic === null) {
return 0.00;
}
return $this->statistic->getInternalRate();
}
if ($this->statisticTotal === null) {
return 0.00;
}
return $this->statisticTotal->getInternalRate();
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
/**
* @internal do not use in plugins, no BC promise given!
*/
interface BudgetStatisticModelInterface
{
public function isMonthlyBudget(): bool;
public function hasTimeBudget(): bool;
public function getTimeBudget(): int;
public function getTimeBudgetSpent(): int;
public function hasBudget(): bool;
public function getBudget(): float;
public function getBudgetSpent(): float;
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\Customer;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
* @method Customer getEntity()
*/
class CustomerBudgetStatisticModel extends BudgetStatisticModel
{
public function __construct(Customer $customer)
{
parent::__construct($customer);
}
public function getCustomer(): Customer
{
return $this->getEntity();
}
}

View File

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

View File

@@ -0,0 +1,109 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\User;
use App\Model\Statistic\StatisticDate;
use DateTime;
use DateTimeInterface;
final class DailyStatistic
{
/**
* @var array<string, StatisticDate>
*/
private $days = [];
private $begin;
private $end;
private $user;
public function __construct(DateTime $begin, DateTime $end, User $user)
{
$this->begin = clone $begin;
$this->end = clone $end;
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
private function setupDays(): void
{
if (!empty($this->days)) {
return;
}
$tmp = clone $this->begin;
$tmp->setTime(0, 0, 0);
while ($tmp < $this->end) {
$id = $tmp->format('Y-m-d');
$this->days[$id] = new StatisticDate(clone $tmp);
$tmp->modify('+1 day');
}
}
/**
* @return StatisticDate[]
*/
public function getDays(): array
{
$this->setupDays();
return array_values($this->days);
}
public function getDayByDateTime(\DateTimeInterface $date): ?StatisticDate
{
return $this->getDay($date->format('Y'), $date->format('m'), $date->format('d'));
}
public function getDayByReportDate(string $date): ?StatisticDate
{
$this->setupDays();
if (!isset($this->days[$date])) {
return null;
}
return $this->days[$date];
}
public function getDay(string $year, string $month, string $day): ?StatisticDate
{
if ((int) $month < 10) {
$month = '0' . (int) $month;
}
if ((int) $day < 10) {
$day = '0' . (int) $day;
}
$date = $year . '-' . $month . '-' . $day;
return $this->getDayByReportDate($date);
}
/**
* @return DateTimeInterface[]
*/
public function getDateTimes(): array
{
$this->setupDays();
$all = [];
foreach ($this->days as $id => $day) {
$all[] = $day->getDate();
}
return $all;
}
}

View File

@@ -0,0 +1,139 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\User;
use App\Model\Statistic\StatisticDate;
use DateTime;
use DateTimeInterface;
final class MonthlyStatistic
{
/**
* @var array<string, array<int, StatisticDate>>
*/
private $years = [];
private $begin;
private $end;
private $user;
public function __construct(DateTime $begin, DateTime $end, User $user)
{
$this->begin = clone $begin;
$this->end = clone $end;
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
private function setupYears(): void
{
if (!empty($this->years)) {
return;
}
$years = [];
$begin = clone $this->begin;
$begin->setTime(0, 0, 0);
$tmp = clone $begin;
$day = (int) $begin->format('d');
while ($tmp < $this->end) {
$curYear = $tmp->format('Y');
if (!isset($years[$curYear])) {
$year = [];
for ($i = 1; $i < 13; $i++) {
$date = clone $begin;
// financial years do NOT start at the first of the month, do not reset day to 1
$date->setDate((int) $curYear, $i, $day);
$date->setTime(0, 0, 0);
if ($date < $begin || $date > $this->end) {
continue;
}
$year[$i] = new StatisticDate($date);
}
$years[$curYear] = $year;
}
$tmp->modify('+1 month');
}
$this->years = $years;
}
/**
* @return string[]
*/
public function getYears(): array
{
$this->setupYears();
return array_keys($this->years);
}
public function getYear(string $year): ?array
{
$this->setupYears();
if (!isset($this->years[$year])) {
return null;
}
return $this->years[$year];
}
/**
* @return StatisticDate[]
*/
public function getMonths(): array
{
$this->setupYears();
$all = [];
foreach ($this->years as $number => $months) {
foreach ($months as $monthNumber => $statisticDate) {
$all[] = $statisticDate;
}
}
return $all;
}
public function getMonth(string $year, string $month): ?StatisticDate
{
$this->setupYears();
$month = (int) $month;
if (!isset($this->years[$year]) || !isset($this->years[$year][$month])) {
return null;
}
return $this->years[$year][$month];
}
/**
* @return DateTimeInterface[]
*/
public function getDateTimes(): array
{
$this->setupYears();
$all = [];
foreach ($this->years as $number => $months) {
foreach ($months as $monthNumber => $statisticDate) {
$all[] = $statisticDate->getDate();
}
}
return $all;
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\Project;
/**
* Object used to unify the access to budget data in charts.
*
* @internal do not use in plugins, no BC promise given!
* @method Project getEntity()
*/
class ProjectBudgetStatisticModel extends BudgetStatisticModel
{
public function __construct(Project $project)
{
parent::__construct($project);
}
public function getProject(): Project
{
return $this->getEntity();
}
}

View File

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

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model\Statistic;
use App\Model\TimesheetCountedStatistic;
/**
* @final
*/
class BudgetStatistic extends TimesheetCountedStatistic
{
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model\Statistic;
final class StatisticDate extends Timesheet
{
private $date;
private $billableDuration = 0;
private $billableRate = 0.00;
public function __construct(\DateTimeInterface $date)
{
$this->date = clone $date;
}
public function getDate(): \DateTimeInterface
{
return $this->date;
}
public function getBillableDuration(): int
{
return $this->billableDuration;
}
public function setBillableDuration(int $billableDuration): void
{
$this->billableDuration = $billableDuration;
}
public function getBillableRate(): float
{
return $this->billableRate;
}
public function setBillableRate(float $billableRate): void
{
$this->billableRate = $billableRate;
}
}

View File

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

View File

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

View File

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

View File

@@ -31,10 +31,10 @@ class UserStatistic extends TimesheetCountedStatistic
public function addValuesFromMonth(Month $month): void 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->setDurationBillable($this->getDurationBillable() + $month->getBillableDuration());
$this->setRecordRate($this->getRate() + $month->getTotalRate()); $this->setRate($this->getRate() + $month->getRate());
$this->setRateBillable($this->getRateBillable() + $month->getBillableRate()); $this->setRateBillable($this->getRateBillable() + $month->getBillableRate());
$this->setRecordInternalRate($this->getRecordInternalRate() + $month->getTotalInternalRate()); $this->setInternalRate($this->getInternalRate() + $month->getInternalRate());
} }
} }

View File

@@ -13,17 +13,22 @@ use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Event\ProjectBudgetStatisticEvent;
use App\Event\ProjectStatisticEvent; use App\Event\ProjectStatisticEvent;
use App\Form\Model\DateRange;
use App\Model\ActivityStatistic; use App\Model\ActivityStatistic;
use App\Model\ProjectBudgetStatisticModel;
use App\Model\ProjectStatistic; use App\Model\ProjectStatistic;
use App\Model\Statistic\Month; use App\Model\Statistic\Month;
use App\Model\Statistic\Year; use App\Model\Statistic\Year;
use App\Model\UserStatistic; use App\Model\UserStatistic;
use App\Reporting\ProjectDateRange\ProjectDateRangeQuery;
use App\Reporting\ProjectDetails\ProjectDetailsModel; use App\Reporting\ProjectDetails\ProjectDetailsModel;
use App\Reporting\ProjectDetails\ProjectDetailsQuery; use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Reporting\ProjectInactive\ProjectInactiveQuery; use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel; use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery; use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\Loader\ProjectLoader;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
@@ -51,13 +56,21 @@ class ProjectStatisticService
$this->userRepository = $userRepository; $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); $statistics = $this->getBudgetStatistic([$project], $begin, $end);
$event = new ProjectStatisticEvent($project, $statistic); $event = new ProjectStatisticEvent($project, array_pop($statistics), $begin, $end);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
return $statistic; return $event->getStatistic();
} }
/** /**
@@ -97,7 +110,253 @@ class ProjectStatisticService
$this->repository->addPermissionCriteria($qb, $user); $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 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 = []; $years = [];
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = $this->timesheetRepository->createQueryBuilder('t');
@@ -132,7 +393,7 @@ class ProjectStatisticService
$activity->setRecordRate($tmp['rate']); $activity->setRecordRate($tmp['rate']);
$activity->setRecordDuration($tmp['duration']); $activity->setRecordDuration($tmp['duration']);
$activity->setRecordInternalRate($tmp['internalRate']); $activity->setRecordInternalRate($tmp['internalRate']);
$activity->setRecordAmount($tmp['count']); $activity->setCounter($tmp['count']);
$model->addActivity($activity); $model->addActivity($activity);
} }
// --------------------------------------------------- // ---------------------------------------------------
@@ -140,8 +401,8 @@ class ProjectStatisticService
// fetch stats grouped by YEAR, MONTH and USER // fetch stats grouped by YEAR, MONTH and USER
$qb1 = clone $qb; $qb1 = clone $qb;
$qb1 $qb1
->addSelect('YEAR(t.begin) as year') ->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.begin) as month') ->addSelect('MONTH(t.date) as month')
->addSelect('IDENTITY(t.user) as user') ->addSelect('IDENTITY(t.user) as user')
->addGroupBy('year') ->addGroupBy('year')
->addGroupBy('month') ->addGroupBy('month')
@@ -151,33 +412,35 @@ class ProjectStatisticService
$userMonths = $qb1->getQuery()->getResult(); $userMonths = $qb1->getQuery()->getResult();
$userIds = array_unique(array_column($userMonths, 'user')); $userIds = array_unique(array_column($userMonths, 'user'));
$qb2 = $this->userRepository->createQueryBuilder('u'); if (!empty($userIds)) {
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds)); $qb2 = $this->userRepository->createQueryBuilder('u');
$users = []; $qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
foreach ($qb2->getQuery()->getResult() as $user) { $users = [];
$users[$user->getId()] = new UserStatistic($user); foreach ($qb2->getQuery()->getResult() as $user) {
} $users[$user->getId()] = new UserStatistic($user);
}
foreach ($userMonths as $tmp) {
$user = $users[$tmp['user']]->getUser(); foreach ($userMonths as $tmp) {
$year = $model->getUserYear($tmp['year'], $user); $user = $users[$tmp['user']]->getUser();
if ($year === null) { $year = $model->getUserYear($tmp['year'], $user);
$year = new Year($tmp['year']); if ($year === null) {
$model->setUserYear($year, $user); $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 // fetch stats grouped by YEARS
$qb1 = clone $qb; $qb1 = clone $qb;
$qb1 $qb1
->addSelect('YEAR(t.begin) as year') ->addSelect('YEAR(t.date) as year')
->addGroupBy('year') ->addGroupBy('year')
; ;
foreach ($qb1->getQuery()->getResult() as $year) { foreach ($qb1->getQuery()->getResult() as $year) {
@@ -192,8 +455,8 @@ class ProjectStatisticService
$qb2 $qb2
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity') ->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
->addSelect('a as activity') ->addSelect('a as activity')
->addSelect('YEAR(t.begin) as year') ->addSelect('YEAR(t.date) as year')
->andWhere('YEAR(t.begin) = :year') ->andWhere('YEAR(t.date) = :year')
->setParameter('year', $year['year']) ->setParameter('year', $year['year'])
->addGroupBy('year') ->addGroupBy('year')
->addGroupBy('a') ->addGroupBy('a')
@@ -204,7 +467,7 @@ class ProjectStatisticService
$activity->setRecordRate($tmp['rate']); $activity->setRecordRate($tmp['rate']);
$activity->setRecordDuration($tmp['duration']); $activity->setRecordDuration($tmp['duration']);
$activity->setRecordInternalRate($tmp['internalRate']); $activity->setRecordInternalRate($tmp['internalRate']);
$activity->setRecordAmount($tmp['count']); $activity->setCounter($tmp['count']);
$model->addYearActivity($tmp['year'], $activity); $model->addYearActivity($tmp['year'], $activity);
} }
} }
@@ -214,8 +477,8 @@ class ProjectStatisticService
// fetch stats grouped by MONTH and YEAR // fetch stats grouped by MONTH and YEAR
$qb1 = clone $qb; $qb1 = clone $qb;
$qb1 $qb1
->addSelect('YEAR(t.begin) as year') ->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.begin) as month') ->addSelect('MONTH(t.date) as month')
->addGroupBy('year') ->addGroupBy('year')
->addGroupBy('month') ->addGroupBy('month')
; ;
@@ -279,44 +542,58 @@ class ProjectStatisticService
$this->repository->addPermissionCriteria($qb, $user); $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 User $user
* @param Project[] $projects * @param Project[] $projects
* @param DateTime|null $today * @param DateTime $today
* @return ProjectViewModel[] * @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); $factory = DateTimeFactory::createByUser($user);
if (null === $today) {
$today = $factory->createDateTime();
}
$today = clone $today; $today = clone $today;
$begin = $factory->getStartOfWeek($today); $startOfWeek = $factory->getStartOfWeek($today);
$end = $factory->getEndOfWeek($today); $endOfWeek = $factory->getEndOfWeek($today);
$startMonth = (clone $begin)->modify('first day of this month'); $startMonth = (clone $startOfWeek)->modify('first day of this month');
$endMonth = (clone $begin)->modify('last day of this month'); $endMonth = (clone $startOfWeek)->modify('last day of this month');
$projectViews = []; $projectViews = [];
foreach ($projects as $project) { foreach ($projects as $project) {
$projectViews[$project->getId()] = new ProjectViewModel($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); $projectIds = array_keys($projectViews);
$qb = $this->timesheetRepository->createQueryBuilder('t'); $tplQb = $this->timesheetRepository->createQueryBuilder('t');
$qb $tplQb
->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') ->select('IDENTITY(t.project) AS id')
->andWhere($qb->expr()->in('t.project', ':project')) ->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') ->groupBy('t.project')
->setParameter('project', array_values($projectIds)) ->setParameter('project', array_values($projectIds))
; ;
$qb = clone $tplQb;
$qb->addSelect('MAX(t.date) as lastRecord');
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) { foreach ($result as $row) {
$projectViews[$row['id']]->setDurationTotal($row['duration']); $projectViews[$row['id']]->setDurationTotal($row['duration']);
@@ -329,14 +606,10 @@ class ProjectStatisticService
} }
// values for today // values for today
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = clone $tplQb;
$qb $qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration') ->andWhere('DATE(t.date) = :start_date')
->andWhere($qb->expr()->in('t.project', ':project')) ->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
->andWhere('DATE(t.begin) = :starting_date')
->groupBy('t.project')
->setParameter('starting_date', $today->format('Y-m-d'))
->setParameter('project', array_values($projectIds))
; ;
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
@@ -345,15 +618,11 @@ class ProjectStatisticService
} }
// values for the current week // values for the current week
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = clone $tplQb;
$qb $qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration') ->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->andWhere($qb->expr()->in('t.project', ':project')) ->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
->andWhere('DATE(t.begin) BETWEEN :start_date AND :end_date') ->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
->groupBy('t.project')
->setParameter('start_date', $begin->format('Y-m-d'))
->setParameter('end_date', $end->format('Y-m-d'))
->setParameter('project', array_values($projectIds))
; ;
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
@@ -362,15 +631,11 @@ class ProjectStatisticService
} }
// values for the current month // values for the current month
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = clone $tplQb;
$qb $qb
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration') ->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->andWhere($qb->expr()->in('t.project', ':project')) ->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
->andWhere('DATE(t.begin) BETWEEN :start_month AND :end_month') ->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
->groupBy('t.project')
->setParameter('start_month', $startMonth)
->setParameter('end_month', $endMonth)
->setParameter('project', array_values($projectIds))
; ;
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
@@ -379,14 +644,10 @@ class ProjectStatisticService
} }
// values for all time (not exported) // values for all time (not exported)
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = clone $tplQb;
$qb $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.exported = :exported')
->groupBy('t.project')
->setParameter('exported', false, Types::BOOLEAN) ->setParameter('exported', false, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
; ;
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
@@ -396,16 +657,12 @@ class ProjectStatisticService
} }
// values for all time (not exported and billable) // values for all time (not exported and billable)
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = clone $tplQb;
$qb $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.exported = :exported')
->andWhere('t.billable = :billable') ->andWhere('t.billable = :billable')
->groupBy('t.project')
->setParameter('exported', false, Types::BOOLEAN) ->setParameter('exported', false, Types::BOOLEAN)
->setParameter('billable', true, Types::BOOLEAN) ->setParameter('billable', true, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
; ;
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
@@ -415,14 +672,11 @@ class ProjectStatisticService
} }
// values for all time (none billable) // values for all time (none billable)
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = clone $tplQb;
$qb $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') ->andWhere('t.billable = :billable')
->groupBy('t.project') ->groupBy('t.project')
->setParameter('billable', true, Types::BOOLEAN) ->setParameter('billable', true, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
; ;
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting\ProjectDateRange;
use App\Form\Type\CustomerType;
use App\Form\Type\MonthPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectDateRangeForm extends AbstractType
{
/**
* Simplify cross linking between pages by removing the block prefix.
*
* @return null|string
*/
public function getBlockPrefix()
{
return null;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('customer', CustomerType::class, [
'required' => false,
'label' => false,
'width' => false,
]);
$builder->add('month', MonthPickerType::class, [
'label' => false,
'view_timezone' => $options['timezone'],
'model_timezone' => $options['timezone'],
]);
$builder->add('includeNoBudget', CheckboxType::class, [
'required' => false,
'label' => 'label.includeNoBudget',
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProjectDateRangeQuery::class,
'timezone' => date_default_timezone_get(),
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting\ProjectDateRange;
use App\Entity\Customer;
use App\Entity\User;
final class ProjectDateRangeQuery
{
/**
* @var \DateTime
*/
private $month;
/**
* @var User|null
*/
private $user;
/**
* @var Customer|null
*/
private $customer;
private $includeNoBudget = false;
private $onlyWithRecords = false;
public function __construct(\DateTime $month, User $user)
{
$this->month = clone $month;
$this->user = $user;
}
public function isIncludeNoBudget(): bool
{
return $this->includeNoBudget;
}
public function setIncludeNoBudget(bool $includeNoBudget): void
{
$this->includeNoBudget = $includeNoBudget;
}
public function isOnlyWithRecords(): bool
{
return $this->onlyWithRecords;
}
public function setOnlyWithRecords(bool $onlyWithRecords): void
{
$this->onlyWithRecords = $onlyWithRecords;
}
public function getUser(): ?User
{
return $this->user;
}
public function getMonth(): \DateTime
{
return $this->month;
}
public function setMonth(\DateTime $month): void
{
$this->month = $month;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
}

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ final class ReportingService
$event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project')); $event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project'));
} }
if ($this->security->isGranted('budget_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')); $event->addReport(new Report('inactive_projects', 'report_project_inactive', 'report_inactive_project', 'project'));
} }

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,26 @@ class ActivityRepository extends EntityRepository
return $this->findBy(['project' => $project]); 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 * @param Activity $activity
* @throws ORMException * @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 * @param Activity $activity
* @return ActivityStatistic * @return ActivityStatistic
@@ -110,7 +131,7 @@ class ActivityRepository extends EntityRepository
$stats = new ActivityStatistic(); $stats = new ActivityStatistic();
if (null !== $timesheetResult) { if (null !== $timesheetResult) {
$stats->setRecordAmount($timesheetResult['amount']); $stats->setCounter($timesheetResult['amount']);
$stats->setRecordDuration($timesheetResult['duration']); $stats->setRecordDuration($timesheetResult['duration']);
$stats->setRecordRate($timesheetResult['rate']); $stats->setRecordRate($timesheetResult['rate']);
$stats->setRecordInternalRate($timesheetResult['internal_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 * @deprecated since 1.1 - use getQueryBuilderForFormType() instead - will be removed with 2.0
* @codeCoverageIgnore
*/ */
public function builderForEntityType($activity, $project) public function builderForEntityType($activity, $project)
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,26 @@ class ProjectRepository extends EntityRepository
return $project; 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 * @param Project $project
* @throws ORMException * @throws ORMException
@@ -79,6 +99,16 @@ class ProjectRepository extends EntityRepository
return $this->count([]); 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 public function getProjectStatistics(Project $project, ?DateTime $begin = null, ?DateTime $end = null): ProjectStatistic
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
@@ -103,7 +133,7 @@ class ProjectRepository extends EntityRepository
$stats = new ProjectStatistic(); $stats = new ProjectStatistic();
if (null !== $timesheetResult) { if (null !== $timesheetResult) {
$stats->setRecordAmount($timesheetResult['amount']); $stats->setCounter($timesheetResult['amount']);
$stats->setRecordDuration($timesheetResult['duration']); $stats->setRecordDuration($timesheetResult['duration']);
$stats->setRecordRate($timesheetResult['rate']); $stats->setRecordRate($timesheetResult['rate']);
$stats->setRecordInternalRate($timesheetResult['internal_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 * @deprecated since 1.1 - use getQueryBuilderForFormType() istead - will be removed with 2.0
* @codeCoverageIgnore
*/ */
public function builderForEntityType($project, $customer) public function builderForEntityType($project, $customer)
{ {

View File

@@ -28,7 +28,6 @@ use App\Repository\Query\TimesheetQuery;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
@@ -46,8 +45,17 @@ class TimesheetRepository extends EntityRepository
public const STATS_QUERY_USER = 'users'; public const STATS_QUERY_USER = 'users';
public const STATS_QUERY_AMOUNT = 'amount'; public const STATS_QUERY_AMOUNT = 'amount';
public const STATS_QUERY_ACTIVE = 'active'; 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'; 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 public function getRawData(Timesheet $id): array
{ {
$qb = $this->createQueryBuilder('t'); $qb = $this->createQueryBuilder('t');
@@ -56,17 +64,18 @@ class TimesheetRepository extends EntityRepository
't.rate', 't.rate',
't.duration', 't.duration',
't.hourlyRate', 't.hourlyRate',
't.billable',
'IDENTITY(p.customer) as customer', 'IDENTITY(p.customer) as customer',
'IDENTITY(t.project) as project', 'IDENTITY(t.project) as project',
'IDENTITY(t.activity) as activity', 'IDENTITY(t.activity) as activity',
'IDENTITY(t.user) as user' 'IDENTITY(t.user) as user'
]) ])
->leftJoin(Project::class, 'p', Join::WITH, 'p.id = t.project') ->leftJoin(Project::class, 'p', Join::WITH, 'p.id = t.project')
->andWhere('t.id = :id') ->andWhere($qb->expr()->eq('t.id', ':id'))
->setParameter('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 * @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*/ */
public function add(Timesheet $timesheet, int $maxRunningEntries) 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 Timesheet $entry
* @param bool $flush * @param bool $flush
* @return bool * @return bool
* @throws RepositoryException * @throws RepositoryException
* @throws \Doctrine\ORM\ORMException * @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException * @throws \Doctrine\ORM\OptimisticLockException
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/ */
public function stopRecording(Timesheet $entry, bool $flush = true) public function stopRecording(Timesheet $entry, bool $flush = true)
{ {
@@ -264,39 +276,26 @@ class TimesheetRepository extends EntityRepository
} }
/** /**
* @param string $select * @param string|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 DateTime|null $begin * @param DateTime|null $begin
* @param DateTime|null $end * @param DateTime|null $end
* @param User|null $user * @param User|null $user
* @return int|mixed * @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException * @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 = $this->getEntityManager()->createQueryBuilder();
$qb->select($select) $qb->from(Timesheet::class, 't');
->from(Timesheet::class, 't'); foreach ($selects as $s) {
$qb->addSelect($s);
}
if (!empty($begin)) { if (!empty($begin)) {
$qb $qb
@@ -315,36 +314,75 @@ class TimesheetRepository extends EntityRepository
->setParameter('user', $user); ->setParameter('user', $user);
} }
if (\is_array($select)) {
return $qb->getQuery()->getOneOrNullResult();
}
$result = $qb->getQuery()->getSingleScalarResult(); $result = $qb->getQuery()->getSingleScalarResult();
return empty($result) ? 0 : $result; 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); $allTimeData = $this->queryTimeRange([
$recordsTotal = $this->getStatistic(self::STATS_QUERY_AMOUNT, null, null, $user); 'COALESCE(SUM(t.duration), 0) as duration',
$rateTotal = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user); 'COALESCE(SUM(t.rate), 0) as rate',
$amountMonth = $this->queryThisMonth('COALESCE(SUM(t.rate), 0)', $user); 'COUNT(t.id) as amount'
$durationMonth = $this->queryThisMonth('COALESCE(SUM(t.duration), 0)', $user); ], null, null, $user);
$firstEntry = $this->getEntityManager()
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user') $timezone = new \DateTimeZone($user->getTimezone());
->setParameter('user', $user) $begin = new DateTime('first day of this month 00:00:00', $timezone);
->getSingleScalarResult(); $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 = new TimesheetStatistic();
$stats->setAmountTotal($rateTotal);
$stats->setDurationTotal($durationTotal); if ($bcSafe) {
$stats->setAmountThisMonth($amountMonth); $firstEntry = $this->getEntityManager()
$stats->setDurationThisMonth($durationMonth); ->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user AND 1=12')
$stats->setFirstEntry(new DateTime($firstEntry, new \DateTimeZone($user->getTimezone()))); ->setParameter('user', $user)
$stats->setRecordsTotal($recordsTotal); ->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; 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 $begin
* @param DateTime $end * @param DateTime $end
@@ -353,6 +391,8 @@ class TimesheetRepository extends EntityRepository
*/ */
public function getMonthlyStats(DateTime $begin, DateTime $end, ?User $user = null): array 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 */ /** @var Year[] $years */
$years = []; $years = [];
@@ -404,12 +444,25 @@ class TimesheetRepository extends EntityRepository
return $years; 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 private function getMonthlyStatsQuery(User $user = null, ?DateTime $begin = null, ?DateTime $end = null, ?bool $billable = null): QueryBuilder
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Timesheet::class, 't'); $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)) { if (!empty($begin)) {
$qb->andWhere($qb->expr()->gte('t.begin', ':from')); $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')); $or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->select('t, p, a, c') $qb->select('t, p, a, c')
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
->andWhere($qb->expr()->isNotNull('t.end')) ->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or) ->andWhere($or)
->orderBy('t.begin', 'DESC') ->orderBy('t.begin', 'DESC')
->setParameter('begin', $begin) ->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 User|null $user
* @param DateTime $begin * @param DateTime $begin
* @param DateTime $end * @param DateTime $end
@@ -646,6 +702,9 @@ class TimesheetRepository extends EntityRepository
} }
/** /**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*
* @param User $user * @param User $user
* @param int $hardLimit * @param int $hardLimit
* @param bool $flush * @param bool $flush
@@ -653,7 +712,6 @@ class TimesheetRepository extends EntityRepository
* @throws RepositoryException * @throws RepositoryException
* @throws \Doctrine\ORM\ORMException * @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException * @throws \Doctrine\ORM\OptimisticLockException
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/ */
public function stopActiveEntries(User $user, int $hardLimit, bool $flush = true) public function stopActiveEntries(User $user, int $hardLimit, bool $flush = true)
{ {
@@ -753,8 +811,7 @@ class TimesheetRepository extends EntityRepository
$qb $qb
->resetDQLPart('select') ->resetDQLPart('select')
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
// faster then using "distinct id", as the user field is a separate (and smaller) index ->select($qb->expr()->count('t.id'))
->select($qb->expr()->count('t.user'))
; ;
$counter = (int) $qb->getQuery()->getSingleScalarResult(); $counter = (int) $qb->getQuery()->getSingleScalarResult();

View File

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

View File

@@ -48,6 +48,8 @@ final class CurrentUser
return null; 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; $this->user = $user;
return $this->user; return $this->user;

View File

@@ -48,9 +48,14 @@ class DateTimeFactory
return $this->timezone; 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); $date->setTime(0, 0, 0);
return $date; return $date;
@@ -81,7 +86,7 @@ class DateTimeFactory
public function getEndOfWeek(?DateTime $date = null): DateTime public function getEndOfWeek(?DateTime $date = null): DateTime
{ {
if (null === $date) { if (null === $date) {
$date = $this->createDateTime('now'); $date = $this->createDateTime();
} }
$lastDay = $this->startOnSunday ? 6 : 7; $lastDay = $this->startOnSunday ? 6 : 7;
@@ -89,9 +94,14 @@ class DateTimeFactory
return $this->createWeekDateTime($date->format('o'), $date->format('W'), $lastDay, 23, 59, 59); 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); $date->setTime(23, 59, 59);
return $date; return $date;

View File

@@ -0,0 +1,247 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Model\MonthlyStatistic;
use App\Repository\TimesheetRepository;
use DateTime;
final class TimesheetStatisticService
{
/**
* @var TimesheetRepository
*/
private $repository;
public function __construct(TimesheetRepository $repository)
{
$this->repository = $repository;
}
/**
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return DailyStatistic[]
*/
public function getDailyStatistics(DateTime $begin, DateTime $end, array $users): array
{
/** @var DailyStatistic[] $stats */
$stats = [];
foreach ($users as $user) {
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = new DailyStatistic($begin, $end, $user);
}
}
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('t.billable as billable')
->addSelect('IDENTITY(t.user) as user')
->addSelect('DAY(t.date) as day')
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('user', $users)
->groupBy('year')
->addGroupBy('month')
->addGroupBy('day')
->addGroupBy('user')
->addGroupBy('billable')
;
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$day = $stats[$row['user']]->getDay($row['year'], $row['month'], $row['day']);
if ($day === null) {
// timezone differences
continue;
}
$day->setTotalDuration($day->getTotalDuration() + (int) $row['duration']);
$day->setTotalRate($day->getTotalRate() + (float) $row['rate']);
$day->setTotalInternalRate($day->getTotalInternalRate() + (float) $row['internalRate']);
if ($row['billable']) {
$day->setBillableRate((float) $row['rate']);
$day->setBillableDuration((int) $row['duration']);
}
}
return array_values($stats);
}
/**
* @internal only for core development
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return array<int, DailyStatistic[]>
*/
public function getDailyStatisticsGrouped(DateTime $begin, DateTime $end, array $users): array
{
/** @var DailyStatistic[] $stats */
$stats = [];
$usersById = [];
foreach ($users as $user) {
$usersById[$user->getId()] = $user;
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = [];
}
}
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.rate), 0.0) as rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('t.billable as billable')
->addSelect('IDENTITY(t.user) as user')
->addSelect('IDENTITY(t.project) as project')
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('DATE(t.date) as date')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('user', $users)
->groupBy('date')
->addGroupBy('project')
->addGroupBy('activity')
->addGroupBy('user')
->addGroupBy('billable')
;
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$uid = $row['user'];
$pid = $row['project'];
$aid = $row['activity'];
if (!isset($stats[$uid][$pid])) {
$stats[$uid][$pid] = ['project' => $pid, 'activities' => []];
}
if (!isset($stats[$uid][$pid]['activities'][$aid])) {
$stats[$uid][$pid]['activities'][$aid] = ['activity' => $aid, 'days' => new DailyStatistic($begin, $end, $usersById[$uid])];
}
/** @var DailyStatistic $days */
$days = $stats[$uid][$pid]['activities'][$aid]['days'];
$day = $days->getDayByReportDate($row['date']);
if ($day === null) {
// timezone differences
continue;
}
$day->setTotalDuration($day->getTotalDuration() + (int) $row['duration']);
$day->setTotalRate($day->getTotalRate() + (float) $row['rate']);
$day->setTotalInternalRate($day->getTotalInternalRate() + (float) $row['internalRate']);
if ($row['billable']) {
$day->setBillableRate((float) $row['rate']);
$day->setBillableDuration((int) $row['duration']);
}
}
return $stats;
}
public function findFirstRecordDate(User $user): ?DateTime
{
$result = $this->repository->createQueryBuilder('t')
->select('MIN(t.begin)')
->where('t.user = :user')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
if ($result === null) {
return null;
}
return new DateTime($result, new \DateTimeZone($user->getTimezone()));
}
/**
* Returns an array of Year statistics.
*
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @return MonthlyStatistic[]
*/
public function getMonthlyStats(DateTime $begin, DateTime $end, array $users): array
{
/** @var MonthlyStatistic[] $stats */
$stats = [];
foreach ($users as $user) {
if (!isset($stats[$user->getId()])) {
$stats[$user->getId()] = new MonthlyStatistic($begin, $end, $user);
}
}
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('t.billable as billable')
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->addSelect('IDENTITY(t.user) as user')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('user', $users)
->groupBy('year')
->addGroupBy('month')
->addGroupBy('user')
->addGroupBy('billable')
;
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
$month = $stats[$row['user']]->getMonth($row['year'], $row['month']);
if ($month === null) {
// might happen for the last month, which is accidentally queried due to timezone differences
continue;
}
$month->setTotalDuration($month->getTotalDuration() + (int) $row['duration']);
$month->setTotalRate($month->getTotalRate() + (float) $row['rate']);
$month->setTotalInternalRate($month->getTotalInternalRate() + (float) $row['internalRate']);
if ($row['billable']) {
$month->setBillableRate((float) $row['rate']);
$month->setBillableDuration((int) $row['duration']);
}
}
return array_values($stats);
}
}

View File

@@ -37,6 +37,7 @@ class UserDateTimeFactory extends DateTimeFactory
public function getTimezone(): DateTimeZone public function getTimezone(): DateTimeZone
{ {
if ($this->initializedFromUser === false) { 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(); $timezone = date_default_timezone_get();
$user = $this->user->getUser(); $user = $this->user->getUser();

View File

@@ -27,6 +27,7 @@ class Extensions extends AbstractExtension
public function getFilters() public function getFilters()
{ {
return [ return [
new TwigFilter('report_date', [$this, 'formatReportDate']),
new TwigFilter('docu_link', [$this, 'documentationLink']), new TwigFilter('docu_link', [$this, 'documentationLink']),
new TwigFilter('multiline_indent', [$this, 'multilineIndent']), new TwigFilter('multiline_indent', [$this, 'multilineIndent']),
new TwigFilter('color', [$this, 'color']), 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 public function getIsoDayByName(string $weekDay): int
{ {
$key = array_search( $key = array_search(

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More