Timesheet validator: prevent overbooking budgets (#2422)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
283
tests/Timesheet/RateServiceTest.php
Normal file
283
tests/Timesheet/RateServiceTest.php
Normal 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
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
390
tests/Validator/TimesheetBudgetUsedValidatorTest.php
Normal file
390
tests/Validator/TimesheetBudgetUsedValidatorTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user