From 2e60e1413276576bc3f2cdf1c2901e298c1605c8 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Wed, 7 Feb 2018 12:51:00 +0100 Subject: [PATCH] added rounding rules and hourly rates factor #112 (#128) * added unit tests and documentation #112 * fixed DashboardController * added CONTRIBUTION guidelines * fixed license year --- CONTRIBUTING.md | 21 ++- LICENSE | 2 +- README.md | 2 +- config/packages/kimai.yaml | 20 ++- config/services.yaml | 35 ++-- src/Command/KimaiImporterCommand.php | 28 ++- src/Controller/DashboardController.php | 3 +- src/DataFixtures/AppFixtures.php | 29 +++- src/DataFixtures/InvoiceFixtures.php | 14 +- src/DependencyInjection/AppExtension.php | 2 + src/DependencyInjection/Configuration.php | 47 ++++- src/Doctrine/TimesheetSubscriber.php | 47 ++--- src/Kernel.php | 13 ++ .../Calculator/DurationCalculator.php | 139 +++++++++++++++ src/Timesheet/Calculator/RateCalculator.php | 86 ++++++++++ src/Timesheet/CalculatorInterface.php | 28 +++ .../Calculator/DurationCalculatorTest.php | 152 +++++++++++++++++ .../Calculator/RateCalculatorTest.php | 160 ++++++++++++++++++ var/docs/README.md | 11 ++ var/docs/configurations.md | 81 +++++++++ var/docs/developers.md | 76 ++++++++- var/docs/users.md | 5 +- 22 files changed, 925 insertions(+), 76 deletions(-) create mode 100644 src/Timesheet/Calculator/DurationCalculator.php create mode 100644 src/Timesheet/Calculator/RateCalculator.php create mode 100644 src/Timesheet/CalculatorInterface.php create mode 100644 tests/Timesheet/Calculator/DurationCalculatorTest.php create mode 100644 tests/Timesheet/Calculator/RateCalculatorTest.php create mode 100644 var/docs/README.md create mode 100644 var/docs/configurations.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9b6e14b..95361afd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,23 @@ -Contributing -============ +# Contributing The Kimai application is an open source project. Contributions made by the community are welcome. Send us your ideas, code reviews, pull requests and feature requests to help us improve this project. + +## Pull request rules + +- We use PSR-2 code styles, please run `bin/console kimai:phpcs` before sending in a pull-request +- Please add PHPUnit tests for your changes +- Verify everything still works by executing our tests `bin/console kimai:test-unit` and `bin/console kimai:test-integration` +- If you want to contribute new files, please add them with the file-header template from below +- With sending in a PR, you accept that your contributions/code will be published under MIT license (see the LICENSE file as well) + +### File-header template +``` +/* + * 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. + */ +``` diff --git a/LICENSE b/LICENSE index 7ba01b6e..d85d0841 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2016 Kevin Papst +Copyright (c) 2017-2018 Kevin Papst Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6c6d60b0..a2f667d6 100644 --- a/README.md +++ b/README.md @@ -170,4 +170,4 @@ All available Kimai 2 bundles can be found at the [Kimai recipes](https://github If you want to develop for Kimai 2 please read the following documentation: - an example on how to extend Kimai 2 can be found in this [GitHub repository](https://github.com/kevinpapst/kimai2-invoice) -- the developer documentation can be found at [var/docs/developers.md](var/docs/developers.md) +- the developer documentation can be found at [var/docs/developers.md](var/docs/developers.md) both on GitHub and your local installation diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 84a75493..3dcfa003 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -1,6 +1,24 @@ kimai: timesheet: - rounding: 15 + + # Rounding rules are used to round the begin & end dates and the duration for timesheet records. + # The "default" rule will round "begin" down and "end" up to the full minute, the "duration" will not be rounded. + # Please read var/docs/configurations.md to find out more about rounding rules + rounding: + default: + days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] + begin: 1 + end: 1 + duration: 0 + + # If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration. + # The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays". + # Please read var/docs/configurations.md to find out more about rates rules + # rates: + # weekend: + # days: ['saturday','sunday'] + # factor: 1.5 + invoice: renderer: default: 'App\Controller\InvoiceController::invoiceAction' diff --git a/config/services.yaml b/config/services.yaml index dac7cdf6..201a65a1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -33,7 +33,7 @@ services: App\Invoice\ServiceInvoice: class: App\Invoice\ServiceInvoice - arguments: ['%kimai.invoice%', '@service_container'] + arguments: ['%kimai.invoice%'] # a route listener, that injects the locale through a URL directory App\EventSubscriber\RedirectToLocaleSubscriber: @@ -53,20 +53,14 @@ services: tags: - { name: doctrine.event_subscriber } - # updates timesheet records, to make sure they are calculated and can't be manipulated by the users + # updates timesheet records and apply configured rate & rounding rules App\Doctrine\TimesheetSubscriber: class: App\Doctrine\TimesheetSubscriber + arguments: [!tagged timesheet.calculator] tags: - { name: doctrine.event_listener, event: prePersist, lazy: true } - { name: doctrine.event_listener, event: preUpdate, lazy: true } - # Uncomment the following lines to define a service for the Timesheet Doctrine repository - # - # app.timesheet_repository: - # class: Doctrine\ORM\EntityRepository - # factory: ['@doctrine.orm.entity_manager', getRepository] - # arguments: [App\Entity\Timesheet] - # ================================================================================ # FORMS # ================================================================================ @@ -85,17 +79,12 @@ services: App\Twig\Extensions: arguments: ['%app_locales%'] - # avanzu_admin_theme.navbar_task_listener: - # class: "%avanzu_admin_theme.navbar_task_listener.class%" - # tags: - # - { name: kernel.event_listener, event: theme.tasks, method: onListTasks } - # - # avanzu_admin_theme.navbar_notify_listener: - # class: "%avanzu_admin_theme.navbar_notify_listener.class%" - # tags: - # - { name: kernel.event_listener, event: theme.notifications, method: onListNotifications } - # - # avanzu_admin_theme.navbar_msg_listener: - # class: "%avanzu_admin_theme.navbar_msg_listener.class%" - # tags: - # - { name: kernel.event_listener, event: theme.messages, method: onListMessages } + # ================================================================================ + # TIMESHEET RECORD CALCULATOR + # ================================================================================ + + App\Timesheet\Calculator\DurationCalculator: + arguments: ["%kimai.timesheet.rounding%"] + + App\Timesheet\Calculator\RateCalculator: + arguments: ["%kimai.timesheet.rates%"] diff --git a/src/Command/KimaiImporterCommand.php b/src/Command/KimaiImporterCommand.php index e8ce9354..0e3315f2 100644 --- a/src/Command/KimaiImporterCommand.php +++ b/src/Command/KimaiImporterCommand.php @@ -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()]; diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index e3e08908..8512dc0e 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -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."})) }}", ], ]; diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index ec449ddb..e7f03678 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -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 + ] ]; } } diff --git a/src/DataFixtures/InvoiceFixtures.php b/src/DataFixtures/InvoiceFixtures.php index 3f807a51..3ab8f1f9 100644 --- a/src/DataFixtures/InvoiceFixtures.php +++ b/src/DataFixtures/InvoiceFixtures.php @@ -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' ) ; diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index bb34c235..d573f6ea 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -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']); } /** diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index bcabbf18..b91df062 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -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') diff --git a/src/Doctrine/TimesheetSubscriber.php b/src/Doctrine/TimesheetSubscriber.php index d130f4e9..0335b0c3 100644 --- a/src/Doctrine/TimesheetSubscriber.php +++ b/src/Doctrine/TimesheetSubscriber.php @@ -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); - } } diff --git a/src/Kernel.php b/src/Kernel.php index 5ea948d7..1263cfa0 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -1,9 +1,17 @@ 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'; diff --git a/src/Timesheet/Calculator/DurationCalculator.php b/src/Timesheet/Calculator/DurationCalculator.php new file mode 100644 index 00000000..99857966 --- /dev/null +++ b/src/Timesheet/Calculator/DurationCalculator.php @@ -0,0 +1,139 @@ +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); + } +} diff --git a/src/Timesheet/Calculator/RateCalculator.php b/src/Timesheet/Calculator/RateCalculator.php new file mode 100644 index 00000000..d5f36932 --- /dev/null +++ b/src/Timesheet/Calculator/RateCalculator.php @@ -0,0 +1,86 @@ +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); + } +} diff --git a/src/Timesheet/CalculatorInterface.php b/src/Timesheet/CalculatorInterface.php new file mode 100644 index 00000000..85ee7943 --- /dev/null +++ b/src/Timesheet/CalculatorInterface.php @@ -0,0 +1,28 @@ +setBegin(new \DateTime()); + $this->assertEquals(0, $record->getDuration()); + + $sut = new DurationCalculator([]); + $sut->calculate($record); + $this->assertEquals(0, $record->getDuration()); + } + + /** + * @dataProvider getTestData + */ + public function testCalculate($rules, $start, $end, $expectedDuration) + { + $record = new Timesheet(); + $record->setBegin($start); + $record->setEnd($end); + $this->assertEquals(0, $record->getDuration()); + + $sut = new DurationCalculator($rules); + $sut->calculate($record); + $this->assertEquals($expectedDuration, $record->getDuration()); + } + + public function getTestData() + { + $start = new \DateTime(); + $start->setTime(12, 0, 0); + $day = $start->format('l'); + + return [ + [ + [], + $start, + (clone $start)->setTimestamp($start->getTimestamp() + 1837), + 1837 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'begin' => 15, + 'end' => 15, + 'duration' => 0, + ], + ], + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + 5400 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'begin' => 0, + 'end' => 0, + 'duration' => 0, + ], + ], + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + 4517 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'begin' => 1, + 'end' => 1, + 'duration' => 0, + ], + ], + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + 4560 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'begin' => 0, + 'end' => 0, + 'duration' => 30, + ], + ], + (clone $start)->setTime(12, 10, 51), + (clone $start)->setTime(14, 40, 52), + 10800 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'begin' => 15, + 'end' => 0, + 'duration' => 0, + ], + 'weekdays' => [ + 'days' => ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'], + 'begin' => 0, + 'end' => 1, + 'duration' => 30, + ], + ], + (clone $start)->setTime(12, 27, 35), // 12:15 + (clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 => 2:30 + 9000 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'begin' => 15, + 'end' => 0, + 'duration' => 30, + ], + 'weekdays' => [ + 'days' => ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'], + 'begin' => 0, + 'end' => 1, + 'duration' => 0, + ], + ], + (clone $start)->setTime(12, 27, 35), // 12:15 + (clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 (second duration will not be rounded) + 8280 + ], + ]; + } +} diff --git a/tests/Timesheet/Calculator/RateCalculatorTest.php b/tests/Timesheet/Calculator/RateCalculatorTest.php new file mode 100644 index 00000000..df65d6ee --- /dev/null +++ b/tests/Timesheet/Calculator/RateCalculatorTest.php @@ -0,0 +1,160 @@ +setName(UserPreference::HOURLY_RATE); + $pref->setValue(self::HOURLY_RATE); + + $user = new User(); + $user->setPreferences([$pref]); + + return $user; + } + + public function testCalculateWithEmptyEnd() + { + $record = new Timesheet(); + $record->setBegin(new \DateTime()); + $this->assertEquals(0, $record->getRate()); + + $sut = new RateCalculator([]); + $sut->calculate($record); + $this->assertEquals(0, $record->getRate()); + } + + /** + * @expectedException \InvalidArgumentException + * @dataProvider getDisallowedFactors + */ + public function testCalculateWithInvalidFactor($factor) + { + $today = new \DateTime(); + $day = $today->format('l'); + $rules = [ + 'default' => [ + 'days' => [$day], + 'factor' => $factor + ], + ]; + $seconds = 41837; + + $end = new \DateTime(); + $start = clone $end; + $start->setTimestamp($end->getTimestamp() - $seconds); + + $record = new Timesheet(); + $record->setUser($this->getTestUser()); + $record->setBegin($start); + $record->setDuration($seconds); + $this->assertEquals(0, $record->getRate()); + + $record->setEnd($end); + + $sut = new RateCalculator($rules); + $sut->calculate($record); + } + + public function getDisallowedFactors() + { + return [ + [0], + [-1], + ]; + } + + /** + * @dataProvider getRuleDefinitions + */ + public function testCalculateWithRules($rules, $expectedFactor) + { + $seconds = 41837; + + $end = new \DateTime(); + $start = clone $end; + $start->setTimestamp($end->getTimestamp() - $seconds); + + $record = new Timesheet(); + $record->setUser($this->getTestUser()); + $record->setBegin($start); + $record->setDuration($seconds); + $this->assertEquals(0, $record->getRate()); + + $record->setEnd($end); + + $sut = new RateCalculator($rules); + $sut->calculate($record); + + $this->assertEquals( + $this->rateForSeconds(self::HOURLY_RATE, $seconds) * $expectedFactor, + $record->getRate() + ); + } + + public function getRuleDefinitions() + { + $start = new \DateTime(); + $start->setTime(12, 0, 0); + $day = $start->format('l'); + + return [ + [ + [], + 1 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'factor' => 2.0 + ], + 'foo' => [ + 'days' => ['bar'], + 'factor' => 1.5 + ], + ], + 2.0 + ], + [ + [ + 'default' => [ + 'days' => [$day], + 'factor' => 2.0 + ], + 'weekdays' => [ + 'days' => ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'], + 'factor' => 1.5 + ], + ], + 3.5 + ], + ]; + } + + protected function rateForSeconds($hourlyRate, $seconds) + { + return (float) $hourlyRate * ($seconds / 3600); + } +} diff --git a/var/docs/README.md b/var/docs/README.md new file mode 100644 index 00000000..f06e0f4a --- /dev/null +++ b/var/docs/README.md @@ -0,0 +1,11 @@ +# Kimai documentation + +Welcome to the official Kimai v2 documentation. + +- [Kimai configurations](configurations.md) - the Kimai core configurations, which can only be set in configuration files +- [Developer docu](developers.md) - information for developer who would like to extend Kimai +- [User and Security](users.md) - everything related to authentication, security, users, roles ... + +We know there is a lot missing right now and would appreciate [any help](https://github.com/kevinpapst/kimai2/pulls) in writing. +If you need information which is not available right now, [ask us](https://github.com/kevinpapst/kimai2/issues) and we +try to add it as soon as possible. \ No newline at end of file diff --git a/var/docs/configurations.md b/var/docs/configurations.md new file mode 100644 index 00000000..d06cb7e5 --- /dev/null +++ b/var/docs/configurations.md @@ -0,0 +1,81 @@ +# Configurations + +There are several configurations that can be configured with the yaml files in `config/packages/*.yaml + +## Remember me login + +The default period for the `Remember me` option can be changed in the config file [security.yaml](../../config/packages/security.yaml). + +## Timesheet records - rounding of begin, end and duration + +Rounding rules are used to round the begin & end dates and the duration for timesheet records. + +1. You can define as many rules as you want ("default" is only an example) +2. Every matching rule will be applied, so be careful with overlapping rules +3. The end_date of timesheet records will be used to match the day (think about entries which are recorded overnight) +4. If you set one of "begin", "end", "duration" to 0 no rounding will be applied for that field and the exact time (including seconds) is used for calculation +5. The values of the rules are minutes (not the minute of an hour), so 5 for "begin" means we round down to the previous multiple of five +6. You can define different rules for different days of the week +7. "begin" will always be rounded to the floor (down) and "end" & "duration" to the ceiling (up) +8. Rounding rules will be applied on stopped timesheet records only, so you might see an un-rounded value for the start time and duration until you stop the record + +You can configure your `rounding` rules by changing the configuration file [kimai.yaml](../../config/packages/kimai.yaml). + +### Examples + +A simple example to always charge at least 1 hour for weekend work (even if you only worked for 5 minutes) could look like this: + +```yaml +kimai: + timesheet: + rounding: + weekend: + days: ['saturday','sunday'] + begin: 1 + end: 1 + duration: 60 +``` + +A rule which is often used is to round to a mulitple of 10: + +```yaml +kimai: + timesheet: + rounding: + workdays: + days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] + begin: 10 + end: 10 + duration: 0 +``` + +## Timesheet records - hourly rates + +If you want to apply different hourly rates multiplication `factor` for specific weekdays, you can use this `rates` configuration. + +1. You can define as many rules as you want ("workdays" and "weekend" are only examples) +2. Every matching rule will be applied, so be careful with overlapping rules +3. The end_date of timesheet records will be used to match the day (think about entries which are recorded overnight) +4. "days" is an array of weekdays, where the days need to be written in english and in lowercase +5. "factor" will be used as multiplier for the applied hourly rate +6. Rate rules will be applied on stopped timesheet records only, as it can't be calculated before +7. There is no default rule active, by default the users hourly-rate is used for calculation + +You can configure the `hourly_rate` rules by changing the configuration file [kimai.yaml](../../config/packages/kimai.yaml). + +### Examples + +1. The "workdays" rule will use the default "hourly rate" for each timesheet entry recorded between "monday" to "friday" as a multiplication with 1 will not change the result +2. The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays" + +```yaml +kimai: + timesheet: + rates: + workdays: + days: ['monday','tuesday','wednesday','thursday','friday'] + factor: 1 + weekend: + days: ['saturday','sunday'] + factor: 1.5 +``` diff --git a/var/docs/developers.md b/var/docs/developers.md index 1ea19f3c..24e64fc1 100644 --- a/var/docs/developers.md +++ b/var/docs/developers.md @@ -46,7 +46,7 @@ You can find more information at: ### Rebuilding assets for use in subdirectory If you want to run Kimai in a subdirectory, you have to rebuild the frontend assets with a different webpack configuration. -Edit the file [webpack.config.js](https://github.com/kevinpapst/kimai2/blob/master/webpack.config.js) and change `.setPublicPath('/build/')` to your needs. +Edit the file [webpack.config.js](../../webpack.config.js) and change `.setPublicPath('/build/')` to your needs. After that re-compile the assets with: ```bash @@ -93,7 +93,7 @@ class MySubscriber implements EventSubscriberInterface } } ``` -For more details check the [official menu subscriber](https://github.com/kevinpapst/kimai2/blob/master/src/EventSubscriber/MenuSubscriber.php). +For more details check the [official menu subscriber](../../src/EventSubscriber/MenuSubscriber.php). ## Adding tabs to the "control sidebar" @@ -113,6 +113,72 @@ twig: template: sidebar/home.html.twig ``` -You have to define the icon to be used and then one of controller action or template. -Both follow the default naming syntax and you can easily link your bundle here instead of the official. -You should NOT add them in `config/packages/kimai.yaml` but only in your own bundle config. \ No newline at end of file +You have to define the `icon` (font-awesome without the `fa-` prefix) to be used and one of `controller` action or `template`. +Both follow the default naming syntax and you can easily link your bundle here instead of the app controller or templates. +You should NOT add them in `config/packages/kimai.yaml` but in your own bundle config, otherwise they might get lost in a Kimai update. + +## Adding invoice renderer + +An invoice renderer is a controller action that receives an instanceof `App\Model\InvoiceModel` and returns a HTML response. +This HTML response is the preview inside for the invoice screen. + +Adding invoice renderer can be achieved by adding keys to the configuration `kimai.invoice.renderer` like this: + +``` +kimai: + invoice: + renderer: + default: 'App\Controller\InvoiceController::invoiceAction' +``` + +The name of the renderer must be unique, please prefix it with your vendor or bundle name and make sure it only contains +character as it will be stored in a database column. + +## Adding invoice calculator + +An invoice calculator is a class extending `App\Invoice\CalculatorInterface` and is responsible for calculating +invoice rates, taxes and such. + +Adding invoice calculator can be achieved by adding keys to the configuration `kimai.invoice.calculator` like this: + +``` +kimai: + invoice: + calculator: + default: 'App\Invoice\DefaultCalculator' +``` + +The name of the calculator must be unique, please prefix it with your vendor or bundle name and make sure it only contains +character as it will be stored in a database column. + +## Adding invoice-number generator + +An invoice-number generator is a class extending `App\Invoice\NumberGeneratorInterface` and its only task is to generate +a number for the invoice. In most cases you do not want to mix multiple invoice-number generators througout your invoice templates. + +Adding invoice-number generator can be achieved by adding keys to the configuration `kimai.invoice.number_generator` like this: + +``` +kimai: + invoice: + number_generator: + default: 'App\Invoice\DateNumberGenerator' +``` + +The name of the number generator must be unique, please prefix it with your vendor or bundle name and make sure it only contains +character as it will be stored in a database column. + +## Adding timesheet calculator + +A timesheet calculator will be called on stopped timesheet records. It can rewrite all values but will normally take care +of the columns `begin`, `end`, `duration` and `rate` but could also be used to apply a default `description`. + +Timesheet calculator need to implement the interface `App\Timesheet\CalculatorInterface` and will be automatically tagged +as `timesheet.calculator` in the service container. They will be found and used *only* if you add them to the service container. + +You can apply several rules in the config file [kimai.yaml](../../config/packages/kimai.yaml) for the existing +`DurationCalculator` and `RateCalculator` implementations. Please read the [configurations chapter](configurations.md) to find out more. + +The configuration for "rounding rules" can be fetched from the container parameter `kimai.timesheet.rounding`. + +The configuration for "hourly-rates multiplication factors" can be fetched from the container parameter `kimai.timesheet.rates`. diff --git a/var/docs/users.md b/var/docs/users.md index 7bacdfa3..f2fb68fc 100644 --- a/var/docs/users.md +++ b/var/docs/users.md @@ -20,7 +20,8 @@ There are multiple pre-defined roles in Kimai, which define the ACLs. A user can ## Remember me login If you have chosen to login with the `Remember me` option, your login will be extended to one week (default value). -After coming back and being remembered you have access to all the following features: +After coming back and being remembered you have access to all the following features: + - view your own timesheet - start and stop new records - edit existing records @@ -29,4 +30,4 @@ If you are an administrator, you will see all your allowed options in the menu, form when you try to access them. This is a security feature to prevent abuse in case you forgot to logout in public environments. -The default period for the `Remember me` option can be changed in the config file [security.yaml](config/packages/security.yaml). +Read the [configurations chapter](configurations.md) if you want to change the value.