added rounding rules and hourly rates factor #112 (#128)

* added unit tests and documentation #112
* fixed DashboardController
* added CONTRIBUTION guidelines
* fixed license year
This commit is contained in:
Kevin Papst
2018-02-07 12:51:00 +01:00
committed by GitHub
parent 65790f1fb7
commit 2e60e14132
22 changed files with 925 additions and 76 deletions

View File

@@ -118,7 +118,11 @@ class KimaiImporterCommand extends Command
->setName('kimai:import-v1')
->setDescription('Import data from a Kimai v1 installation')
->setHelp('This command allows you to import the most important data from a Kimi v1 installation.')
->addArgument('connection', InputArgument::REQUIRED, 'The database connection as URL, for example: mysql://user:password@127.0.0.1:3306/kimai?charset=latin1')
->addArgument(
'connection',
InputArgument::REQUIRED,
'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=latin1'
)
->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables')
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer', 'de')
@@ -272,7 +276,8 @@ class KimaiImporterCommand extends Command
'Start: ' . $this->bytesHumanReadable($bytesStart) . PHP_EOL .
'After caching: ' . $this->bytesHumanReadable($bytesCached) . PHP_EOL .
'After import: ' . $this->bytesHumanReadable($bytesImported) . PHP_EOL .
'Total consumption for importing '.$allImports.' new database entries: ' . $this->bytesHumanReadable($bytesImported - $bytesStart)
'Total consumption for importing '.$allImports.' new database entries: ' .
$this->bytesHumanReadable($bytesImported - $bytesStart)
);
}
@@ -288,8 +293,11 @@ class KimaiImporterCommand extends Command
*/
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
{
$version = $this->getImportConnection()->query('SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "version"')->fetchColumn();
$revision = $this->getImportConnection()->query('SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "revision"')->fetchColumn();
$versionQuery = 'SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "version"';
$revisionQuery = 'SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "revision"';
$version = $this->getImportConnection()->query($versionQuery)->fetchColumn();
$revision = $this->getImportConnection()->query($revisionQuery)->fetchColumn();
if (version_compare($requiredVersion, $version) == 1) {
$io->error(
@@ -645,7 +653,9 @@ class KimaiImporterCommand extends Command
$project = null;
if (!isset($this->projects[$projectId])) {
throw new \Exception('Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId);
throw new \Exception(
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
);
}
$project = $this->projects[$projectId];
@@ -668,8 +678,12 @@ class KimaiImporterCommand extends Command
* @return Activity
* @throws \Exception
*/
protected function createActivity(SymfonyStyle $io, ObjectManager $entityManager, Project $project, array $oldActivity)
{
protected function createActivity(
SymfonyStyle $io,
ObjectManager $entityManager,
Project $project,
array $oldActivity
) {
$activityId = $oldActivity['activityID'];
if (isset($this->activities[$activityId][$project->getId()])) {
return $this->activities[$activityId][$project->getId()];

View File

@@ -14,6 +14,7 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
@@ -104,7 +105,7 @@ class DashboardController extends Controller
//"{{ widgets.info_box_counter('stats.amountThisMonth', timesheetGlobal.amountThisMonth|money, 'money', 'green') }}",
"{{ widgets.info_box_counter('stats.durationTotal', timesheetGlobal.durationTotal|duration(true), 'hourglass-o', 'yellow') }}",
//"{{ widgets.info_box_counter('stats.amountTotal', timesheetGlobal.amountTotal|money, 'money', 'red') }}",
"{{ widgets.info_box_counter('stats.activeRecordings', timesheetGlobal.activeCurrently, 'hourglass-o', 'red', path('admin_timesheet', {'state': 1})) }}",
"{{ widgets.info_box_counter('stats.activeRecordings', timesheetGlobal.activeCurrently, 'hourglass-o', 'red', path('admin_timesheet', {'state': ".TimesheetQuery::STATE_RUNNING."})) }}",
],
];

View File

@@ -88,15 +88,32 @@ class AppFixtures extends Fixture
protected function getUserDefinition()
{
return [
['Clara Haynes', 'CFO', 'clara_customer', 'clara_customer@example.com', 'ROLE_CUSTOMER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', true],
['John Doe', 'Developer', 'john_user', 'john_user@example.com', 'ROLE_USER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', true],
[
'Clara Haynes', 'CFO', 'clara_customer', 'clara_customer@example.com', 'ROLE_CUSTOMER',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', true
],
[
'John Doe', 'Developer', 'john_user', 'john_user@example.com', 'ROLE_USER',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', true
],
// inactive user to test login
['Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', 'ROLE_USER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', false],
['Tony Maier', 'Head of Development', 'tony_teamlead', 'tony_teamlead@example.com', 'ROLE_TEAMLEAD', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true],
[
'Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', 'ROLE_USER',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', false
],
[
'Tony Maier', 'Head of Development', 'tony_teamlead', 'tony_teamlead@example.com', 'ROLE_TEAMLEAD',
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true
],
// no avatar to test default image macro
['Anna Smith', 'Administrator', 'anna_admin', 'anna_admin@example.com', 'ROLE_ADMIN', null, true],
[
'Anna Smith', 'Administrator', 'anna_admin', 'anna_admin@example.com', 'ROLE_ADMIN', null, true
],
// no alias to test twig username macro
[null, 'Super Administrator', 'susan_super', 'susan_super@example.com', 'ROLE_SUPER_ADMIN', '/bundles/avanzuadmintheme/img/avatar.png', true]
[
null, 'Super Administrator', 'susan_super', 'susan_super@example.com', 'ROLE_SUPER_ADMIN',
'/bundles/avanzuadmintheme/img/avatar.png', true
]
];
}
}

View File

@@ -44,14 +44,16 @@ class InvoiceFixtures extends Fixture
->setVat(19)
->setDueDays(14)
->setPaymentTerms(
'I would like to thank you for your confidence and will gladly be there for you in the future.
Please transfer the total amount within 14 days to the given account and use the invoice number as reference.'
'I would like to thank you for your confidence and will gladly be there for you in the future.' .
PHP_EOL .
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
'as reference.'
)
->setAddress(
'795 Folsom Ave, Suite 600
San Francisco, CA 94107
Phone: (804) 123-5432
Email: info@almasaeedstudio.com'
'795 Folsom Ave, Suite 600' . PHP_EOL .
'San Francisco, CA 94107' . PHP_EOL .
'Phone: (804) 123-456789' . PHP_EOL .
'Email: email@example.com'
)
;

View File

@@ -52,6 +52,8 @@ class AppExtension extends Extension
}
$container->setParameter('kimai.invoice', $config['invoice']);
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
}
/**

View File

@@ -29,7 +29,52 @@ class Configuration implements ConfigurationInterface
->children()
->arrayNode('timesheet')
->children()
->integerNode('rounding')->end()
->arrayNode('rounding')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->integerNode('begin')
->defaultValue(0)
->end()
->integerNode('end')
->defaultValue(0)
->end()
->integerNode('duration')
->defaultValue(0)
->end()
->end()
->end()
->defaultValue(array())
->end()
->arrayNode('rates')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->floatNode('factor')
->isRequired()
->defaultValue(1)
->end()
->end()
->end()
->defaultValue([])
->end()
->end()
->end()
->arrayNode('invoice')

View File

@@ -9,7 +9,7 @@
namespace App\Doctrine;
use App\Entity\UserPreference;
use App\Timesheet\CalculatorInterface;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Event\LifecycleEventArgs;
@@ -20,6 +20,27 @@ use App\Entity\Timesheet;
*/
class TimesheetSubscriber implements EventSubscriber
{
/**
* @var CalculatorInterface[]
*/
protected $calculator;
/**
* TimesheetSubscriber constructor.
* @param iterable $calculators
*/
public function __construct(iterable $calculators)
{
foreach ($calculators as $calculator) {
if (!($calculator instanceof CalculatorInterface)) {
throw new \InvalidArgumentException(
'Invalid TimesheetCalculator implementation given. Expected CalculatorInterface but received ' .
get_class($calculator)
);
}
}
$this->calculator = $calculators;
}
/**
* @return array
@@ -55,26 +76,12 @@ class TimesheetSubscriber implements EventSubscriber
{
$entity = $args->getObject();
if ($entity instanceof Timesheet) {
if ($entity->getEnd() !== null) {
$duration = $entity->getEnd()->getTimestamp() - $entity->getBegin()->getTimestamp();
$entity->setDuration($duration);
if (!($entity instanceof Timesheet)) {
return;
}
// TODO allow to set hourly rate on activity, project and customer and prefer these
$rate = $this->calculateRate($entity);
$entity->setRate($rate);
}
foreach ($this->calculator as $calculator) {
$calculator->calculate($entity);
}
}
/**
* @param Timesheet $entity
* @return float
*/
protected function calculateRate(Timesheet $entity)
{
$hourlyRate = (float) $entity->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
return (float) $hourlyRate * ($entity->getDuration() / 3600);
}
}

View File

@@ -1,9 +1,17 @@
<?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;
use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\Timesheet\CalculatorInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
@@ -27,6 +35,11 @@ class Kernel extends BaseKernel
return $this->getProjectDir().'/var/log';
}
protected function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(CalculatorInterface::class)->addTag('timesheet.calculator');
}
public function registerBundles()
{
$contents = require $this->getProjectDir().'/config/bundles.php';

View File

@@ -0,0 +1,139 @@
<?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\Calculator;
use App\Entity\Timesheet;
use App\Timesheet\CalculatorInterface;
/**
* Implementation to calculate the durations for a timesheet record.
*
* This calculator takes the configuration %kimai.timesheet.rounding% as argument,
* so its rounding behaviour can be customized.
*/
class DurationCalculator implements CalculatorInterface
{
/**
* @var array
*/
protected $roundings;
/**
* DurationCalculator constructor.
* @param array $roundings
*/
public function __construct(array $roundings)
{
$this->roundings = $roundings;
}
/**
* @param Timesheet $record
*/
public function calculate(Timesheet $record)
{
if ($record->getEnd() === null) {
return;
}
$this->applyDuration($record);
$this->applyRoundings($record);
}
/**
* @param Timesheet $record
*/
protected function applyDuration(Timesheet $record)
{
$duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp();
$record->setDuration($duration);
}
/**
* @param Timesheet $record
*/
protected function applyRoundings(Timesheet $record)
{
foreach ($this->roundings as $rounding) {
$weekday = $record->getEnd()->format('l');
$days = array_map('strtolower', $rounding['days']);
if (in_array(strtolower($weekday), $days)) {
$this->roundBegin($record, $rounding['begin']);
$this->roundEnd($record, $rounding['end']);
$this->applyDuration($record);
$this->roundDuration($record, $rounding['duration']);
}
}
}
/**
* @param Timesheet $record
* @param int $minutes
*/
protected function roundBegin(Timesheet $record, $minutes)
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getBegin()->getTimestamp();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
if ($diff === 0) {
return;
}
$record->getBegin()->setTimestamp($timestamp - $diff);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
protected function roundEnd(Timesheet $record, $minutes)
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getEnd()->getTimestamp();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
if ($diff === 0) {
return;
}
$record->getEnd()->setTimestamp($timestamp - $diff + $seconds);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
protected function roundDuration(Timesheet $record, $minutes)
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getDuration();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
if ($diff === 0) {
return;
}
$record->setDuration($timestamp - $diff + $seconds);
}
}

