Timesheet validator: prevent overbooking budgets (#2422)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[]
|
||||
*/
|
||||
|
||||
@@ -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[]
|
||||
*/
|
||||
|
||||
@@ -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[]
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
58
src/Timesheet/Rate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
144
src/Timesheet/RateService.php
Normal file
144
src/Timesheet/RateService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
src/Timesheet/RateServiceInterface.php
Normal file
20
src/Timesheet/RateServiceInterface.php
Normal 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;
|
||||
}
|
||||
26
src/Validator/Constraints/TimesheetBudgetUsedConstraint.php
Normal file
26
src/Validator/Constraints/TimesheetBudgetUsedConstraint.php
Normal 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;
|
||||
}
|
||||
}
|
||||
256
src/Validator/TimesheetBudgetUsedValidator.php
Normal file
256
src/Validator/TimesheetBudgetUsedValidator.php
Normal 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()
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user