diff --git a/config/services.yaml b/config/services.yaml
index 0912c8db..cdc1ea0f 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -132,7 +132,7 @@ services:
$roundingModes: !tagged timesheet.rounding_mode
$rules: '%kimai.timesheet.rounding%'
- App\Timesheet\Calculator\RateCalculator:
+ App\Timesheet\RateService:
arguments: ['%kimai.timesheet.rates%']
App\Timesheet\TrackingModeService:
diff --git a/src/Configuration/SystemConfiguration.php b/src/Configuration/SystemConfiguration.php
index 4d83cb6b..462ca8e5 100644
--- a/src/Configuration/SystemConfiguration.php
+++ b/src/Configuration/SystemConfiguration.php
@@ -131,6 +131,11 @@ class SystemConfiguration implements SystemBundleConfiguration
return (bool) $this->find('timesheet.rules.allow_future_times');
}
+ public function isTimesheetAllowOverbookingBudget(): bool
+ {
+ return (bool) $this->find('timesheet.rules.allow_overbooking_budget');
+ }
+
public function isTimesheetAllowOverlappingRecords(): bool
{
return (bool) $this->find('timesheet.rules.allow_overlapping_records');
diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php
index 88f75e5e..8f76e6c4 100644
--- a/src/Controller/SystemConfigurationController.php
+++ b/src/Controller/SystemConfigurationController.php
@@ -269,6 +269,10 @@ final class SystemConfigurationController extends AbstractController
->setName('timesheet.rules.allow_overlapping_records')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
+ (new Configuration())
+ ->setName('timesheet.rules.allow_overbooking_budget')
+ ->setType(CheckboxType::class)
+ ->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.lockdown_period_start')
->setOptions(['help' => $lockdownStartHelp])
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index c22d7113..15b20456 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -222,6 +222,9 @@ class Configuration implements ConfigurationInterface
->booleanNode('allow_future_times')
->defaultTrue()
->end()
+ ->booleanNode('allow_overbooking_budget')
+ ->defaultTrue()
+ ->end()
->booleanNode('allow_overlapping_records')
->defaultTrue()
->end()
diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php
index 8c3a4140..6eda2646 100644
--- a/src/Entity/Activity.php
+++ b/src/Entity/Activity.php
@@ -268,6 +268,11 @@ class Activity implements EntityWithMetaFields
return $this->budget;
}
+ public function hasBudget(): bool
+ {
+ return $this->budget > 0.00;
+ }
+
public function setTimeBudget(int $seconds): Activity
{
$this->timeBudget = $seconds;
@@ -280,6 +285,11 @@ class Activity implements EntityWithMetaFields
return $this->timeBudget;
}
+ public function hasTimeBudget(): bool
+ {
+ return $this->timeBudget > 0;
+ }
+
/**
* @return Collection|MetaTableTypeInterface[]
*/
diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php
index 276285e2..ba5a6dd4 100644
--- a/src/Entity/Customer.php
+++ b/src/Entity/Customer.php
@@ -538,6 +538,11 @@ class Customer implements EntityWithMetaFields
return $this->budget;
}
+ public function hasBudget(): bool
+ {
+ return $this->budget > 0.00;
+ }
+
public function setTimeBudget(int $seconds): Customer
{
$this->timeBudget = $seconds;
@@ -550,6 +555,11 @@ class Customer implements EntityWithMetaFields
return $this->timeBudget;
}
+ public function hasTimeBudget(): bool
+ {
+ return $this->timeBudget > 0;
+ }
+
/**
* @return Collection|MetaTableTypeInterface[]
*/
diff --git a/src/Entity/Project.php b/src/Entity/Project.php
index ec5eda44..36021557 100644
--- a/src/Entity/Project.php
+++ b/src/Entity/Project.php
@@ -432,6 +432,11 @@ class Project implements EntityWithMetaFields
return $this->budget;
}
+ public function hasBudget(): bool
+ {
+ return $this->budget > 0.00;
+ }
+
public function setTimeBudget(int $seconds): Project
{
$this->timeBudget = $seconds;
@@ -444,6 +449,11 @@ class Project implements EntityWithMetaFields
return $this->timeBudget;
}
+ public function hasTimeBudget(): bool
+ {
+ return $this->timeBudget > 0;
+ }
+
/**
* @return Collection|MetaTableTypeInterface[]
*/
diff --git a/src/Model/CustomerStatistic.php b/src/Model/CustomerStatistic.php
index f88e3ea2..e4c4d981 100644
--- a/src/Model/CustomerStatistic.php
+++ b/src/Model/CustomerStatistic.php
@@ -14,46 +14,32 @@ class CustomerStatistic extends TimesheetCountedStatistic
/**
* @var int
*/
- protected $activityAmount = 0;
+ private $activityAmount = 0;
/**
* @var int
*/
- protected $projectAmount = 0;
+ private $projectAmount = 0;
- /**
- * @return int
- */
- public function getActivityAmount()
+ public function getActivityAmount(): int
{
return $this->activityAmount;
}
- /**
- * @param int $activityAmount
- * @return $this
- */
- public function setActivityAmount($activityAmount)
+ public function setActivityAmount(int $activityAmount): CustomerStatistic
{
- $this->activityAmount = (int) $activityAmount;
+ $this->activityAmount = $activityAmount;
return $this;
}
- /**
- * @return int
- */
- public function getProjectAmount()
+ public function getProjectAmount(): int
{
return $this->projectAmount;
}
- /**
- * @param int $projectAmount
- * @return $this
- */
- public function setProjectAmount($projectAmount)
+ public function setProjectAmount(int $projectAmount): CustomerStatistic
{
- $this->projectAmount = (int) $projectAmount;
+ $this->projectAmount = $projectAmount;
return $this;
}
diff --git a/src/Model/ProjectStatistic.php b/src/Model/ProjectStatistic.php
index 19c9596c..4c4cb847 100644
--- a/src/Model/ProjectStatistic.php
+++ b/src/Model/ProjectStatistic.php
@@ -9,44 +9,21 @@
namespace App\Model;
-use App\Entity\Project;
-
class ProjectStatistic extends TimesheetCountedStatistic
{
- /**
- * @var Project
- */
- private $project;
/**
* @var int
*/
private $activityAmount = 0;
- public function __construct(Project $project)
- {
- $this->project = $project;
- }
-
- public function getProject(): Project
- {
- return $this->project;
- }
-
- /**
- * @return int
- */
- public function getActivityAmount()
+ public function getActivityAmount(): int
{
return $this->activityAmount;
}
- /**
- * @param int $activityAmount
- * @return ProjectStatistic
- */
- public function setActivityAmount($activityAmount)
+ public function setActivityAmount(int $activityAmount): ProjectStatistic
{
- $this->activityAmount = (int) $activityAmount;
+ $this->activityAmount = $activityAmount;
return $this;
}
diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php
index 73173450..652dfb5a 100644
--- a/src/Repository/ActivityRepository.php
+++ b/src/Repository/ActivityRepository.php
@@ -93,26 +93,26 @@ class ActivityRepository extends EntityRepository
*/
public function getActivityStatistics(Activity $activity)
{
- $stats = new ActivityStatistic();
-
$qb = $this->getEntityManager()->createQueryBuilder();
-
$qb
- ->addSelect('COUNT(t.id) as recordAmount')
- ->addSelect('SUM(t.duration) as recordDuration')
- ->addSelect('SUM(t.rate) as recordRate')
- ->addSelect('SUM(t.internalRate) as recordInternalRate')
->from(Timesheet::class, 't')
+ ->addSelect('COUNT(t.id) as amount')
+ ->addSelect('SUM(t.duration) as duration')
+ ->addSelect('SUM(t.rate) as rate')
+ ->addSelect('SUM(t.internalRate) as internal_rate')
->where('t.activity = :activity')
+ ->setParameter('activity', $activity)
;
- $timesheetResult = $qb->getQuery()->execute(['activity' => $activity], Query::HYDRATE_ARRAY);
+ $timesheetResult = $qb->getQuery()->getOneOrNullResult();
- if (isset($timesheetResult[0])) {
- $stats->setRecordAmount($timesheetResult[0]['recordAmount']);
- $stats->setRecordDuration($timesheetResult[0]['recordDuration']);
- $stats->setRecordRate($timesheetResult[0]['recordRate']);
- $stats->setRecordInternalRate($timesheetResult[0]['recordInternalRate']);
+ $stats = new ActivityStatistic();
+
+ if (null !== $timesheetResult) {
+ $stats->setRecordAmount($timesheetResult['amount']);
+ $stats->setRecordDuration($timesheetResult['duration']);
+ $stats->setRecordRate($timesheetResult['rate']);
+ $stats->setRecordInternalRate($timesheetResult['internal_rate']);
}
return $stats;
diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php
index 11408e3a..a7c64ad0 100644
--- a/src/Repository/CustomerRepository.php
+++ b/src/Repository/CustomerRepository.php
@@ -86,50 +86,56 @@ class CustomerRepository extends EntityRepository
*/
public function getCustomerStatistics(Customer $customer)
{
- $stats = new CustomerStatistic();
-
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
- ->addSelect('COUNT(t.id) as recordAmount')
- ->addSelect('SUM(t.duration) as recordDuration')
- ->addSelect('SUM(t.rate) as recordRate')
- ->addSelect('SUM(t.internalRate) as recordInternalRate')
->from(Timesheet::class, 't')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
+ ->addSelect('COUNT(t.id) as amount')
+ ->addSelect('SUM(t.duration) as duration')
+ ->addSelect('SUM(t.rate) as rate')
+ ->addSelect('SUM(t.internalRate) as internal_rate')
->andWhere('p.customer = :customer')
+ ->setParameter('customer', $customer)
;
- $timesheetResult = $qb->getQuery()->execute(['customer' => $customer], Query::HYDRATE_ARRAY);
- if (isset($timesheetResult[0])) {
- $stats->setRecordAmount($timesheetResult[0]['recordAmount']);
- $stats->setRecordDuration($timesheetResult[0]['recordDuration']);
- $stats->setRecordRate($timesheetResult[0]['recordRate']);
- $stats->setRecordInternalRate($timesheetResult[0]['recordInternalRate']);
+ $timesheetResult = $qb->getQuery()->getOneOrNullResult();
+
+ $stats = new CustomerStatistic();
+
+ if (null !== $timesheetResult) {
+ $stats->setRecordAmount($timesheetResult['amount']);
+ $stats->setRecordDuration($timesheetResult['duration']);
+ $stats->setRecordRate($timesheetResult['rate']);
+ $stats->setRecordInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
- ->select('COUNT(a.id) as activityAmount')
+ ->select('COUNT(a.id) as amount')
->from(Activity::class, 'a')
->join(Project::class, 'p', Query\Expr\Join::WITH, 'a.project = p.id')
->andWhere('a.project = p.id')
->andWhere('p.customer = :customer')
+ ->setParameter('customer', $customer)
;
- $activityResult = $qb->getQuery()->execute(['customer' => $customer], Query::HYDRATE_ARRAY);
- if (isset($activityResult[0])) {
- $stats->setActivityAmount($activityResult[0]['activityAmount']);
+ $activityResult = $qb->getQuery()->getOneOrNullResult();
+
+ if (null !== $activityResult) {
+ $stats->setActivityAmount($activityResult['amount']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
- $qb->select('COUNT(p.id) as projectAmount')
+ $qb->select('COUNT(p.id) as amount')
->from(Project::class, 'p')
->andWhere('p.customer = :customer')
+ ->setParameter('customer', $customer)
;
- $projectResult = $qb->getQuery()->execute(['customer' => $customer], Query::HYDRATE_ARRAY);
- if (isset($projectResult[0])) {
- $stats->setProjectAmount($projectResult[0]['projectAmount']);
+ $projectResult = $qb->getQuery()->getOneOrNullResult();
+
+ if (null !== $projectResult) {
+ $stats->setProjectAmount($projectResult['amount']);
}
return $stats;
diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php
index 93e80b54..d0307bb0 100644
--- a/src/Repository/ProjectRepository.php
+++ b/src/Repository/ProjectRepository.php
@@ -80,16 +80,13 @@ class ProjectRepository extends EntityRepository
public function getProjectStatistics(Project $project, ?DateTime $begin = null, ?DateTime $end = null): ProjectStatistic
{
- $stats = new ProjectStatistic($project);
-
$qb = $this->getEntityManager()->createQueryBuilder();
-
$qb
->from(Timesheet::class, 't')
- ->addSelect('COUNT(t.id) as recordAmount')
- ->addSelect('SUM(t.duration) as recordDuration')
- ->addSelect('SUM(t.rate) as recordRate')
- ->addSelect('SUM(t.internalRate) as recordInternalRate')
+ ->addSelect('COUNT(t.id) as amount')
+ ->addSelect('SUM(t.duration) as duration')
+ ->addSelect('SUM(t.rate) as rate')
+ ->addSelect('SUM(t.internalRate) as internal_rate')
->andWhere('t.project = :project')
->setParameter('project', $project)
;
@@ -98,31 +95,35 @@ class ProjectRepository extends EntityRepository
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin);
}
+
if (null !== $end) {
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
->setParameter('end', $end);
}
- $timesheetResult = $qb->getQuery()->getArrayResult();
+ $timesheetResult = $qb->getQuery()->getOneOrNullResult();
- if (isset($timesheetResult[0])) {
- $stats->setRecordAmount($timesheetResult[0]['recordAmount']);
- $stats->setRecordDuration($timesheetResult[0]['recordDuration']);
- $stats->setRecordRate($timesheetResult[0]['recordRate']);
- $stats->setRecordInternalRate($timesheetResult[0]['recordInternalRate']);
+ $stats = new ProjectStatistic();
+
+ if (null !== $timesheetResult) {
+ $stats->setRecordAmount($timesheetResult['amount']);
+ $stats->setRecordDuration($timesheetResult['duration']);
+ $stats->setRecordRate($timesheetResult['rate']);
+ $stats->setRecordInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
- $qb->select('COUNT(a.id) as activityAmount')
+ $qb
->from(Activity::class, 'a')
+ ->select('COUNT(a.id) as amount')
->andWhere('a.project = :project')
+ ->setParameter('project', $project)
;
- $resultActivities = $qb->getQuery()->execute(['project' => $project], Query::HYDRATE_ARRAY);
- if (isset($resultActivities[0])) {
- $resultActivities = $resultActivities[0];
+ $resultActivities = $qb->getQuery()->getOneOrNullResult();
- $stats->setActivityAmount($resultActivities['activityAmount']);
+ if (null !== $resultActivities) {
+ $stats->setActivityAmount($resultActivities['amount']);
}
return $stats;
diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php
index 65dde32b..4bdc0410 100644
--- a/src/Repository/TimesheetRepository.php
+++ b/src/Repository/TimesheetRepository.php
@@ -11,6 +11,7 @@ namespace App\Repository;
use App\Entity\ActivityRate;
use App\Entity\CustomerRate;
+use App\Entity\Project;
use App\Entity\ProjectRate;
use App\Entity\RateInterface;
use App\Entity\Team;
@@ -26,7 +27,9 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use DateInterval;
use DateTime;
+use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository;
+use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
@@ -45,6 +48,27 @@ class TimesheetRepository extends EntityRepository
public const STATS_QUERY_ACTIVE = 'active';
public const STATS_QUERY_MONTHLY = 'monthly';
+ public function getRawData(Timesheet $id): array
+ {
+ $qb = $this->createQueryBuilder('t');
+ $qb
+ ->select([
+ 't.rate',
+ 't.duration',
+ 't.hourlyRate',
+ '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')
+ ->setParameter('id', $id)
+ ;
+
+ return $qb->getQuery()->getSingleResult(AbstractQuery::HYDRATE_ARRAY);
+ }
+
/**
* @param mixed $id
* @param null $lockMode
diff --git a/src/Timesheet/Calculator/RateCalculator.php b/src/Timesheet/Calculator/RateCalculator.php
index fb64754c..d5e40ade 100644
--- a/src/Timesheet/Calculator/RateCalculator.php
+++ b/src/Timesheet/Calculator/RateCalculator.php
@@ -10,12 +10,9 @@
namespace App\Timesheet\Calculator;
use App\Entity\Rate;
-use App\Entity\RateInterface;
use App\Entity\Timesheet;
-use App\Entity\UserPreference;
-use App\Repository\TimesheetRepository;
use App\Timesheet\CalculatorInterface;
-use App\Timesheet\Util;
+use App\Timesheet\RateService;
/**
* Implementation to calculate the rate for a timesheet record.
@@ -23,136 +20,28 @@ use App\Timesheet\Util;
class RateCalculator implements CalculatorInterface
{
/**
- * @var array
+ * @var RateService
*/
- private $rates;
- /**
- * @var TimesheetRepository
- */
- private $repository;
+ private $service;
- public function __construct(array $rates, TimesheetRepository $repository)
+ public function __construct(RateService $service)
{
- $this->rates = $rates;
- $this->repository = $repository;
+ $this->service = $service;
}
- /**
- * @param Timesheet $record
- */
public function calculate(Timesheet $record)
{
- if (null === $record->getEnd()) {
- $record->setRate(0);
- $record->setInternalRate(0);
+ $rate = $this->service->calculate($record);
- return;
+ $record->setRate($rate->getRate());
+ $record->setInternalRate($rate->getInternalRate());
+
+ if ($rate->getHourlyRate() !== null) {
+ $record->setHourlyRate($rate->getHourlyRate());
}
- $fixedRate = $record->getFixedRate();
- $hourlyRate = $record->getHourlyRate();
- $fixedInternalRate = null;
- $internalRate = null;
-
- $rate = $this->getBestFittingRate($record);
-
- if (null !== $rate) {
- if ($rate->isFixed()) {
- $fixedRate = $fixedRate ?? $rate->getRate();
- $fixedInternalRate = $rate->getRate();
- if (null !== $rate->getInternalRate()) {
- $fixedInternalRate = $rate->getInternalRate();
- }
- } else {
- $hourlyRate = $hourlyRate ?? $rate->getRate();
- $internalRate = $rate->getRate();
- if (null !== $rate->getInternalRate()) {
- $internalRate = $rate->getInternalRate();
- }
- }
+ if ($rate->getFixedRate() !== null) {
+ $record->setFixedRate($rate->getFixedRate());
}
-
- if (null !== $fixedRate) {
- $record->setFixedRate($fixedRate);
- $record->setRate($fixedRate);
- if (null === $fixedInternalRate) {
- $fixedInternalRate = (float) $record->getUser()->getPreferenceValue(UserPreference::INTERNAL_RATE, $fixedRate);
- }
- $record->setInternalRate($fixedInternalRate);
-
- return;
- }
-
- // user preferences => fallback if nothing else was configured
- if (null === $hourlyRate) {
- $hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0.00);
- }
- if (null === $internalRate) {
- $internalRate = $record->getUser()->getPreferenceValue(UserPreference::INTERNAL_RATE, 0.00);
- if (null === $internalRate) {
- $internalRate = $hourlyRate;
- } else {
- $internalRate = (float) $internalRate;
- }
- }
-
- $factor = $this->getRateFactor($record);
-
- $factoredHourlyRate = (float) ($hourlyRate * $factor);
- $factoredInternalRate = (float) ($internalRate * $factor);
- $totalRate = 0;
- $totalInternalRate = 0;
- if (null !== $record->getDuration()) {
- $totalRate = Util::calculateRate($factoredHourlyRate, $record->getDuration());
- $totalInternalRate = Util::calculateRate($factoredInternalRate, $record->getDuration());
- }
- $record->setHourlyRate($factoredHourlyRate);
- $record->setInternalRate($totalInternalRate);
- $record->setRate($totalRate);
- }
-
- private function getBestFittingRate(Timesheet $timesheet): ?RateInterface
- {
- $rates = $this->repository->findMatchingRates($timesheet);
- /** @var RateInterface[] $sorted */
- $sorted = [];
- foreach ($rates as $rate) {
- $score = $rate->getScore();
- if (null !== $rate->getUser() && $timesheet->getUser() === $rate->getUser()) {
- ++$score;
- }
-
- $sorted[$score] = $rate;
- }
-
- if (!empty($sorted)) {
- ksort($sorted);
-
- return end($sorted);
- }
-
- return null;
- }
-
- /**
- * @param Timesheet $record
- * @return float
- */
- protected function getRateFactor(Timesheet $record)
- {
- $factor = 0;
- foreach ($this->rates as $rateFactor) {
- $weekday = $record->getEnd()->format('l');
- $days = array_map('strtolower', $rateFactor['days']);
- if (\in_array(strtolower($weekday), $days)) {
- $factor += $rateFactor['factor'];
- }
- }
-
- if ($factor <= 0) {
- $factor = 1;
- }
-
- return $factor;
}
}
diff --git a/src/Timesheet/Rate.php b/src/Timesheet/Rate.php
new file mode 100644
index 00000000..53a32b5b
--- /dev/null
+++ b/src/Timesheet/Rate.php
@@ -0,0 +1,58 @@
+rate = $rate;
+ $this->internalRate = $internalRate;
+ $this->fixedRate = $fixedRate;
+ $this->hourlyRate = $hourlyRate;
+ }
+
+ public function getRate(): float
+ {
+ return $this->rate;
+ }
+
+ public function getInternalRate(): float
+ {
+ return $this->internalRate;
+ }
+
+ public function getFixedRate(): ?float
+ {
+ return $this->fixedRate;
+ }
+
+ public function getHourlyRate(): ?float
+ {
+ return $this->hourlyRate;
+ }
+}
diff --git a/src/Timesheet/RateService.php b/src/Timesheet/RateService.php
new file mode 100644
index 00000000..96e23179
--- /dev/null
+++ b/src/Timesheet/RateService.php
@@ -0,0 +1,144 @@
+rates = $rates;
+ $this->repository = $repository;
+ }
+
+ public function calculate(Timesheet $record): Rate
+ {
+ if (null === $record->getEnd()) {
+ return new Rate(0.00, 0.00);
+ }
+
+ $fixedRate = $record->getFixedRate();
+ $hourlyRate = $record->getHourlyRate();
+ $fixedInternalRate = null;
+ $internalRate = null;
+
+ $rate = $this->getBestFittingRate($record);
+
+ if (null !== $rate) {
+ if ($rate->isFixed()) {
+ $fixedRate = $fixedRate ?? $rate->getRate();
+ $fixedInternalRate = $rate->getRate();
+ if (null !== $rate->getInternalRate()) {
+ $fixedInternalRate = $rate->getInternalRate();
+ }
+ } else {
+ $hourlyRate = $hourlyRate ?? $rate->getRate();
+ $internalRate = $rate->getRate();
+ if (null !== $rate->getInternalRate()) {
+ $internalRate = $rate->getInternalRate();
+ }
+ }
+ }
+
+ if (null !== $fixedRate) {
+ if (null === $fixedInternalRate) {
+ $fixedInternalRate = (float) $record->getUser()->getPreferenceValue(UserPreference::INTERNAL_RATE, $fixedRate);
+ }
+
+ return new Rate($fixedRate, $fixedInternalRate, null, $fixedRate);
+ }
+
+ // user preferences => fallback if nothing else was configured
+ if (null === $hourlyRate) {
+ $hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0.00);
+ }
+
+ if (null === $internalRate) {
+ $internalRate = $record->getUser()->getPreferenceValue(UserPreference::INTERNAL_RATE, 0.00);
+ if (null === $internalRate) {
+ $internalRate = $hourlyRate;
+ } else {
+ $internalRate = (float) $internalRate;
+ }
+ }
+
+ $factor = $this->getRateFactor($record);
+
+ $factoredHourlyRate = (float) ($hourlyRate * $factor);
+ $factoredInternalRate = (float) ($internalRate * $factor);
+ $totalRate = 0;
+ $totalInternalRate = 0;
+
+ if (null !== $record->getDuration()) {
+ $totalRate = Util::calculateRate($factoredHourlyRate, $record->getDuration());
+ $totalInternalRate = Util::calculateRate($factoredInternalRate, $record->getDuration());
+ }
+
+ return new Rate($totalRate, $totalInternalRate, $factoredHourlyRate, null);
+ }
+
+ private function getBestFittingRate(Timesheet $timesheet): ?RateInterface
+ {
+ $rates = $this->repository->findMatchingRates($timesheet);
+ /** @var RateInterface[] $sorted */
+ $sorted = [];
+ foreach ($rates as $rate) {
+ $score = $rate->getScore();
+ if (null !== $rate->getUser() && $timesheet->getUser() === $rate->getUser()) {
+ ++$score;
+ }
+
+ $sorted[$score] = $rate;
+ }
+
+ if (!empty($sorted)) {
+ ksort($sorted);
+
+ return end($sorted);
+ }
+
+ return null;
+ }
+
+ private function getRateFactor(Timesheet $record): float
+ {
+ $factor = 0.00;
+
+ foreach ($this->rates as $rateFactor) {
+ $weekday = $record->getEnd()->format('l');
+ $days = array_map('strtolower', $rateFactor['days']);
+ if (\in_array(strtolower($weekday), $days)) {
+ $factor += $rateFactor['factor'];
+ }
+ }
+
+ if ($factor <= 0) {
+ $factor = 1.00;
+ }
+
+ return (float) $factor;
+ }
+}
diff --git a/src/Timesheet/RateServiceInterface.php b/src/Timesheet/RateServiceInterface.php
new file mode 100644
index 00000000..dd360146
--- /dev/null
+++ b/src/Timesheet/RateServiceInterface.php
@@ -0,0 +1,20 @@
+configuration = $configuration;
+ $this->customerRepository = $customerRepository;
+ $this->projectRepository = $projectRepository;
+ $this->activityRepository = $activityRepository;
+ $this->timesheetRepository = $timesheetRepository;
+ $this->rateService = $rateService;
+ }
+
+ /**
+ * @param Timesheet $timesheet
+ * @param Constraint $constraint
+ */
+ public function validate($timesheet, Constraint $constraint)
+ {
+ if (!($constraint instanceof TimesheetBudgetUsedConstraint)) {
+ throw new UnexpectedTypeException($constraint, TimesheetBudgetUsedConstraint::class);
+ }
+
+ if (!\is_object($timesheet) || !($timesheet instanceof Timesheet)) {
+ throw new UnexpectedTypeException($timesheet, Timesheet::class);
+ }
+
+ if ($this->configuration->isTimesheetAllowOverbookingBudget()) {
+ return;
+ }
+
+ if ($this->context->getViolations()->count() > 0) {
+ return;
+ }
+
+ // we can only work with stopped entries
+ if (null === $timesheet->getEnd() || null === $timesheet->getUser() || null === $timesheet->getProject()) {
+ return;
+ }
+
+ $duration = $timesheet->getDuration();
+ if (null === $duration || 0 === $duration) {
+ $duration = $timesheet->getEnd()->getTimestamp() - $timesheet->getBegin()->getTimestamp();
+ }
+
+ $timeRate = $this->rateService->calculate($timesheet);
+ $rate = $timeRate->getRate();
+
+ $activityDuration = $duration;
+ $activityRate = $rate;
+ $projectDuration = $duration;
+ $projectRate = $rate;
+ $customerDuration = $duration;
+ $customerRate = $rate;
+
+ if ($timesheet->getId() !== null) {
+ $rawData = $this->timesheetRepository->getRawData($timesheet);
+
+ // if an existing entry was updated, but duration and rate were not changed: do not validate
+ // this could for example happen if overbooking config was recently activated
+ if ($duration === $rawData['duration'] && $rate === $rawData['rate']) {
+ return;
+ }
+
+ // the duration of an existing entry could be increased or lowered
+ $activityId = (int) $rawData['activity'];
+ $projectId = (int) $rawData['project'];
+ $customerId = (int) $rawData['customer'];
+
+ if (null !== $timesheet->getActivity() && $activityId === $timesheet->getActivity()->getId()) {
+ $activityDuration -= $rawData['duration'];
+ $activityRate -= $rawData['rate'];
+ }
+
+ if ($projectId === $timesheet->getProject()->getId()) {
+ $projectDuration -= $rawData['duration'];
+ $projectRate -= $rawData['rate'];
+ }
+
+ if ($customerId === $timesheet->getProject()->getCustomer()->getId()) {
+ $customerDuration -= $rawData['duration'];
+ $customerRate -= $rawData['rate'];
+ }
+ }
+
+ if (null !== $timesheet->getActivity() && $this->checkActivity($constraint, $timesheet, $activityDuration, $activityRate)) {
+ return;
+ }
+
+ if ($this->checkProject($constraint, $timesheet, $projectDuration, $projectRate)) {
+ return;
+ }
+
+ if ($this->checkCustomer($constraint, $timesheet, $customerDuration, $customerRate)) {
+ return;
+ }
+ }
+
+ private function checkActivity(TimesheetBudgetUsedConstraint $constraint, Timesheet $timesheet, int $duration, float $rate): bool
+ {
+ $activity = $timesheet->getActivity();
+
+ if (!$activity->hasBudget() && !$activity->hasTimeBudget()) {
+ return false;
+ }
+
+ $stat = $this->activityRepository->getActivityStatistics($activity);
+
+ $fullRate = ($stat->getRecordRate() + $rate);
+
+ if ($activity->hasBudget() && $fullRate > $activity->getBudget()) {
+ $this->addBudgetViolation($constraint, $timesheet, 'activity', $activity->getBudget(), $stat->getRecordRate());
+
+ return true;
+ }
+
+ $fullDuration = ($stat->getRecordDuration() + $duration);
+
+ if ($activity->hasTimeBudget() && $fullDuration > $activity->getTimeBudget()) {
+ $this->addTimeBudgetViolation($constraint, 'activity', $activity->getTimeBudget(), $stat->getRecordDuration());
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function checkProject(TimesheetBudgetUsedConstraint $constraint, Timesheet $timesheet, int $duration, float $rate): bool
+ {
+ $project = $timesheet->getProject();
+
+ if (!$project->hasBudget() && !$project->hasTimeBudget()) {
+ return false;
+ }
+
+ $stat = $this->projectRepository->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(TimesheetBudgetUsedConstraint $constraint, Timesheet $timesheet, int $duration, float $rate): bool
+ {
+ $customer = $timesheet->getProject()->getCustomer();
+
+ if (!$customer->hasBudget() && !$customer->hasTimeBudget()) {
+ return false;
+ }
+
+ $stat = $this->customerRepository->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 false;
+ }
+
+ private function addBudgetViolation(TimesheetBudgetUsedConstraint $constraint, Timesheet $timesheet, string $field, float $budget, float $rate)
+ {
+ // using the locale of the assigned user is not the best solution, but allows to be independent from the request stack
+ $helper = new LocaleHelper($timesheet->getUser()->getLanguage());
+ $currency = $timesheet->getProject()->getCustomer()->getCurrency();
+
+ $free = $budget - $rate;
+ $free = $free > 0 ? $free : 0;
+
+ $this->context->buildViolation($constraint->messageRate)
+ ->atPath($field)
+ ->setTranslationDomain('validators')
+ ->setParameters([
+ '%used%' => $helper->money($rate, $currency),
+ '%budget%' => $helper->money($budget, $currency),
+ '%free%' => $helper->money($free, $currency)
+ ])
+ ->addViolation()
+ ;
+ }
+
+ private function addTimeBudgetViolation(TimesheetBudgetUsedConstraint $constraint, string $field, int $budget, int $duration)
+ {
+ $durationFormat = new Duration();
+
+ $free = $budget - $duration;
+ $free = $free > 0 ? $free : 0;
+
+ $this->context->buildViolation($constraint->messageTime)
+ ->atPath($field)
+ ->setTranslationDomain('validators')
+ ->setParameters([
+ '%used%' => $durationFormat->format($duration),
+ '%budget%' => $durationFormat->format($budget),
+ '%free%' => $durationFormat->format($free)
+ ])
+ ->addViolation()
+ ;
+ }
+}
diff --git a/tests/Controller/SystemConfigurationControllerTest.php b/tests/Controller/SystemConfigurationControllerTest.php
index 261a934a..be1915ee 100644
--- a/tests/Controller/SystemConfigurationControllerTest.php
+++ b/tests/Controller/SystemConfigurationControllerTest.php
@@ -101,6 +101,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
['name' => 'timesheet.active_entries.default_begin', 'value' => '23:59'],
['name' => 'timesheet.rules.allow_future_times', 'value' => false],
['name' => 'timesheet.rules.allow_overlapping_records', 'value' => false],
+ ['name' => 'timesheet.rules.allow_overbooking_budget', 'value' => false],
['name' => 'timesheet.rules.lockdown_period_start', 'value' => null],
['name' => 'timesheet.rules.lockdown_period_end', 'value' => null],
['name' => 'timesheet.rules.lockdown_grace_period', 'value' => null],
@@ -136,6 +137,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
['name' => 'timesheet.active_entries.default_begin', 'value' => '23:59'],
['name' => 'timesheet.rules.allow_future_times', 'value' => 1],
['name' => 'timesheet.rules.allow_overlapping_records', 'value' => 1],
+ ['name' => 'timesheet.rules.allow_overbooking_budget', 'value' => 1],
['name' => 'timesheet.rules.lockdown_period_start', 'value' => 'first day of last month'],
['name' => 'timesheet.rules.lockdown_period_end', 'value' => 'first day of last month'],
['name' => 'timesheet.rules.lockdown_grace_period', 'value' => '+10 days'],
@@ -146,8 +148,8 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
],
[
'#system_configuration_form_timesheet_configuration_0_value', // mode
- '#system_configuration_form_timesheet_configuration_7_value', // hard_limit
- '#system_configuration_form_timesheet_configuration_8_value', // soft_limit
+ '#system_configuration_form_timesheet_configuration_8_value', // hard_limit
+ '#system_configuration_form_timesheet_configuration_9_value', // soft_limit
],
true
);
diff --git a/tests/Controller/TimesheetControllerTest.php b/tests/Controller/TimesheetControllerTest.php
index 8de64993..dd035113 100644
--- a/tests/Controller/TimesheetControllerTest.php
+++ b/tests/Controller/TimesheetControllerTest.php
@@ -9,10 +9,14 @@
namespace App\Tests\Controller;
+use App\Entity\Activity;
+use App\Entity\Configuration;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Form\Type\DateRangeType;
+use App\Repository\ConfigurationRepository;
+use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\TimesheetTestMetaFieldSubscriberMock;
@@ -369,6 +373,7 @@ class TimesheetControllerTest extends ControllerBaseTest
['name' => 'timesheet.active_entries.default_begin', 'value' => '08:00'],
['name' => 'timesheet.rules.allow_future_times', 'value' => true],
['name' => 'timesheet.rules.allow_overlapping_records', 'value' => false],
+ ['name' => 'timesheet.rules.allow_overbooking_budget', 'value' => true],
['name' => 'timesheet.rules.lockdown_period_start', 'value' => null],
['name' => 'timesheet.rules.lockdown_period_end', 'value' => null],
['name' => 'timesheet.rules.lockdown_grace_period', 'value' => null],
@@ -406,6 +411,58 @@ class TimesheetControllerTest extends ControllerBaseTest
);
}
+ public function testCreateActionWithOverbookedActivity()
+ {
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
+
+ $fixture = new ActivityFixtures();
+ $fixture->setAmount(1);
+ $fixture->setIsGlobal(true);
+ $fixture->setIsVisible(true);
+ $fixture->setCallback(function (Activity $activity) {
+ $activity->setBudget(1000);
+ $activity->setTimeBudget(3600);
+ });
+ $activities = $this->importFixture($fixture);
+ /** @var Activity $activity */
+ $activity = $activities[0];
+
+ $fixture = new TimesheetFixtures();
+ $fixture->setAmount(1);
+ $fixture->setActivities([$activity]);
+ $fixture->setUser($this->getUserByRole(User::ROLE_USER));
+ $timesheets = $this->importFixture($fixture);
+ $id = $timesheets[0]->getId();
+
+ $this->request($client, '/timesheet/' . $id . '/edit');
+
+ $response = $client->getResponse();
+ $this->assertTrue($response->isSuccessful());
+
+ /** @var ConfigurationRepository $repository */
+ $repository = $this->getEntityManager()->getRepository(Configuration::class);
+ $config = new Configuration();
+ $config->setName('timesheet.rules.allow_overbooking_budget');
+ $config->setValue(false);
+ $repository->saveConfiguration($config);
+
+ $this->assertHasValidationError(
+ $client,
+ '/timesheet/' . $id . '/edit',
+ 'form[name=timesheet_edit_form]',
+ [
+ 'timesheet_edit_form' => [
+ 'hourlyRate' => 100,
+ 'begin' => '2020-02-18 01:00',
+ 'end' => '2020-02-18 02:10',
+ 'project' => 1,
+ 'activity' => $activity->getId(),
+ ]
+ ],
+ ['#timesheet_edit_form_activity']
+ );
+ }
+
public function testCreateActionWithBeginAndEndAndTagValues()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
diff --git a/tests/DependencyInjection/AppExtensionTest.php b/tests/DependencyInjection/AppExtensionTest.php
index a74e8ec8..b83af0e9 100644
--- a/tests/DependencyInjection/AppExtensionTest.php
+++ b/tests/DependencyInjection/AppExtensionTest.php
@@ -205,6 +205,7 @@ class AppExtensionTest extends TestCase
'lockdown_period_start' => null,
'lockdown_period_end' => null,
'lockdown_grace_period' => null,
+ 'allow_overbooking_budget' => true,
],
'default_begin' => 'now',
'duration_increment' => null,
diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php
index 30824d9a..04003c3e 100644
--- a/tests/DependencyInjection/ConfigurationTest.php
+++ b/tests/DependencyInjection/ConfigurationTest.php
@@ -285,6 +285,7 @@ class ConfigurationTest extends TestCase
'lockdown_period_start' => null,
'lockdown_period_end' => null,
'lockdown_grace_period' => null,
+ 'allow_overbooking_budget' => true,
],
'duration_increment' => null,
'time_increment' => null,
diff --git a/tests/Entity/ActivityTest.php b/tests/Entity/ActivityTest.php
index e2bccd21..7e1dd261 100644
--- a/tests/Entity/ActivityTest.php
+++ b/tests/Entity/ActivityTest.php
@@ -38,6 +38,8 @@ class ActivityTest extends TestCase
self::assertFalse($sut->hasColor());
$this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget());
+ self::assertFalse($sut->hasBudget());
+ self::assertFalse($sut->hasTimeBudget());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
@@ -68,9 +70,11 @@ class ActivityTest extends TestCase
$this->assertInstanceOf(Activity::class, $sut->setBudget(12345.67));
$this->assertEquals(12345.67, $sut->getBudget());
+ self::assertTrue($sut->hasBudget());
$this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget());
+ self::assertTrue($sut->hasTimeBudget());
$this->assertTrue($sut->isGlobal());
$this->assertInstanceOf(Activity::class, $sut->setProject(new Project()));
diff --git a/tests/Entity/CustomerTest.php b/tests/Entity/CustomerTest.php
index bb2cb15c..e2ae5f7c 100644
--- a/tests/Entity/CustomerTest.php
+++ b/tests/Entity/CustomerTest.php
@@ -51,6 +51,8 @@ class CustomerTest extends TestCase
self::assertFalse($sut->hasColor());
self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget());
+ self::assertFalse($sut->hasBudget());
+ self::assertFalse($sut->hasTimeBudget());
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count());
self::assertNull($sut->getMetaField('foo'));
@@ -103,9 +105,11 @@ class CustomerTest extends TestCase
self::assertInstanceOf(Customer::class, $sut->setBudget(12345.67));
self::assertEquals(12345.67, $sut->getBudget());
+ self::assertTrue($sut->hasBudget());
self::assertInstanceOf(Customer::class, $sut->setTimeBudget(937321));
self::assertEquals(937321, $sut->getTimeBudget());
+ self::assertTrue($sut->hasTimeBudget());
self::assertInstanceOf(Customer::class, $sut->setVatId('ID 1234567890'));
self::assertEquals('ID 1234567890', $sut->getVatId());
diff --git a/tests/Entity/ProjectTest.php b/tests/Entity/ProjectTest.php
index 334585dc..e0a932a4 100644
--- a/tests/Entity/ProjectTest.php
+++ b/tests/Entity/ProjectTest.php
@@ -41,6 +41,8 @@ class ProjectTest extends TestCase
self::assertFalse($sut->hasColor());
self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget());
+ self::assertFalse($sut->hasBudget());
+ self::assertFalse($sut->hasTimeBudget());
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count());
self::assertNull($sut->getMetaField('foo'));
@@ -95,9 +97,11 @@ class ProjectTest extends TestCase
self::assertInstanceOf(Project::class, $sut->setBudget(12345.67));
self::assertEquals(12345.67, $sut->getBudget());
+ self::assertTrue($sut->hasBudget());
self::assertInstanceOf(Project::class, $sut->setTimeBudget(937321));
self::assertEquals(937321, $sut->getTimeBudget());
+ self::assertTrue($sut->hasTimeBudget());
}
public function testMetaFields()
diff --git a/tests/Model/ProjectStatisticTest.php b/tests/Model/ProjectStatisticTest.php
index 25a76825..3ed058a1 100644
--- a/tests/Model/ProjectStatisticTest.php
+++ b/tests/Model/ProjectStatisticTest.php
@@ -9,7 +9,6 @@
namespace App\Tests\Model;
-use App\Entity\Project;
use App\Model\ProjectStatistic;
use PHPUnit\Framework\TestCase;
@@ -20,7 +19,7 @@ class ProjectStatisticTest extends TestCase
{
public function testDefaultValues()
{
- $sut = new ProjectStatistic(new Project());
+ $sut = new ProjectStatistic();
self::assertEquals(0, $sut->getActivityAmount());
self::assertEquals(0, $sut->getRecordAmount());
self::assertEquals(0, $sut->getRecordDuration());
@@ -28,8 +27,7 @@ class ProjectStatisticTest extends TestCase
public function testSetter()
{
- $project = new Project();
- $sut = new ProjectStatistic($project);
+ $sut = new ProjectStatistic();
$sut->setRecordAmount(7654);
$sut->setRecordDuration(826);
$sut->setActivityAmount(13);
@@ -37,6 +35,5 @@ class ProjectStatisticTest extends TestCase
self::assertEquals(13, $sut->getActivityAmount());
self::assertEquals(7654, $sut->getRecordAmount());
self::assertEquals(826, $sut->getRecordDuration());
- self::assertSame($project, $sut->getProject());
}
}
diff --git a/tests/Timesheet/Calculator/RateCalculatorTest.php b/tests/Timesheet/Calculator/RateCalculatorTest.php
index ab13b81b..fde21935 100644
--- a/tests/Timesheet/Calculator/RateCalculatorTest.php
+++ b/tests/Timesheet/Calculator/RateCalculatorTest.php
@@ -20,6 +20,7 @@ use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\Calculator\RateCalculator;
+use App\Timesheet\RateService;
use PHPUnit\Framework\TestCase;
/**
@@ -46,7 +47,7 @@ class RateCalculatorTest extends TestCase
$record->setActivity(new Activity());
$record->setUser($this->getTestUser());
- $sut = new RateCalculator([], $this->getRateRepositoryMock());
+ $sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock()));
$sut->calculate($record);
$this->assertEquals(50, $record->getRate());
}
@@ -62,7 +63,7 @@ class RateCalculatorTest extends TestCase
$record->setActivity(new Activity());
$record->setUser($this->getTestUser());
- $sut = new RateCalculator([], $this->getRateRepositoryMock());
+ $sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock()));
$sut->calculate($record);
$this->assertEquals(10, $record->getRate());
}
@@ -173,7 +174,7 @@ class RateCalculatorTest extends TestCase
$rates[] = $rate;
}
- $sut = new RateCalculator([], $this->getRateRepositoryMock($rates));
+ $sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock($rates)));
$sut->calculate($timesheet);
$this->assertEquals($expectedRate, $timesheet->getRate());
$this->assertEquals($expectedInternalRate, $timesheet->getInternalRate());
@@ -207,7 +208,7 @@ class RateCalculatorTest extends TestCase
$this->assertEquals(0, $record->getRate());
- $sut = new RateCalculator([], $this->getRateRepositoryMock());
+ $sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock()));
$sut->calculate($record);
$this->assertEquals(0, $record->getRate());
}
@@ -233,7 +234,7 @@ class RateCalculatorTest extends TestCase
$record->setEnd($end);
- $sut = new RateCalculator($rules, $this->getRateRepositoryMock());
+ $sut = new RateCalculator(new RateService($rules, $this->getRateRepositoryMock()));
$sut->calculate($record);
$this->assertEquals($expectedRate, $record->getRate());
diff --git a/tests/Timesheet/RateServiceTest.php b/tests/Timesheet/RateServiceTest.php
new file mode 100644
index 00000000..5a7aae65
--- /dev/null
+++ b/tests/Timesheet/RateServiceTest.php
@@ -0,0 +1,283 @@
+getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
+ if (!empty($rates)) {
+ $mock->expects($this->any())->method('findMatchingRates')->willReturn($rates);
+ }
+
+ return $mock;
+ }
+
+ public function testCalculateWithTimesheetHourlyRate()
+ {
+ $record = new Timesheet();
+ $record->setEnd(new \DateTime());
+ $record->setDuration(1800);
+ $record->setHourlyRate(100);
+ $record->setActivity(new Activity());
+ $record->setUser($this->getTestUser());
+
+ $sut = new RateService([], $this->getRateRepositoryMock());
+ $rate = $sut->calculate($record);
+ $this->assertEquals(50, $rate->getRate());
+ }
+
+ public function testCalculateWithTimesheetFixedRate()
+ {
+ $record = new Timesheet();
+ $record->setEnd(new \DateTime());
+ $record->setDuration(1800);
+ $record->setFixedRate(10);
+ // make sure that fixed rate is always applied, even if hourly rate is set
+ $record->setHourlyRate(99);
+ $record->setActivity(new Activity());
+ $record->setUser($this->getTestUser());
+
+ $sut = new RateService([], $this->getRateRepositoryMock());
+ $rate = $sut->calculate($record);
+ $this->assertEquals(10, $rate->getRate());
+ }
+
+ public function getRateTestData()
+ { // expected, expInt, durat, userH, userIn, timeH, timeF, actH, actIn, actF, proH, proIn, proFi, custH, custIn, custF
+ yield 'a0' => [0.0, 0.0, 0, 0, 0, null, null, null, null, false, null, null, false, null, null, false];
+ yield 'a2' => [0.0, 0.0, 0, 0, null, null, null, null, null, false, null, null, false, null, null, false];
+ yield 'a4' => [0.0, 0.0, 1800, 0, 0, null, null, null, null, false, null, null, false, null, null, false];
+ yield 'a6' => [0.5, 6.72, 1800, 1, 13.44, null, null, null, null, false, null, null, false, null, null, false];
+ yield 'a8' => [0.0, 1, 0, 0, 0, 0, 0, 0, 1, true, 0, null, true, 0, null, true];
+ // rate: 1.5 => timesheet hourly rate , internal: 2.5 => activity hourly rate (30 min)
+ yield 'b1' => [1.5, 2.5, 1800, 1, 1, 3, null, 5, null, false, 7, null, false, 9, null, false];
+ yield 'b2' => [2.5, 2.5, 1800, 1, 1, null, null, 5, null, false, 7, null, false, 9, null, false];
+ yield 'b3' => [3.5, 6.5, 1800, 1, 1, null, null, null, null, false, 7, 13, false, 9, 9, false];
+ yield 'b4' => [4.5, 6.5, 1800, 1, 15, null, null, null, null, false, null, null, false, 9, 13, false];
+ // rate: 2.0 => timesheet fixed rate , internal: 3.0 => activity fixed rate
+ yield 'b5' => [2.0, 3.0, 1800, 1, 1, null, 2, 3, null, true, 4, null, true, 5, null, true];
+ yield 'b6' => [3.0, 3.0, 1800, 1, 1, null, null, 3, null, true, 4, null, true, 5, null, true];
+ yield 'b7' => [4.0, 4.0, 1800, 1, 1, null, null, null, null, false, 4, null, true, 5, null, true];
+ yield 'b8' => [3.0, 3.0, 1800, 1, 1, null, null, 3, null, true, null, null, false, 5, null, true];
+ // rate: 2.0 => timesheet fixed rate , internal: 5.0 => customer hourly rate
+ yield 'b9' => [2.0, 5.0, 1800, 1, 1, null, 2, null, null, false, null, null, false, 5, null, true];
+ // rate: 5.0 => timesheet hourly rate , internal: 7.5 => user internal rate (30 min)
+ yield 'c0' => [5.0, 7.5, 1800, 100, 15, 10, null, null, null, false, null, null, false, null, null, false];
+ // internal: 10 because no rule applies and as fallback the users internal rate is used
+ yield 'd0' => [10, 100, 1800, 100, 100, null, 10, null, null, false, null, null, false, null, null, false];
+ yield 'e0' => [10, 10, 1800, 100, 100, null, null, 20, null, false, null, null, false, null, null, false];
+ yield 'f0' => [20, 78, 1800, 100, 100, null, null, 20, 78, true, null, null, false, null, null, false];
+ yield 'g0' => [15, 11.5, 1800, 100, 100, null, null, null, null, false, 30, 23, false, null, null, false];
+ yield 'h0' => [30, 30, 1800, 100, 100, null, null, null, null, false, 30, null, true, null, null, false];
+ yield 'i0' => [20, 13.5, 1800, 100, 100, null, null, null, null, false, null, null, false, 40, 27, false];
+ yield 'j0' => [40, 84, 1800, 100, 45, null, null, null, null, false, null, null, false, 40, 84, true];
+ // make sure the last fallback for the internal rate is the users hourly rate
+ yield 'k0' => [8.82, 6, 1800, 17.64, 12, null, null, null, null, false, null, null, false, null, null, true];
+ yield 'k1' => [8.82, 8.82, 1800, 17.64, null, null, null, null, null, false, null, null, false, null, null, true];
+ }
+
+ /**
+ * @dataProvider getRateTestData
+ */
+ public function testRates(
+ $expectedRate,
+ $expectedInternalRate,
+ $duration,
+ $userRate,
+ $userInternalRate,
+ $timesheetHourly,
+ $timesheetFixed,
+ $activityRate,
+ $activityInternal,
+ $activityIsFixed,
+ $projectRate,
+ $projectInternal,
+ $projectIsFixed,
+ $customerRate,
+ $customerInternal,
+ $customerIsFixed
+ ) {
+ $customer = new Customer();
+
+ $project = new Project();
+ $project->setCustomer($customer);
+
+ $activity = new Activity();
+ $activity->setProject($project);
+
+ $timesheet = new Timesheet();
+ $timesheet
+ ->setEnd(new \DateTime())
+ ->setHourlyRate($timesheetHourly)
+ ->setFixedRate($timesheetFixed)
+ ->setActivity($activity)
+ ->setProject($project)
+ ->setDuration($duration)
+ ->setUser($this->getTestUser($userRate, $userInternalRate))
+ ;
+
+ $rates = [];
+
+ if (null !== $customerRate) {
+ $rate = new CustomerRate();
+ $rate->setRate($customerRate);
+ $rate->setIsFixed($customerIsFixed);
+ if (null !== $customerInternal) {
+ $rate->setInternalRate($customerInternal);
+ }
+ $rates[] = $rate;
+ }
+
+ if (null !== $projectRate) {
+ $rate = new ProjectRate();
+ $rate->setRate($projectRate);
+ $rate->setIsFixed($projectIsFixed);
+ if (null !== $projectInternal) {
+ $rate->setInternalRate($projectInternal);
+ }
+ $rates[] = $rate;
+ }
+
+ if (null !== $activityRate) {
+ $rate = new ActivityRate();
+ $rate->setRate($activityRate);
+ $rate->setIsFixed($activityIsFixed);
+ if (null !== $activityInternal) {
+ $rate->setInternalRate($activityInternal);
+ }
+ $rates[] = $rate;
+ }
+
+ $sut = new RateService([], $this->getRateRepositoryMock($rates));
+ $rate = $sut->calculate($timesheet);
+ $this->assertEquals($expectedRate, $rate->getRate());
+ $this->assertEquals($expectedInternalRate, $rate->getInternalRate());
+ }
+
+ protected function getTestUser($rate = 75, $internalRate = 75)
+ {
+ $user = new User();
+
+ $pref = new UserPreference();
+ $pref->setName(UserPreference::HOURLY_RATE);
+ $pref->setValue($rate);
+
+ $prefInt = new UserPreference();
+ $prefInt->setName(UserPreference::INTERNAL_RATE);
+ $prefInt->setValue($internalRate);
+
+ $user->setPreferences([$pref, $prefInt]);
+
+ return $user;
+ }
+
+ public function testCalculateWithEmptyEnd()
+ {
+ $record = new Timesheet();
+ $record->setBegin(new \DateTime());
+ $record->setDuration(1800);
+ $record->setFixedRate(100);
+ $record->setHourlyRate(100);
+ $record->setActivity(new Activity());
+
+ $this->assertEquals(0, $record->getRate());
+
+ $sut = new RateService([], $this->getRateRepositoryMock());
+ $rate = $sut->calculate($record);
+ $this->assertEquals(0, $rate->getRate());
+ }
+
+ /**
+ * Uses the hourly rate from user_preferences to calculate the rate.
+ *
+ * @dataProvider getRuleDefinitions
+ */
+ public function testCalculateWithRulesByUsersHourlyRate($duration, $rules, $expectedRate)
+ {
+ $end = new \DateTime('12:00:00', new \DateTimeZone('UTC'));
+ $start = clone $end;
+ $start->setTimestamp($end->getTimestamp() - $duration);
+
+ $record = new Timesheet();
+ $record->setUser($this->getTestUser());
+ $record->setBegin($start);
+ $record->setDuration($duration);
+ $record->setActivity(new Activity());
+
+ $this->assertEquals(0, $record->getRate());
+
+ $record->setEnd($end);
+
+ $sut = new RateService($rules, $this->getRateRepositoryMock());
+ $rate = $sut->calculate($record);
+
+ $this->assertEquals($expectedRate, $rate->getRate());
+ }
+
+ public function getRuleDefinitions()
+ {
+ $start = new \DateTime('12:00:00', new \DateTimeZone('UTC'));
+ $day = $start->format('l');
+
+ return [
+ [
+ 31837,
+ [],
+ 663.27
+ ],
+ [
+ 31837,
+ [
+ 'default' => [
+ 'days' => [$day],
+ 'factor' => 2.0
+ ],
+ 'foo' => [
+ 'days' => ['bar'],
+ 'factor' => 1.5
+ ],
+ ],
+ 1326.54
+ ],
+ [
+ 31837,
+ [
+ 'default' => [
+ 'days' => [$day],
+ 'factor' => 2.0
+ ],
+ 'foo' => [
+ 'days' => ['MonDay', 'tUEsdAy', 'WEdnesday', 'THursday', 'friDay', 'SATURday', 'sunDAY'],
+ 'factor' => 1.5
+ ],
+ ],
+ 2321.45
+ ],
+ ];
+ }
+}
diff --git a/tests/Validator/TimesheetBudgetUsedValidatorTest.php b/tests/Validator/TimesheetBudgetUsedValidatorTest.php
new file mode 100644
index 00000000..a3f22330
--- /dev/null
+++ b/tests/Validator/TimesheetBudgetUsedValidatorTest.php
@@ -0,0 +1,390 @@
+createMock(SystemConfiguration::class);
+ $configuration->method('isTimesheetAllowOverbookingBudget')->willReturn($isAllowed);
+
+ $customerRepository = $this->createMock(CustomerRepository::class);
+ $customerStatistic = $customerStatistic ?? new CustomerStatistic();
+ $customerRepository->method('getCustomerStatistics')->willReturn($customerStatistic);
+
+ $projectRepository = $this->createMock(ProjectRepository::class);
+ $projectStatistic = $projectStatistic ?? new ProjectStatistic();
+ $projectRepository->method('getProjectStatistics')->willReturn($projectStatistic);
+
+ $activityRepository = $this->createMock(ActivityRepository::class);
+ $activityStatistic = $activityStatistic ?? new ActivityStatistic();
+ $activityRepository->method('getActivityStatistics')->willReturn($activityStatistic);
+
+ $timesheetRepository = $this->createMock(TimesheetRepository::class);
+ if (null !== $rawData) {
+ $timesheetRepository->method('getRawData')->willReturn($rawData);
+ }
+
+ if ($rate !== null) {
+ $rateService = $this->createMock(RateServiceInterface::class);
+ $rateService->method('calculate')->willReturn($rate);
+ } else {
+ $rateService = new RateService([], $timesheetRepository);
+ }
+
+ return new TimesheetBudgetUsedValidator($configuration, $customerRepository, $projectRepository, $activityRepository, $timesheetRepository, $rateService);
+ }
+
+ public function testConstraintIsInvalid()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new Timesheet(), new NotBlank());
+ }
+
+ public function testConstraintWithPreExistingViolation()
+ {
+ $this->validator = $this->createValidator();
+ $this->validator->initialize($this->context);
+ $this->context->addViolation('FOOOOOOOOO');
+
+ $this->validator->validate(new Timesheet(), new TimesheetBudgetUsedConstraint());
+ $this->buildViolation('FOOOOOOOOO')->assertRaised();
+ }
+
+ public function testTargetIsInvalid()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate('foo', new TimesheetBudgetUsedConstraint());
+ }
+
+ public function testWithMissingEnd()
+ {
+ $timesheet = new Timesheet();
+ $timesheet->setBegin(new DateTime());
+
+ $this->validator->validate($timesheet, new TimesheetBudgetUsedConstraint());
+ $this->assertNoViolation();
+ }
+
+ public function testWithMissingUser()
+ {
+ $timesheet = new Timesheet();
+ $timesheet->setBegin(new DateTime());
+ $timesheet->setEnd(new DateTime());
+
+ $this->validator->validate($timesheet, new TimesheetBudgetUsedConstraint());
+ $this->assertNoViolation();
+ }
+
+ public function testWithMissingProject()
+ {
+ $timesheet = new Timesheet();
+ $timesheet->setBegin(new DateTime());
+ $timesheet->setEnd(new DateTime());
+ $timesheet->setUser(new User());
+
+ $this->validator->validate($timesheet, new TimesheetBudgetUsedConstraint());
+ $this->assertNoViolation();
+ }
+
+ public function testWithoutBudget()
+ {
+ $project = new Project();
+ $project->setCustomer(new Customer());
+
+ $timesheet = new Timesheet();
+ $timesheet->setBegin(new DateTime());
+ $timesheet->setEnd(new DateTime());
+ $timesheet->setUser(new User());
+ $timesheet->setProject($project);
+
+ $this->validator->validate($timesheet, new TimesheetBudgetUsedConstraint());
+ $this->assertNoViolation();
+ }
+
+ public function testWithAllowedOverbooking()
+ {
+ $this->validator = $this->createValidator(true);
+ $this->validator->initialize($this->context);
+
+ $activity = new Activity();
+ $activity->setTimeBudget(3600);
+
+ $begin = new DateTime();
+ $end = clone $begin;
+ $end->modify('+3601 seconds');
+
+ $project = new Project();
+ $project->setCustomer(new Customer());
+
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+ $timesheet->setUser(new User());
+ $timesheet->setProject($project);
+ $timesheet->setActivity($activity);
+
+ $this->validator->validate($timesheet, new TimesheetBudgetUsedConstraint());
+ $this->assertNoViolation();
+ }
+
+ public function getViolationTestData()
+ {
+ return [
+ // activity: violations
+ 'a_a' => [1230, null, null, null, null, null, 3600, null, null, null, null, null, '00:20', '00:39', '01:00', 'activity', '+3600 seconds'],
+ 'a_b' => [null, 1001.0, null, null, null, null, null, 1000.0, null, null, null, null, '€1,001.00', '€0.00', '€1,000.00', 'activity', '+3600 seconds'],
+
+ // activity: no violations
+ 'a_c' => [1230, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'a_d' => [null, 1001.0, null, null, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'a_e' => [1230, 1001.0, null, null, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+
+ // previously logged available budgets expected violation duration entry currently in database
+ 'a_f' => [1320, null, null, null, null, null, 3600, null, null, null, null, null, '00:22', '00:38', '01:00', 'activity', '+3600 seconds', ['rate' => 1.0, 'duration' => 1000]],
+ 'a_h1' => [7200, null, null, null, null, null, 7200, null, null, null, null, null, '02:00', '00:00', '02:00', 'activity', '+3601 seconds', ['rate' => 1.0, 'duration' => 3600]],
+ 'a_h' => [3601, null, null, null, null, null, 3600, null, null, null, null, null, null, null, null, null, '+3600 seconds', ['rate' => 1.0, 'duration' => 3601]],
+ 'a_g' => [null, 1002.0, null, null, null, null, null, 1000.0, null, null, null, null, '1,002.00', '0.00', '1,000.00', 'activity', '+3600 seconds', ['rate' => 1.0, 'duration' => 1010]],
+ 'a_g1' => [null, 1002.0, null, null, null, null, null, 1000.0, null, null, null, null, null, null, null, null, '+3600 seconds', ['rate' => 2.0, 'duration' => 0]],
+ // nothing changed => no violation
+ 'a_x1' => [3600, 1000.0, null, null, null, null, 3600, 1000.0, null, null, null, null, null, null, null, null, '+3600 seconds', ['rate' => 1000.0, 'duration' => 3600], new Rate(1000.0, 0.00)],
+
+ // project: violations
+ 'p_j' => [null, null, 1230, null, null, null, null, null, 3600, null, null, null, '00:20', '00:39', '01:00', 'project', '+3600 seconds'],
+ 'p_k' => [null, null, null, 1001.0, null, null, null, null, null, 1000.0, null, null, '€1,001.00', '€0.00', '€1,000.00', 'project', '+3600 seconds'],
+
+ // previously logged available budgets expected violation duration entry currently in database
+ 'p_f' => [null, null, 1320, null, null, null, null, null, 3600, null, null, null, '00:22', '00:38', '01:00', 'project', '+3600 seconds', ['rate' => 1.0, 'duration' => 1000]],
+ 'p_h1' => [null, null, 7200, null, null, null, null, null, 7200, null, null, null, '02:00', '00:00', '02:00', 'project', '+3601 seconds', ['rate' => 1.0, 'duration' => 3600]],
+ 'p_h' => [null, null, 3601, null, null, null, null, null, 3600, null, null, null, null, null, null, null, '+3600 seconds', ['rate' => 1.0, 'duration' => 3601]],
+ 'p_g' => [null, null, null, 1002.0, null, null, null, null, null, 1000.0, null, null, '1,002.00', '0.00', '1,000.00', 'project', '+3600 seconds', ['rate' => 1.0, 'duration' => 1010]],
+ 'p_g1' => [null, null, null, 1002.0, null, null, null, null, null, 1000.0, null, null, null, null, null, null, '+3600 seconds', ['rate' => 2.0, 'duration' => 0]],
+
+ // project: no violations
+ 'p_n' => [null, null, 1230, null, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'p_o' => [null, null, null, 1001.0, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'p_p' => [null, null, 1230, 1001, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+
+ 'p_q' => [1230, null, 1230, null, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'p_r' => [1230, 1001.0, 1230, null, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'p_s' => [null, 1001.0, null, 1001.0, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'p_t' => [null, 1001.0, 1230, 1001.0, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'p_u' => [1230, 1001.0, 1230, 1001.0, null, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+
+ // customer: violations
+ 'c_v' => [null, null, null, null, 1230, null, null, null, null, null, 3600, null, '00:20', '00:39', '01:00', 'customer', '+3600 seconds'],
+ 'c_w' => [null, null, null, null, null, 1001.0, null, null, null, null, null, 1000.0, '€1,001.00', '€0.00', '€1,000.00', 'customer', '+3600 seconds'],
+
+ // previously logged available budgets expected violation duration entry currently in database
+ 'c_f' => [null, null, null, null, 1320, null, null, null, null, null, 3600, null, '00:22', '00:38', '01:00', 'customer', '+3600 seconds', ['rate' => 1.0, 'duration' => 1000]],
+ 'c_h1' => [null, null, null, null, 7200, null, null, null, null, null, 7200, null, '02:00', '00:00', '02:00', 'customer', '+3601 seconds', ['rate' => 1.0, 'duration' => 3600]],
+ 'c_h' => [null, null, null, null, 3601, null, null, null, null, null, 3600, null, null, null, null, null, '+3600 seconds', ['rate' => 1.0, 'duration' => 3601]],
+ 'c_g' => [null, null, null, null, null, 1002.0, null, null, null, null, null, 1000.0, '1,002.00', '0.00', '1,000.00', 'customer', '+3600 seconds', ['rate' => 1.0, 'duration' => 1010]],
+ 'c_g1' => [null, null, null, null, null, 1002.0, null, null, null, null, null, 1000.0, null, null, null, null, '+3600 seconds', ['rate' => 2.0, 'duration' => 0]],
+
+ // customer: no violations
+ 'c_z' => [null, null, null, null, 1230, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_1' => [null, null, null, null, null, 1001.0, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_2' => [null, null, null, null, 1230, 1001.0, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_3' => [1230, null, 1230, null, 1230, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_4' => [1230, 1001.0, 1230, null, null, 1001.0, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_5' => [null, 1001.0, null, 1001.0, 1230, 1001.0, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_6' => [null, 1001.0, 1230, 1001.0, 1230, null, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ 'c_7' => [1230, 1001.0, 1230, 1001.0, null, 1001.0, null, null, null, null, null, null, null, null, null, null, '+3600 seconds'],
+ ];
+ }
+
+ /**
+ * @dataProvider getViolationTestData
+ */
+ public function testWithActivityTimeBudget(
+ ?int $activityDuration,
+ ?float $activityRate,
+ ?int $projectDuration,
+ ?float $projectRate,
+ ?int $customerDuration,
+ ?float $customerRate,
+ ?int $activityTimeBudget,
+ ?float $activityBudget,
+ ?int $projectTimeBudget,
+ ?float $projectBudget,
+ ?int $customerTimeBudget,
+ ?float $customerBudget,
+ ?string $used,
+ ?string $free,
+ ?string $budget,
+ ?string $path,
+ string $duration,
+ array $rawData = [],
+ ?Rate $rate = null
+ ) {
+ $activityStatistic = new ActivityStatistic();
+ if ($activityDuration !== null) {
+ $activityStatistic->setRecordDuration($activityDuration);
+ }
+ if ($activityRate !== null) {
+ $activityStatistic->setRecordRate($activityRate);
+ }
+
+ $projectStatistic = new ProjectStatistic();
+ if ($projectDuration !== null) {
+ $projectStatistic->setRecordDuration($projectDuration);
+ }
+ if ($projectRate !== null) {
+ $projectStatistic->setRecordRate($projectRate);
+ }
+
+ $customerStatistic = new CustomerStatistic();
+ if ($customerDuration !== null) {
+ $customerStatistic->setRecordDuration($customerDuration);
+ }
+ if ($customerRate !== null) {
+ $customerStatistic->setRecordRate($customerRate);
+ }
+
+ $begin = new DateTime();
+ $end = clone $begin;
+ $end->modify($duration);
+
+ if (!empty($rawData)) {
+ if (!\array_key_exists('activity', $rawData)) {
+ $rawData['activity'] = 1;
+ }
+ if (!\array_key_exists('project', $rawData)) {
+ $rawData['project'] = 1;
+ }
+ if (!\array_key_exists('customer', $rawData)) {
+ $rawData['customer'] = 1;
+ }
+ $activity = $this->createMock(Activity::class);
+ $activity->method('getId')->willReturn($rawData['activity']);
+ if ($activityTimeBudget !== null) {
+ $activity->method('getTimeBudget')->willReturn($activityTimeBudget);
+ $activity->method('hasTimeBudget')->willReturn(true);
+ }
+ if ($activityBudget !== null) {
+ $activity->method('getBudget')->willReturn($activityBudget);
+ $activity->method('hasBudget')->willReturn(true);
+ }
+
+ $customer = $this->createMock(Customer::class);
+ $customer->method('getId')->willReturn($rawData['customer']);
+ if ($customerTimeBudget !== null) {
+ $customer->method('getTimeBudget')->willReturn($customerTimeBudget);
+ $customer->method('hasTimeBudget')->willReturn(true);
+ }
+ if ($customerBudget !== null) {
+ $customer->method('getBudget')->willReturn($customerBudget);
+ $customer->method('hasBudget')->willReturn(true);
+ }
+
+ $project = $this->createMock(Project::class);
+ $project->method('getId')->willReturn($rawData['project']);
+ $project->method('getCustomer')->willReturn($customer);
+ if ($projectTimeBudget !== null) {
+ $project->method('getTimeBudget')->willReturn($projectTimeBudget);
+ $project->method('hasTimeBudget')->willReturn(true);
+ }
+ if ($projectBudget !== null) {
+ $project->method('getBudget')->willReturn($projectBudget);
+ $project->method('hasBudget')->willReturn(true);
+ }
+
+ $timesheet = $this->createMock(Timesheet::class);
+ $timesheet->method('getId')->willReturn(1);
+ $timesheet->method('getRate')->willReturn($rawData['rate']);
+ $timesheet->method('getBegin')->willReturn($begin);
+ $timesheet->method('getEnd')->willReturn($end);
+ $timesheet->method('getUser')->willReturn(new User());
+ $timesheet->method('getProject')->willReturn($project);
+ $timesheet->method('getActivity')->willReturn($activity);
+ } else {
+ $activity = new Activity();
+ if ($activityTimeBudget !== null) {
+ $activity->setTimeBudget($activityTimeBudget);
+ }
+ if ($activityBudget !== null) {
+ $activity->setBudget($activityBudget);
+ }
+
+ $customer = new Customer();
+ if ($customerTimeBudget !== null) {
+ $customer->setTimeBudget($customerTimeBudget);
+ }
+ if ($customerBudget !== null) {
+ $customer->setBudget($customerBudget);
+ }
+
+ $project = new Project();
+ if ($projectTimeBudget !== null) {
+ $project->setTimeBudget($projectTimeBudget);
+ }
+ if ($projectBudget !== null) {
+ $project->setBudget($projectBudget);
+ }
+ $project->setCustomer($customer);
+
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+ $timesheet->setUser(new User());
+ $timesheet->setProject($project);
+ $timesheet->setActivity($activity);
+ }
+
+ $this->validator = $this->createValidator(false, $activityStatistic, $projectStatistic, $customerStatistic, $rawData, $rate);
+ $this->validator->initialize($this->context);
+
+ $this->validator->validate($timesheet, new TimesheetBudgetUsedConstraint());
+
+ if (null === $used && null === $budget && null === $free && $path === null) {
+ $this->assertNoViolation();
+ } else {
+ $this->buildViolation('The budget is completely used.')
+ ->atPath('property.path.' . $path)
+ ->setParameters([
+ '%used%' => $used,
+ '%budget%' => $budget,
+ '%free%' => $free
+ ])
+ ->assertRaised();
+ }
+ }
+}
diff --git a/translations/system-configuration.de.xlf b/translations/system-configuration.de.xlf
index f1ee09d0..3dec796a 100644
--- a/translations/system-configuration.de.xlf
+++ b/translations/system-configuration.de.xlf
@@ -66,6 +66,10 @@
label.timesheet.rules.allow_future_times
Erlaube Zeiteinträge in der Zukunft
+
+ label.timesheet.rules.allow_overbooking_budget
+ Erlaube Überbuchung hinterlegter Budgets
+
label.timesheet.rules.allow_overlapping_records
Erlaube überlappende Zeiteinträge
diff --git a/translations/system-configuration.en.xlf b/translations/system-configuration.en.xlf
index 6069cd46..d79327ed 100644
--- a/translations/system-configuration.en.xlf
+++ b/translations/system-configuration.en.xlf
@@ -66,6 +66,10 @@
label.timesheet.rules.allow_future_times
Allow time entries in the future
+
+ label.timesheet.rules.allow_overbooking_budget
+ Allow overbooking of stored budgets
+
label.timesheet.rules.allow_overlapping_records
Allow overlapping time entries
diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf
index e8ec463e..c7d224ed 100644
--- a/translations/validators.de.xlf
+++ b/translations/validators.de.xlf
@@ -34,6 +34,10 @@
The given value is not a valid time.
Der eingetragene Wert ist keine gültige Uhrzeit.
+
+ The budget is completely used.
+ Das Budget ist aufgebraucht. Von den vorhandenen %budget% wurden bisher %used% gebucht, noch nutzbar sind %free%.
+