View File

@@ -0,0 +1,86 @@
<?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\Calculator;
use App\Entity\Timesheet;
use App\Entity\UserPreference;
use App\Timesheet\CalculatorInterface;
/**
* Implementation to calculate the rate for a timesheet record.
*/
class RateCalculator implements CalculatorInterface
{
/**
* @var array
*/
protected $rates;
/**
* RateCalculator constructor.
* @param array $rates
*/
public function __construct(array $rates)
{
$this->rates = $rates;
}
/**
* @param Timesheet $record
*/
public function calculate(Timesheet $record)
{
if ($record->getEnd() === null) {
return;
}
$rate = $this->calculateRate($record);
$factor = $this->getRateFactor($record);
$record->setRate($rate * $factor);
}
/**
* @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)) {
if ($rateFactor['factor'] <= 0) {
throw new \InvalidArgumentException(
'A rate factor smaller or equals 0 is not allowed, given: ' . $rateFactor['factor']
);
}
$factor += $rateFactor['factor'];
}
}
if ($factor <= 0) {
$factor = 1;
}
return $factor;
}
/**
* @param Timesheet $record
* @return float
*/
protected function calculateRate(Timesheet $record)
{
$hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
return (float) $hourlyRate * ($record->getDuration() / 3600);
}
}

View File

@@ -0,0 +1,28 @@
<?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;
/**
* A calculator is called before a Timesheet entity will be updated.
* These classes will normally be used when calculating duration or rates.
*/
interface CalculatorInterface
{
/**
* All necessary changes need to be applied on the given $record.
* The methods return value will not be evaluated.
*
* @param Timesheet $record
* @return void
*/
public function calculate(Timesheet $record);
}