From 3e9371bd6a0bae7d0a7776b20f0f57d8c151a9a8 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sat, 13 Mar 2021 14:41:29 +0100 Subject: [PATCH] Timesheet validator: prevent overbooking budgets (#2422) --- config/services.yaml | 2 +- src/Configuration/SystemConfiguration.php | 5 + .../SystemConfigurationController.php | 4 + src/DependencyInjection/Configuration.php | 3 + src/Entity/Activity.php | 10 + src/Entity/Customer.php | 10 + src/Entity/Project.php | 10 + src/Model/CustomerStatistic.php | 30 +- src/Model/ProjectStatistic.php | 29 +- src/Repository/ActivityRepository.php | 26 +- src/Repository/CustomerRepository.php | 46 ++- src/Repository/ProjectRepository.php | 37 +- src/Repository/TimesheetRepository.php | 24 ++ src/Timesheet/Calculator/RateCalculator.php | 137 +----- src/Timesheet/Rate.php | 58 +++ src/Timesheet/RateService.php | 144 +++++++ src/Timesheet/RateServiceInterface.php | 20 + .../TimesheetBudgetUsedConstraint.php | 26 ++ .../TimesheetBudgetUsedValidator.php | 256 ++++++++++++ .../SystemConfigurationControllerTest.php | 6 +- tests/Controller/TimesheetControllerTest.php | 57 +++ .../DependencyInjection/AppExtensionTest.php | 1 + .../DependencyInjection/ConfigurationTest.php | 1 + tests/Entity/ActivityTest.php | 4 + tests/Entity/CustomerTest.php | 4 + tests/Entity/ProjectTest.php | 4 + tests/Model/ProjectStatisticTest.php | 7 +- .../Calculator/RateCalculatorTest.php | 11 +- tests/Timesheet/RateServiceTest.php | 283 +++++++++++++ .../TimesheetBudgetUsedValidatorTest.php | 390 ++++++++++++++++++ translations/system-configuration.de.xlf | 4 + translations/system-configuration.en.xlf | 4 + translations/validators.de.xlf | 4 + translations/validators.en.xlf | 4 + 34 files changed, 1425 insertions(+), 236 deletions(-) create mode 100644 src/Timesheet/Rate.php create mode 100644 src/Timesheet/RateService.php create mode 100644 src/Timesheet/RateServiceInterface.php create mode 100644 src/Validator/Constraints/TimesheetBudgetUsedConstraint.php create mode 100644 src/Validator/TimesheetBudgetUsedValidator.php create mode 100644 tests/Timesheet/RateServiceTest.php create mode 100644 tests/Validator/TimesheetBudgetUsedValidatorTest.php 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%. + diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index 43e3a533..49f6cdbd 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -34,6 +34,10 @@ The given value is not a valid time. The given value is not a valid time. + + The budget is completely used. + The budget is used up. Of the available %budget%, %used% has been booked so far, %free% can still be used. +