Timesheet validator: prevent overbooking budgets (#2422)

This commit is contained in:
Kevin Papst
2021-03-13 14:41:29 +01:00
committed by GitHub
parent db1344573f
commit 3e9371bd6a
34 changed files with 1425 additions and 236 deletions

View File

@@ -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');

View File

@@ -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])

View File

@@ -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()

View File

@@ -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[]
*/

View File

@@ -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[]
*/

View File

@@ -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[]
*/

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}
}

58
src/Timesheet/Rate.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
final class Rate
{
/**
* @var float
*/
private $rate = 0.00;
/**
* @var float
*/
private $internalRate = 0.00;
/**
* @var float|null
*/
private $fixedRate = null;
/**
* @var float|null
*/
private $hourlyRate = null;
public function __construct(float $rate, float $internalRate, ?float $hourlyRate = null, ?float $fixedRate = null)
{
$this->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;
}
}

View File

@@ -0,0 +1,144 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Entity\RateInterface;
use App\Entity\Timesheet;
use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
/**
* Implementation to calculate the rate for a timesheet record.
*/
final class RateService implements RateServiceInterface
{
/**
* @var array
*/
private $rates;
/**
* @var TimesheetRepository
*/
private $repository;
public function __construct(array $rates, TimesheetRepository $repository)
{
$this->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;
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Entity\Timesheet;
/**
* Implementation to calculate the rate for a timesheet record.
*/
interface RateServiceInterface
{
public function calculate(Timesheet $record): Rate;
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator\Constraints;
use App\Validator\TimesheetBudgetUsedValidator;
final class TimesheetBudgetUsedConstraint extends TimesheetConstraint
{
public const BUDGET_SPENT = 'kimai-timesheet-budget-used-01';
// same messages, so we can re-use the validation translation!
public $messageRate = 'The budget is completely used.';
public $messageTime = 'The budget is completely used.';
public function validatedBy()
{
return TimesheetBudgetUsedValidator::class;
}
}

View File

@@ -0,0 +1,256 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\RateServiceInterface;
use App\Utils\Duration;
use App\Utils\LocaleHelper;
use App\Validator\Constraints\TimesheetBudgetUsedConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetBudgetUsedValidator extends ConstraintValidator
{
private $customerRepository;
private $projectRepository;
private $activityRepository;
private $timesheetRepository;
private $rateService;
private $configuration;
public function __construct(SystemConfiguration $configuration, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, TimesheetRepository $timesheetRepository, RateServiceInterface $rateService)
{
$this->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()
;
}
}