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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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