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

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

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()
;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,283 @@
<?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\Tests\Timesheet;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\Customer;
use App\Entity\CustomerRate;
use App\Entity\Project;
use App\Entity\ProjectRate;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\RateService;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Timesheet\RateService
*/
class RateServiceTest extends TestCase
{
protected function getRateRepositoryMock(array $rates = [])
{
$mock = $this->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
],
];
}
}

View File

@@ -0,0 +1,390 @@
<?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\Tests\Validator;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Model\CustomerStatistic;
use App\Model\ProjectStatistic;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\Rate;
use App\Timesheet\RateService;
use App\Timesheet\RateServiceInterface;
use App\Validator\Constraints\TimesheetBudgetUsedConstraint;
use App\Validator\TimesheetBudgetUsedValidator;
use DateTime;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetBudgetUsedConstraint
* @covers \App\Validator\TimesheetBudgetUsedValidator
*/
class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(bool $isAllowed = false, ?ActivityStatistic $activityStatistic = null, ?ProjectStatistic $projectStatistic = null, ?CustomerStatistic $customerStatistic = null, ?array $rawData = null, ?Rate $rate = null)
{
$configuration = $this->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();
}
}
}

View File

@@ -66,6 +66,10 @@
<source>label.timesheet.rules.allow_future_times</source>
<target>Erlaube Zeiteinträge in der Zukunft</target>
</trans-unit>
<trans-unit id="label.timesheet.rules.allow_overbooking_budget">
<source>label.timesheet.rules.allow_overbooking_budget</source>
<target>Erlaube Überbuchung hinterlegter Budgets</target>
</trans-unit>
<trans-unit id="label.timesheet.rules.allow_overlapping_records">
<source>label.timesheet.rules.allow_overlapping_records</source>
<target>Erlaube überlappende Zeiteinträge</target>

View File

@@ -66,6 +66,10 @@
<source>label.timesheet.rules.allow_future_times</source>
<target>Allow time entries in the future</target>
</trans-unit>
<trans-unit id="label.timesheet.rules.allow_overbooking_budget">
<source>label.timesheet.rules.allow_overbooking_budget</source>
<target>Allow overbooking of stored budgets</target>
</trans-unit>
<trans-unit id="label.timesheet.rules.allow_overlapping_records">
<source>label.timesheet.rules.allow_overlapping_records</source>
<target>Allow overlapping time entries</target>

View File

@@ -34,6 +34,10 @@
<source>The given value is not a valid time.</source>
<target>Der eingetragene Wert ist keine gültige Uhrzeit.</target>
</trans-unit>
<trans-unit id="The budget is completely used.">
<source>The budget is completely used.</source>
<target>Das Budget ist aufgebraucht. Von den vorhandenen %budget% wurden bisher %used% gebucht, noch nutzbar sind %free%.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -34,6 +34,10 @@
<source>The given value is not a valid time.</source>
<target>The given value is not a valid time.</target>
</trans-unit>
<trans-unit id="The budget is completely used.">
<source>The budget is completely used.</source>
<target>The budget is used up. Of the available %budget%, %used% has been booked so far, %free% can still be used.</target>
</trans-unit>
</body>
</file>
</xliff>