From c6c409875920ab22cb8a8e46ea5d76a886b7f764 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sun, 10 Nov 2019 13:57:05 +0100 Subject: [PATCH] rounding rules via admin screen, round begin when starting record (#1229) --- config/packages/kimai.yaml | 64 ++--- config/services.yaml | 10 +- phpstan.neon | 3 +- src/API/BaseApiController.php | 9 + src/API/TimesheetController.php | 12 +- src/Configuration/TimesheetConfiguration.php | 25 ++ src/Controller/AbstractController.php | 9 + .../SystemConfigurationController.php | 35 +++ .../TimesheetAbstractController.php | 1 - src/DependencyInjection/AppExtension.php | 7 +- src/DependencyInjection/Configuration.php | 18 +- src/Doctrine/TimesheetSubscriber.php | 10 +- src/Form/Model/SystemConfiguration.php | 1 + src/Form/Type/RoundingModeType.php | 56 ++++ src/Form/Type/WeekDaysType.php | 63 +++++ src/Kernel.php | 6 + .../Calculator/DurationCalculator.php | 47 +--- src/Timesheet/Rounding/CeilRounding.php | 7 +- src/Timesheet/Rounding/ClosestRounding.php | 7 +- src/Timesheet/Rounding/DefaultRounding.php | 7 +- src/Timesheet/Rounding/FloorRounding.php | 7 +- src/Timesheet/Rounding/RoundingInterface.php | 5 + src/Timesheet/RoundingService.php | 147 ++++++++++ src/Timesheet/TrackingMode/DefaultMode.php | 38 ++- .../TrackingMode/DurationFixedBeginMode.php | 6 +- .../TrackingMode/DurationOnlyMode.php | 2 +- src/Timesheet/TrackingMode/PunchInOutMode.php | 16 +- src/Timesheet/TrackingModeService.php | 27 +- .../SystemConfigurationControllerTest.php | 1 + .../DependencyInjection/AppExtensionTest.php | 22 +- .../DependencyInjection/ConfigurationTest.php | 10 +- tests/Doctrine/TimesheetSubscriberTest.php | 8 - tests/Mocks/RoundingServiceFactory.php | 51 ++++ tests/Mocks/TrackingModeServiceFactory.php | 45 ++++ .../Calculator/DurationCalculatorTest.php | 46 ++-- .../Calculator/RateCalculatorTest.php | 2 +- tests/Timesheet/Rounding/CeilRoundingTest.php | 1 + .../Rounding/ClosestRoundingTest.php | 1 + .../Rounding/DefaultRoundingTest.php | 1 + .../Timesheet/Rounding/FloorRoundingTest.php | 1 + tests/Timesheet/RoundingServiceTest.php | 251 ++++++++++++++++++ .../TrackingMode/DefaultModeTest.php | 10 +- .../TrackingMode/PunchInOutModeTest.php | 24 +- tests/Timesheet/TrackingModeServiceTest.php | 23 +- .../Constraints/TimesheetValidatorTest.php | 7 +- translations/system-configuration.de.xliff | 68 +++++ translations/system-configuration.en.xliff | 68 +++++ 47 files changed, 1100 insertions(+), 185 deletions(-) create mode 100644 src/Form/Type/RoundingModeType.php create mode 100644 src/Form/Type/WeekDaysType.php create mode 100644 src/Timesheet/RoundingService.php create mode 100644 tests/Mocks/RoundingServiceFactory.php create mode 100644 tests/Mocks/TrackingModeServiceFactory.php create mode 100644 tests/Timesheet/RoundingServiceTest.php diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 92f0a791..7d2ebc5f 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -21,11 +21,13 @@ kimai: # -------------------------------------------------------------------------------- # TIME-TRACKING # All configs related to time-tracking, timesheets and record management +# Most settings can be configured in the System configuration screen # -------------------------------------------------------------------------------- timesheet: - # renders timesheet descriptions with markdown - markdown_content: false + # Allows to render timesheet descriptions with markdown + # This setting can be changed through the Administration screen + # markdown_content: false # The time-tracking mode that should be used. # See https://www.kimai.org/documentation/timesheet.html#tracking-modes @@ -42,36 +44,36 @@ kimai: # 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. # Find out more about rounding rules at https://www.kimai.org/documentation/timesheet.html - rounding: - default: - days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] - begin: 1 - end: 1 - duration: 0 - mode: default + # rounding: + # default: + # days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] + # begin: 1 + # end: 1 + # duration: 0 + # mode: default -# # 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". -# # See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation -# rates: -# weekend: -# days: ['saturday','sunday'] -# factor: 1.5 -# -# # If you want to limit the max. active entries per user, you can do it here. -# # The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings -# # The hard_limit is used to detect how many active records are allowed per user: -# # - by default a user can only have one active time-record: it is automatically stopped when a new one is started -# # - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown -# # and the user has to stop an active entry first -# active_entries: -# soft_limit: 1 -# hard_limit: 3 -# -# # Rules that define timesheet validation and behaviour -# rules: -# # whether records in the future can be created -# allow_future_times: true + # 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". + # See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation + # rates: + # weekend: + # days: ['saturday','sunday'] + # factor: 1.5 + + # If you want to limit the max. active entries per user, you can do it here. + # The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings + # The hard_limit is used to detect how many active records are allowed per user: + # - by default a user can only have one active time-record: it is automatically stopped when a new one is started + # - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown + # and the user has to stop an active entry first + # active_entries: + # soft_limit: 1 + # hard_limit: 3 + + # Rules that define timesheet validation and behaviour + # allow_future_times: whether records in the future can be created + # rules: + # allow_future_times: true # -------------------------------------------------------------------------------- diff --git a/config/services.yaml b/config/services.yaml index 4a8dbd71..7eaac5d7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -118,12 +118,18 @@ services: # TIMESHEET RECORD CALCULATOR # ================================================================================ - App\Timesheet\Calculator\DurationCalculator: - arguments: ['%kimai.timesheet.rounding%'] + App\Timesheet\RoundingService: + arguments: + $roundingModes: !tagged timesheet.rounding_mode + $rules: '%kimai.timesheet.rounding%' App\Timesheet\Calculator\RateCalculator: arguments: ['%kimai.timesheet.rates%'] + App\Timesheet\TrackingModeService: + arguments: + $modes: !tagged timesheet.tracking_mode + # ================================================================================ # SECURITY & VOTER # ================================================================================ diff --git a/phpstan.neon b/phpstan.neon index 9ab5cbb4..2ccdaeee 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,7 +17,8 @@ parameters: - '#Access to an undefined property Faker\\Generator::\$stateAbbr.#' - '#Access to an undefined property Faker\\Generator::\$catchPhrase.#' - '#Access to an undefined property Faker\\Generator::\$bs.#' - - '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#' + - '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#' + - '#Method App\\Controller\\AbstractController::getUser\(\) should return App\\Entity\\User|null but returns object|null. #' excludes_analyse: - %rootDir%/../../../src/Command/KimaiImporterCommand.php - %rootDir%/../../../src/Ldap/LdapDriver.php diff --git a/src/API/BaseApiController.php b/src/API/BaseApiController.php index 03afd668..def20140 100644 --- a/src/API/BaseApiController.php +++ b/src/API/BaseApiController.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace App\API; +use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; @@ -18,4 +19,12 @@ abstract class BaseApiController extends AbstractController { public const DATE_FORMAT = DateTimeType::HTML5_FORMAT; public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s'; + + /** + * @return User|null + */ + protected function getUser() + { + return parent::getUser(); + } } diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 0acb4881..c3631bb6 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -19,6 +19,7 @@ use App\Form\API\TimesheetApiEditForm; use App\Repository\Query\TimesheetQuery; use App\Repository\TagRepository; use App\Repository\TimesheetRepository; +use App\Timesheet\RoundingService; use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingModeService; use App\Timesheet\UserDateTimeFactory; @@ -75,6 +76,10 @@ class TimesheetController extends BaseApiController * @var EventDispatcherInterface */ private $dispatcher; + /** + * @var RoundingService + */ + private $roundingService; public function __construct( ViewHandlerInterface $viewHandler, @@ -83,7 +88,8 @@ class TimesheetController extends BaseApiController TimesheetConfiguration $configuration, TagRepository $tagRepository, TrackingModeService $trackingModeService, - EventDispatcherInterface $dispatcher + EventDispatcherInterface $dispatcher, + RoundingService $roundingService ) { $this->viewHandler = $viewHandler; $this->repository = $repository; @@ -92,6 +98,7 @@ class TimesheetController extends BaseApiController $this->tagRepository = $tagRepository; $this->trackingModeService = $trackingModeService; $this->dispatcher = $dispatcher; + $this->roundingService = $roundingService; } protected function getTrackingMode(): TrackingModeInterface @@ -293,12 +300,12 @@ class TimesheetController extends BaseApiController { $timesheet = new Timesheet(); $timesheet->setUser($this->getUser()); - $timesheet->setBegin($this->dateTime->createDateTime()); $event = new TimesheetMetaDefinitionEvent($timesheet); $this->dispatcher->dispatch($event); $mode = $this->getTrackingMode(); + $mode->create($timesheet, $request); $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ 'include_rate' => $this->isGranted('edit_rate', $timesheet), @@ -608,6 +615,7 @@ class TimesheetController extends BaseApiController ->setActivity($timesheet->getActivity()) ->setProject($timesheet->getProject()) ; + $this->roundingService->roundBegin($copyTimesheet); if (null !== ($copy = $paramFetcher->get('copy'))) { if (in_array($copy, ['rates', 'all'])) { diff --git a/src/Configuration/TimesheetConfiguration.php b/src/Configuration/TimesheetConfiguration.php index df6548f8..19d26eb5 100644 --- a/src/Configuration/TimesheetConfiguration.php +++ b/src/Configuration/TimesheetConfiguration.php @@ -47,4 +47,29 @@ class TimesheetConfiguration implements SystemBundleConfiguration { return (int) $this->find('active_entries.soft_limit'); } + + public function getDefaultRoundingDays(): string + { + return (string) $this->find('rounding.default.days'); + } + + public function getDefaultRoundingMode(): string + { + return (string) $this->find('rounding.default.mode'); + } + + public function getDefaultRoundingBegin(): int + { + return (int) $this->find('rounding.default.begin'); + } + + public function getDefaultRoundingEnd(): int + { + return (int) $this->find('rounding.default.end'); + } + + public function getDefaultRoundingDuration(): int + { + return (int) $this->find('rounding.default.duration'); + } } diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index cafc98c4..c7354443 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -9,6 +9,7 @@ namespace App\Controller; +use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController; use Symfony\Component\Translation\DataCollectorTranslator; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -36,6 +37,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv return $this->container->get('translator'); } + /** + * @return User|null + */ + protected function getUser() + { + return parent::getUser(); + } + /** * Adds a "successful" flash message to the stack. * diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php index 8372df6e..501e85cf 100644 --- a/src/Controller/SystemConfigurationController.php +++ b/src/Controller/SystemConfigurationController.php @@ -16,8 +16,10 @@ use App\Form\Model\SystemConfiguration as SystemConfigurationModel; use App\Form\SystemConfigurationForm; use App\Form\Type\EnhancedSelectboxType; use App\Form\Type\LanguageType; +use App\Form\Type\RoundingModeType; use App\Form\Type\SkinType; use App\Form\Type\TrackingModeType; +use App\Form\Type\WeekDaysType; use App\Repository\ConfigurationRepository; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -215,6 +217,39 @@ class SystemConfigurationController extends AbstractController new GreaterThanOrEqual(['value' => 1]) ]), ]), + (new SystemConfigurationModel()) + ->setSection(SystemConfigurationModel::SECTION_ROUNDING) + ->setConfiguration([ + (new Configuration()) + ->setName('timesheet.rounding.default.mode') + ->setType(RoundingModeType::class) + ->setTranslationDomain('system-configuration'), + (new Configuration()) + ->setName('timesheet.rounding.default.begin') + ->setType(IntegerType::class) + ->setTranslationDomain('system-configuration') + ->setConstraints([ + new GreaterThanOrEqual(['value' => 0]) + ]), + (new Configuration()) + ->setName('timesheet.rounding.default.end') + ->setType(IntegerType::class) + ->setTranslationDomain('system-configuration') + ->setConstraints([ + new GreaterThanOrEqual(['value' => 0]) + ]), + (new Configuration()) + ->setName('timesheet.rounding.default.duration') + ->setType(IntegerType::class) + ->setTranslationDomain('system-configuration') + ->setConstraints([ + new GreaterThanOrEqual(['value' => 0]) + ]), + (new Configuration()) + ->setName('timesheet.rounding.default.days') + ->setType(WeekDaysType::class) + ->setTranslationDomain('system-configuration'), + ]), (new SystemConfigurationModel()) ->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER) ->setConfiguration([ diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index 6ff1e6eb..09c474cd 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -178,7 +178,6 @@ abstract class TimesheetAbstractController extends AbstractController { $entry = new Timesheet(); $entry->setUser($this->getUser()); - $entry->setBegin($this->dateTime->createDateTime()); if ($request->query->get('project')) { $project = $projectRepository->find($request->query->get('project')); diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index 7777635b..60c4b3a4 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -36,10 +36,15 @@ class AppExtension extends Extension if (isset($config['timesheet']['duration_only'])) { @trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED); if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) { - trigger_error('Found ambiguous configuration. Please remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); + trigger_error('Found ambiguous configuration: remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); } } + // we use a comma sepearated string internally, to be able to use it in combination with the database configuration system + foreach ($config['timesheet']['rounding'] as $name => $settings) { + $config['timesheet']['rounding'][$name]['days'] = implode(',', $settings['days']); + } + // safe alternatives to %kernel.project_dir% $container->setParameter('kimai.data_dir', $config['data_dir']); $container->setParameter('kimai.plugin_dir', $config['plugin_dir']); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index bb321efb..cc216da4 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -98,17 +98,15 @@ class Configuration implements ConfigurationInterface ->arrayPrototype() ->children() ->arrayNode('days') - ->requiresAtLeastOneElement() ->useAttributeAsKey('key') - ->isRequired() ->scalarPrototype()->end() - ->defaultValue([]) + ->defaultValue(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']) ->end() ->integerNode('begin') - ->defaultValue(0) + ->defaultValue(1) ->end() ->integerNode('end') - ->defaultValue(0) + ->defaultValue(1) ->end() ->integerNode('duration') ->defaultValue(0) @@ -131,7 +129,15 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() - ->defaultValue([]) + ->defaultValue([ + 'default' => [ + 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'begin' => 1, + 'end' => 1, + 'duration' => 0, + 'mode' => 'default' + ] + ]) ->end() ->arrayNode('rates') ->requiresAtLeastOneElement() diff --git a/src/Doctrine/TimesheetSubscriber.php b/src/Doctrine/TimesheetSubscriber.php index 97f2c845..32646ee7 100644 --- a/src/Doctrine/TimesheetSubscriber.php +++ b/src/Doctrine/TimesheetSubscriber.php @@ -26,18 +26,10 @@ class TimesheetSubscriber implements EventSubscriber protected $calculator; /** - * @param iterable $calculators + * @param CalculatorInterface[] $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; } diff --git a/src/Form/Model/SystemConfiguration.php b/src/Form/Model/SystemConfiguration.php index e9a2b468..256dc4bc 100644 --- a/src/Form/Model/SystemConfiguration.php +++ b/src/Form/Model/SystemConfiguration.php @@ -11,6 +11,7 @@ namespace App\Form\Model; class SystemConfiguration { + public const SECTION_ROUNDING = 'rounding'; public const SECTION_TIMESHEET = 'timesheet'; public const SECTION_FORM_CUSTOMER = 'form_customer'; public const SECTION_FORM_USER = 'form_user'; diff --git a/src/Form/Type/RoundingModeType.php b/src/Form/Type/RoundingModeType.php new file mode 100644 index 00000000..27c2085f --- /dev/null +++ b/src/Form/Type/RoundingModeType.php @@ -0,0 +1,56 @@ +service = $service; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $choices = []; + + foreach ($this->service->getRoundingModes() as $mode) { + $id = $mode->getId(); + $choices[ucfirst($id)] = $id; + } + + $resolver->setDefaults([ + 'choices' => $choices, + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/Form/Type/WeekDaysType.php b/src/Form/Type/WeekDaysType.php new file mode 100644 index 00000000..dbfe5dbd --- /dev/null +++ b/src/Form/Type/WeekDaysType.php @@ -0,0 +1,63 @@ +addModelTransformer(new CallbackTransformer( + function ($weekdays) { + return explode(',', $weekdays); + }, + function ($weekdays) { + return implode(',', $weekdays); + } + )); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $choices = [ + 'Monday' => 'monday', + 'Tuesday' => 'tuesday', + 'Wednesday' => 'wednesday', + 'Thursday' => 'thursday', + 'Friday' => 'friday', + 'Saturday' => 'saturday', + 'Sunday' => 'sunday' + ]; + + $resolver->setDefaults([ + 'multiple' => true, + 'choices' => $choices, + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/Kernel.php b/src/Kernel.php index d9fd9f1a..3ec121ff 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -24,6 +24,8 @@ use App\Invoice\RendererInterface as InvoiceRendererInterface; use App\Ldap\FormLoginLdapFactory; use App\Plugin\PluginInterface; use App\Timesheet\CalculatorInterface as TimesheetCalculator; +use App\Timesheet\Rounding\RoundingInterface; +use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Widget\WidgetInterface; use App\Widget\WidgetRendererInterface; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; @@ -52,6 +54,8 @@ class Kernel extends BaseKernel public const TAG_INVOICE_REPOSITORY = 'invoice.repository'; public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator'; public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter'; + public const TAG_TIMESHEET_TRACKING_MODE = 'timesheet.tracking_mode'; + public const TAG_TIMESHEET_ROUNDING_MODE = 'timesheet.rounding_mode'; public function getCacheDir() { @@ -75,6 +79,8 @@ class Kernel extends BaseKernel $container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER); $container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET); $container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER); + $container->registerForAutoconfiguration(TrackingModeInterface::class)->addTag(self::TAG_TIMESHEET_TRACKING_MODE); + $container->registerForAutoconfiguration(RoundingInterface::class)->addTag(self::TAG_TIMESHEET_ROUNDING_MODE); /** @var SecurityExtension $extension */ $extension = $container->getExtension('security'); diff --git a/src/Timesheet/Calculator/DurationCalculator.php b/src/Timesheet/Calculator/DurationCalculator.php index 94dff3b0..fea75b30 100644 --- a/src/Timesheet/Calculator/DurationCalculator.php +++ b/src/Timesheet/Calculator/DurationCalculator.php @@ -11,26 +11,19 @@ namespace App\Timesheet\Calculator; use App\Entity\Timesheet; use App\Timesheet\CalculatorInterface; -use App\Timesheet\Rounding\RoundingInterface; +use App\Timesheet\RoundingService; /** * 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 +final class DurationCalculator implements CalculatorInterface { /** - * @var array + * @var RoundingService */ - protected $roundings; + private $roundings; - /** - * DurationCalculator constructor. - * @param array $roundings - */ - public function __construct(array $roundings) + public function __construct(RoundingService $roundings) { $this->roundings = $roundings; } @@ -44,37 +37,9 @@ class DurationCalculator implements CalculatorInterface 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)) { - $class = 'App\\Timesheet\\Rounding\\' . ucfirst($rounding['mode']) . 'Rounding'; - /* @var $rounder RoundingInterface */ - $rounder = new $class(); - $rounder->roundBegin($record, $rounding['begin']); - $rounder->roundEnd($record, $rounding['end']); - $this->applyDuration($record); - $rounder->roundDuration($record, $rounding['duration']); - } - } + $this->roundings->applyRoundings($record); } } diff --git a/src/Timesheet/Rounding/CeilRounding.php b/src/Timesheet/Rounding/CeilRounding.php index c5fefa9f..41f4f21d 100644 --- a/src/Timesheet/Rounding/CeilRounding.php +++ b/src/Timesheet/Rounding/CeilRounding.php @@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding; use App\Entity\Timesheet; -class CeilRounding implements RoundingInterface +final class CeilRounding implements RoundingInterface { + public function getId(): string + { + return 'ceil'; + } + /** * @param Timesheet $record * @param int $minutes diff --git a/src/Timesheet/Rounding/ClosestRounding.php b/src/Timesheet/Rounding/ClosestRounding.php index 8e2fb768..95eb8f62 100644 --- a/src/Timesheet/Rounding/ClosestRounding.php +++ b/src/Timesheet/Rounding/ClosestRounding.php @@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding; use App\Entity\Timesheet; -class ClosestRounding implements RoundingInterface +final class ClosestRounding implements RoundingInterface { + public function getId(): string + { + return 'closest'; + } + /** * @param Timesheet $record * @param int $minutes diff --git a/src/Timesheet/Rounding/DefaultRounding.php b/src/Timesheet/Rounding/DefaultRounding.php index 2dbdb5e3..1b66eefa 100644 --- a/src/Timesheet/Rounding/DefaultRounding.php +++ b/src/Timesheet/Rounding/DefaultRounding.php @@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding; use App\Entity\Timesheet; -class DefaultRounding implements RoundingInterface +final class DefaultRounding implements RoundingInterface { + public function getId(): string + { + return 'default'; + } + /** * @param Timesheet $record * @param int $minutes diff --git a/src/Timesheet/Rounding/FloorRounding.php b/src/Timesheet/Rounding/FloorRounding.php index 3e360885..278be874 100644 --- a/src/Timesheet/Rounding/FloorRounding.php +++ b/src/Timesheet/Rounding/FloorRounding.php @@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding; use App\Entity\Timesheet; -class FloorRounding implements RoundingInterface +final class FloorRounding implements RoundingInterface { + public function getId(): string + { + return 'floor'; + } + /** * @param Timesheet $record * @param int $minutes diff --git a/src/Timesheet/Rounding/RoundingInterface.php b/src/Timesheet/Rounding/RoundingInterface.php index 0be213f6..a92f1a25 100644 --- a/src/Timesheet/Rounding/RoundingInterface.php +++ b/src/Timesheet/Rounding/RoundingInterface.php @@ -33,4 +33,9 @@ interface RoundingInterface * @param int $minutes */ public function roundDuration(Timesheet $record, $minutes); + + /** + * @return string + */ + public function getId(): string; } diff --git a/src/Timesheet/RoundingService.php b/src/Timesheet/RoundingService.php new file mode 100644 index 00000000..078ba749 --- /dev/null +++ b/src/Timesheet/RoundingService.php @@ -0,0 +1,147 @@ +configuration = $configuration; + $this->roundingModes = $roundingModes; + $this->rules = $rules; + } + + private function getRoundingRules(): array + { + if (empty($this->rulesCache)) { + $this->rulesCache = $this->rules; + if (empty($this->rulesCache) || array_key_exists('default', $this->rulesCache)) { + $this->rulesCache['default']['days'] = $this->configuration->getDefaultRoundingDays(); + $this->rulesCache['default']['begin'] = $this->configuration->getDefaultRoundingBegin(); + $this->rulesCache['default']['end'] = $this->configuration->getDefaultRoundingEnd(); + $this->rulesCache['default']['duration'] = $this->configuration->getDefaultRoundingDuration(); + $this->rulesCache['default']['mode'] = $this->configuration->getDefaultRoundingMode(); + } + + // see AppExtension, conversion from string to array due to system configuration ont allowing to store arrays + foreach ($this->rulesCache as $key => $settings) { + $days = explode(',', $settings['days']); + $days = array_map('trim', $days); + $days = array_map('strtolower', $days); + $this->rulesCache[$key]['days'] = $days; + } + } + + return $this->rulesCache; + } + + public function roundBegin(Timesheet $record): void + { + foreach ($this->getRoundingRules() as $rounding) { + $weekday = $record->getBegin()->format('l'); + + if (in_array(strtolower($weekday), $rounding['days'])) { + $rounder = $this->getRoundingMode($rounding['mode']); + $rounder->roundBegin($record, $rounding['begin']); + } + } + } + + public function roundEnd(Timesheet $record): void + { + foreach ($this->getRoundingRules() as $rounding) { + $weekday = $record->getEnd()->format('l'); + + if (in_array(strtolower($weekday), $rounding['days'])) { + $rounder = $this->getRoundingMode($rounding['mode']); + $rounder->roundEnd($record, $rounding['end']); + } + } + } + + public function roundDuration(Timesheet $record): void + { + foreach ($this->getRoundingRules() as $rounding) { + $weekday = $record->getEnd()->format('l'); + + if (in_array(strtolower($weekday), $rounding['days'])) { + $rounder = $this->getRoundingMode($rounding['mode']); + $rounder->roundDuration($record, $rounding['duration']); + } + } + } + + public function applyRoundings(Timesheet $record): void + { + if (null === $record->getEnd()) { + return; + } + + foreach ($this->getRoundingRules() as $rounding) { + $weekday = $record->getEnd()->format('l'); + + if (in_array(strtolower($weekday), $rounding['days'])) { + $rounder = $this->getRoundingMode($rounding['mode']); + $rounder->roundBegin($record, $rounding['begin']); + $rounder->roundEnd($record, $rounding['end']); + + $duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp(); + $record->setDuration($duration); + + $rounder->roundDuration($record, $rounding['duration']); + } + } + } + + /** + * @return RoundingInterface[] + */ + public function getRoundingModes(): iterable + { + return $this->roundingModes; + } + + public function getRoundingMode(string $id): RoundingInterface + { + foreach ($this->roundingModes as $mode) { + if ($mode->getId() === $id) { + return $mode; + } + } + + throw new \InvalidArgumentException('Unknown rounding mode: ' . $id); + } +} diff --git a/src/Timesheet/TrackingMode/DefaultMode.php b/src/Timesheet/TrackingMode/DefaultMode.php index 9a01a1fd..43083b27 100644 --- a/src/Timesheet/TrackingMode/DefaultMode.php +++ b/src/Timesheet/TrackingMode/DefaultMode.php @@ -9,8 +9,25 @@ namespace App\Timesheet\TrackingMode; -class DefaultMode extends AbstractTrackingMode +use App\Configuration\TimesheetConfiguration; +use App\Entity\Timesheet; +use App\Timesheet\RoundingService; +use App\Timesheet\UserDateTimeFactory; +use Symfony\Component\HttpFoundation\Request; + +final class DefaultMode extends AbstractTrackingMode { + /** + * @var RoundingService + */ + private $rounding; + + public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration, RoundingService $rounding) + { + parent::__construct($dateTime, $configuration); + $this->rounding = $rounding; + } + public function canEditBegin(): bool { return true; @@ -40,4 +57,23 @@ class DefaultMode extends AbstractTrackingMode { return true; } + + public function create(Timesheet $timesheet, Request $request): void + { + parent::create($timesheet, $request); + + if (null === $timesheet->getBegin()) { + $timesheet->setBegin($this->dateTime->createDateTime()); + } + + $this->rounding->roundBegin($timesheet); + + if (null !== $timesheet->getEnd()) { + $this->rounding->roundEnd($timesheet); + + if (null !== $timesheet->getDuration()) { + $this->rounding->roundDuration($timesheet); + } + } + } } diff --git a/src/Timesheet/TrackingMode/DurationFixedBeginMode.php b/src/Timesheet/TrackingMode/DurationFixedBeginMode.php index 09776195..15ad9118 100644 --- a/src/Timesheet/TrackingMode/DurationFixedBeginMode.php +++ b/src/Timesheet/TrackingMode/DurationFixedBeginMode.php @@ -14,16 +14,16 @@ use App\Entity\Timesheet; use App\Timesheet\UserDateTimeFactory; use Symfony\Component\HttpFoundation\Request; -class DurationFixedBeginMode implements TrackingModeInterface +final class DurationFixedBeginMode implements TrackingModeInterface { /** * @var UserDateTimeFactory */ - protected $dateTime; + private $dateTime; /** * @var TimesheetConfiguration */ - protected $configuration; + private $configuration; public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration) { diff --git a/src/Timesheet/TrackingMode/DurationOnlyMode.php b/src/Timesheet/TrackingMode/DurationOnlyMode.php index 2c1ef29d..f3025dfc 100644 --- a/src/Timesheet/TrackingMode/DurationOnlyMode.php +++ b/src/Timesheet/TrackingMode/DurationOnlyMode.php @@ -12,7 +12,7 @@ namespace App\Timesheet\TrackingMode; use App\Entity\Timesheet; use Symfony\Component\HttpFoundation\Request; -class DurationOnlyMode extends AbstractTrackingMode +final class DurationOnlyMode extends AbstractTrackingMode { public function canEditBegin(): bool { diff --git a/src/Timesheet/TrackingMode/PunchInOutMode.php b/src/Timesheet/TrackingMode/PunchInOutMode.php index 680b1a44..87cd7432 100644 --- a/src/Timesheet/TrackingMode/PunchInOutMode.php +++ b/src/Timesheet/TrackingMode/PunchInOutMode.php @@ -10,10 +10,21 @@ namespace App\Timesheet\TrackingMode; use App\Entity\Timesheet; +use App\Timesheet\UserDateTimeFactory; use Symfony\Component\HttpFoundation\Request; -class PunchInOutMode implements TrackingModeInterface +final class PunchInOutMode implements TrackingModeInterface { + /** + * @var UserDateTimeFactory + */ + private $dateTime; + + public function __construct(UserDateTimeFactory $dateTime) + { + $this->dateTime = $dateTime; + } + public function canEditBegin(): bool { return false; @@ -36,6 +47,9 @@ class PunchInOutMode implements TrackingModeInterface public function create(Timesheet $timesheet, Request $request): void { + if (null === $timesheet->getBegin()) { + $timesheet->setBegin($this->dateTime->createDateTime()); + } } public function getId(): string diff --git a/src/Timesheet/TrackingModeService.php b/src/Timesheet/TrackingModeService.php index 1cfb5f71..54c0b67f 100644 --- a/src/Timesheet/TrackingModeService.php +++ b/src/Timesheet/TrackingModeService.php @@ -10,28 +10,28 @@ namespace App\Timesheet; use App\Configuration\TimesheetConfiguration; -use App\Timesheet\TrackingMode\DefaultMode; -use App\Timesheet\TrackingMode\DurationFixedBeginMode; -use App\Timesheet\TrackingMode\DurationOnlyMode; -use App\Timesheet\TrackingMode\PunchInOutMode; use App\Timesheet\TrackingMode\TrackingModeInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; -class TrackingModeService +final class TrackingModeService { /** - * @var UserDateTimeFactory + * @var TrackingModeInterface[] */ - protected $dateTime; + private $modes = []; /** * @var TimesheetConfiguration */ - protected $configuration; + private $configuration; - public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration) + /** + * @param TimesheetConfiguration $configuration + * @param TrackingModeInterface[] $modes + */ + public function __construct(TimesheetConfiguration $configuration, iterable $modes) { - $this->dateTime = $dateTime; $this->configuration = $configuration; + $this->modes = $modes; } /** @@ -39,12 +39,7 @@ class TrackingModeService */ public function getModes(): iterable { - return [ - new DefaultMode($this->dateTime, $this->configuration), - new PunchInOutMode(), - new DurationOnlyMode($this->dateTime, $this->configuration), - new DurationFixedBeginMode($this->dateTime, $this->configuration), - ]; + return $this->modes; } public function getActiveMode(): TrackingModeInterface diff --git a/tests/Controller/SystemConfigurationControllerTest.php b/tests/Controller/SystemConfigurationControllerTest.php index 0da9e63a..a01c9bfa 100644 --- a/tests/Controller/SystemConfigurationControllerTest.php +++ b/tests/Controller/SystemConfigurationControllerTest.php @@ -49,6 +49,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest { return [ ['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')], + ['form[name=system_configuration_form_rounding]', $this->createUrl('/admin/system-config/update/rounding')], ['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')], ['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')], ['form[name=system_configuration_form_theme]', $this->createUrl('/admin/system-config/update/theme')], diff --git a/tests/DependencyInjection/AppExtensionTest.php b/tests/DependencyInjection/AppExtensionTest.php index 0ef7896f..a892ab17 100644 --- a/tests/DependencyInjection/AppExtensionTest.php +++ b/tests/DependencyInjection/AppExtensionTest.php @@ -129,7 +129,15 @@ class AppExtensionTest extends TestCase 'kimai.timesheet' => [ 'mode' => 'default', 'markdown_content' => false, - 'rounding' => [], + 'rounding' => [ + 'default' => [ + 'begin' => 1, + 'end' => 1, + 'duration' => 0, + 'mode' => 'default', + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday' + ] + ], 'rates' => [], 'active_entries' => [ 'soft_limit' => 1, @@ -141,7 +149,15 @@ class AppExtensionTest extends TestCase 'default_begin' => 'now', ], 'kimai.timesheet.rates' => [], - 'kimai.timesheet.rounding' => [], + 'kimai.timesheet.rounding' => [ + 'default' => [ + 'begin' => 1, + 'end' => 1, + 'duration' => 0, + 'mode' => 'default', + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday' + ] + ], 'kimai.ldap' => [ 'user' => [ 'baseDn' => null, @@ -240,7 +256,7 @@ class AppExtensionTest extends TestCase public function testDurationOnlyDeprecationIsTriggered() { $this->expectException(Notice::class); - $this->expectExceptionMessage('Found ambiguous configuration. Please remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); + $this->expectExceptionMessage('Found ambiguous configuration: remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); $minConfig = $this->getMinConfig(); $minConfig['kimai']['timesheet']['duration_only'] = true; diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index f93fd0f1..f0242942 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -220,7 +220,15 @@ class ConfigurationTest extends TestCase 'default_begin' => 'now', 'mode' => 'default', 'markdown_content' => false, - 'rounding' => [], + 'rounding' => [ + 'default' => [ + 'begin' => 1, + 'end' => 1, + 'duration' => 0, + 'mode' => 'default', + 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + ] + ], 'rates' => [], 'active_entries' => [ 'soft_limit' => 1, diff --git a/tests/Doctrine/TimesheetSubscriberTest.php b/tests/Doctrine/TimesheetSubscriberTest.php index e5862cdb..be108046 100644 --- a/tests/Doctrine/TimesheetSubscriberTest.php +++ b/tests/Doctrine/TimesheetSubscriberTest.php @@ -24,12 +24,4 @@ class TimesheetSubscriberTest extends TestCase $events = $sut->getSubscribedEvents(); $this->assertTrue(in_array(Events::onFlush, $events)); } - - public function testConstructThrowsExceptionOnInvalidParam() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid TimesheetCalculator implementation given. Expected CalculatorInterface but received stdClass'); - - new TimesheetSubscriber([new \stdClass()]); - } } diff --git a/tests/Mocks/RoundingServiceFactory.php b/tests/Mocks/RoundingServiceFactory.php new file mode 100644 index 00000000..d156e4f7 --- /dev/null +++ b/tests/Mocks/RoundingServiceFactory.php @@ -0,0 +1,51 @@ + [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'begin' => 0, + 'end' => 0, + 'duration' => 0, + 'mode' => 'default' + ] + ]; + } + + $configuration = new TimesheetConfiguration($loader, [ + 'rounding' => $rules + ]); + + $modes = [ + new CeilRounding(), + new ClosestRounding(), + new DefaultRounding(), + new FloorRounding(), + ]; + + return new RoundingService($configuration, $modes, $rules); + } +} diff --git a/tests/Mocks/TrackingModeServiceFactory.php b/tests/Mocks/TrackingModeServiceFactory.php new file mode 100644 index 00000000..d4602aec --- /dev/null +++ b/tests/Mocks/TrackingModeServiceFactory.php @@ -0,0 +1,45 @@ +getTestCase()))->create(); + $loader = new TestConfigLoader([]); + + $configuration = new TimesheetConfiguration($loader, ['mode' => $mode]); + + if (null === $modes) { + $modes = [ + new DefaultMode($dateTime, $configuration, (new RoundingServiceFactory($this->getTestCase()))->create()), + new PunchInOutMode($dateTime), + new DurationOnlyMode($dateTime, $configuration), + new DurationFixedBeginMode($dateTime, $configuration), + ]; + } + + return new TrackingModeService($configuration, $modes); + } +} diff --git a/tests/Timesheet/Calculator/DurationCalculatorTest.php b/tests/Timesheet/Calculator/DurationCalculatorTest.php index 8d29c380..f8fa34d3 100644 --- a/tests/Timesheet/Calculator/DurationCalculatorTest.php +++ b/tests/Timesheet/Calculator/DurationCalculatorTest.php @@ -10,11 +10,13 @@ namespace App\Tests\Timesheet\Calculator; use App\Entity\Timesheet; +use App\Tests\Mocks\RoundingServiceFactory; use App\Timesheet\Calculator\DurationCalculator; use PHPUnit\Framework\TestCase; /** * @covers \App\Timesheet\Calculator\DurationCalculator + * @covers \App\Timesheet\RoundingService */ class DurationCalculatorTest extends TestCase { @@ -24,7 +26,7 @@ class DurationCalculatorTest extends TestCase $record->setBegin(new \DateTime()); $this->assertEquals(0, $record->getDuration()); - $sut = new DurationCalculator([]); + $sut = new DurationCalculator((new RoundingServiceFactory($this))->create()); $sut->calculate($record); $this->assertEquals(0, $record->getDuration()); } @@ -39,7 +41,7 @@ class DurationCalculatorTest extends TestCase $record->setEnd($end); $this->assertEquals(0, $record->getDuration()); - $sut = new DurationCalculator($rules); + $sut = new DurationCalculator((new RoundingServiceFactory($this))->create($rules)); $sut->calculate($record); $this->assertEquals($expectedDuration, $record->getDuration()); } @@ -52,7 +54,7 @@ class DurationCalculatorTest extends TestCase return [ [ - [], + null, $start, (clone $start)->setTimestamp($start->getTimestamp() + 1837), 1837 @@ -60,7 +62,7 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 15, 'end' => 15, 'duration' => 0, @@ -74,7 +76,7 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 0, 'end' => 0, 'duration' => 0, @@ -88,7 +90,7 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 1, 'end' => 1, 'duration' => 0, @@ -102,7 +104,7 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 0, 'end' => 0, 'duration' => 30, @@ -116,14 +118,14 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 15, 'end' => 0, 'duration' => 0, 'mode' => 'default', ], - 'weekdays' => [ - 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', 'begin' => 0, 'end' => 1, 'duration' => 30, @@ -137,14 +139,14 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 15, 'end' => 0, 'duration' => 30, 'mode' => 'default', ], - 'weekdays' => [ - 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', 'begin' => 0, 'end' => 1, 'duration' => 0, @@ -158,14 +160,14 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 0, 'end' => 0, 'duration' => 1, 'mode' => 'default', ], - 'weekdays' => [ - 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', 'begin' => 0, 'end' => 0, 'duration' => 1, @@ -179,14 +181,14 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 1, 'end' => 1, 'duration' => 1, 'mode' => 'default', ], - 'weekdays' => [ - 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', 'begin' => 1, 'end' => 1, 'duration' => 1, @@ -200,14 +202,14 @@ class DurationCalculatorTest extends TestCase [ [ 'default' => [ - 'days' => [$day], + 'days' => $day, 'begin' => 0, 'end' => 0, 'duration' => 0, 'mode' => 'default', ], - 'weekdays' => [ - 'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', 'begin' => 0, 'end' => 0, 'duration' => 0, diff --git a/tests/Timesheet/Calculator/RateCalculatorTest.php b/tests/Timesheet/Calculator/RateCalculatorTest.php index f6aab3ab..4b744b9f 100644 --- a/tests/Timesheet/Calculator/RateCalculatorTest.php +++ b/tests/Timesheet/Calculator/RateCalculatorTest.php @@ -215,7 +215,7 @@ class RateCalculatorTest extends TestCase 'days' => [$day], 'factor' => 2.0 ], - 'weekdays' => [ + 'foo' => [ 'days' => ['MonDay', 'tUEsdAy', 'WEdnesday', 'THursday', 'friDay', 'SATURday', 'sunDAY'], 'factor' => 1.5 ], diff --git a/tests/Timesheet/Rounding/CeilRoundingTest.php b/tests/Timesheet/Rounding/CeilRoundingTest.php index 1a1d1f53..48eb4d6d 100644 --- a/tests/Timesheet/Rounding/CeilRoundingTest.php +++ b/tests/Timesheet/Rounding/CeilRoundingTest.php @@ -31,6 +31,7 @@ class CeilRoundingTest extends TestCase $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $sut = new CeilRounding(); + self::assertEquals('ceil', $sut->getId()); $sut->roundBegin($record, $roundBegin); $sut->roundEnd($record, $roundEnd); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); diff --git a/tests/Timesheet/Rounding/ClosestRoundingTest.php b/tests/Timesheet/Rounding/ClosestRoundingTest.php index 822035f8..e4e778fc 100644 --- a/tests/Timesheet/Rounding/ClosestRoundingTest.php +++ b/tests/Timesheet/Rounding/ClosestRoundingTest.php @@ -31,6 +31,7 @@ class ClosestRoundingTest extends TestCase $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $sut = new ClosestRounding(); + self::assertEquals('closest', $sut->getId()); $sut->roundBegin($record, $roundBegin); $sut->roundEnd($record, $roundEnd); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); diff --git a/tests/Timesheet/Rounding/DefaultRoundingTest.php b/tests/Timesheet/Rounding/DefaultRoundingTest.php index a2236255..ebbe235b 100644 --- a/tests/Timesheet/Rounding/DefaultRoundingTest.php +++ b/tests/Timesheet/Rounding/DefaultRoundingTest.php @@ -31,6 +31,7 @@ class DefaultRoundingTest extends TestCase $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $sut = new DefaultRounding(); + self::assertEquals('default', $sut->getId()); $sut->roundBegin($record, $roundBegin); $sut->roundEnd($record, $roundEnd); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); diff --git a/tests/Timesheet/Rounding/FloorRoundingTest.php b/tests/Timesheet/Rounding/FloorRoundingTest.php index 0f662160..efe6e3b8 100644 --- a/tests/Timesheet/Rounding/FloorRoundingTest.php +++ b/tests/Timesheet/Rounding/FloorRoundingTest.php @@ -31,6 +31,7 @@ class FloorRoundingTest extends TestCase $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $sut = new FloorRounding(); + self::assertEquals('floor', $sut->getId()); $sut->roundBegin($record, $roundBegin); $sut->roundEnd($record, $roundEnd); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); diff --git a/tests/Timesheet/RoundingServiceTest.php b/tests/Timesheet/RoundingServiceTest.php new file mode 100644 index 00000000..769eec9d --- /dev/null +++ b/tests/Timesheet/RoundingServiceTest.php @@ -0,0 +1,251 @@ +setBegin(new \DateTime()); + $this->assertEquals(0, $record->getDuration()); + + $sut = (new RoundingServiceFactory($this))->create(); + $sut->applyRoundings($record); + $this->assertEquals(0, $record->getDuration()); + } + + /** + * @dataProvider getTestData + */ + public function testCalculate($rules, $start, $end, $expectedStart, $expectedEnd, $expectedDuration) + { + $record = new Timesheet(); + $record->setBegin($start); + $record->setEnd($end); + $this->assertEquals(0, $record->getDuration()); + + $sut = (new RoundingServiceFactory($this))->create($rules); + $sut->roundBegin($record); + $this->assertEquals($expectedStart, $record->getBegin()); + $sut->roundEnd($record); + $this->assertEquals($expectedEnd, $record->getEnd()); + + // set the proper duration + $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); + + $sut->roundDuration($record); + $this->assertEquals($expectedDuration, $record->getDuration()); + } + + public function getTestData() + { + $start = new \DateTime(); + $start->setTime(12, 0, 0); + $day = $start->format('l'); + + return [ + [ + null, + $start, + (clone $start)->setTimestamp($start->getTimestamp() + 1837), + $start, + (clone $start)->setTimestamp($start->getTimestamp() + 1837), + 1837 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 15, + 'end' => 15, + 'duration' => 0, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + (clone $start)->setTime(12, 15, 00), + (clone $start)->setTime(13, 45, 00), + 5400 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 0, + 'end' => 0, + 'duration' => 0, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + 4517 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 1, + 'end' => 1, + 'duration' => 0, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 17, 35), + (clone $start)->setTime(13, 32, 52), + (clone $start)->setTime(12, 17, 00), + (clone $start)->setTime(13, 33, 00), + 4560 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 0, + 'end' => 0, + 'duration' => 30, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 10, 51), + (clone $start)->setTime(14, 40, 52), + (clone $start)->setTime(12, 10, 51), + (clone $start)->setTime(14, 40, 52), + 10800 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 15, + 'end' => 0, + 'duration' => 0, + 'mode' => 'default', + ], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'begin' => 0, + 'end' => 1, + 'duration' => 30, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 27, 35), // 12:15 + (clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 => 2:30 + (clone $start)->setTime(12, 15, 00), // 12:15 + (clone $start)->setTime(14, 33, 00), // 14:33 => 2:18 => 2:30 + 9000 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 15, + 'end' => 0, + 'duration' => 30, + 'mode' => 'default', + ], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'begin' => 0, + 'end' => 1, + 'duration' => 0, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 27, 35), // 12:15 + (clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 (second duration will not be rounded) + (clone $start)->setTime(12, 15, 00), // 12:15 + (clone $start)->setTime(14, 33, 00), // 14:33 => 2:18 (second duration will not be rounded) + 9000 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 0, + 'end' => 0, + 'duration' => 1, + 'mode' => 'default', + ], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'begin' => 0, + 'end' => 0, + 'duration' => 1, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 27, 35), // no diff, to test ... + (clone $start)->setTime(12, 27, 35), // ... that no rounding is applied + (clone $start)->setTime(12, 27, 35), // no diff, to test ... + (clone $start)->setTime(12, 27, 35), // ... that no rounding is applied + 0 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 1, + 'end' => 1, + 'duration' => 1, + 'mode' => 'default', + ], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'begin' => 1, + 'end' => 1, + 'duration' => 1, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 27, 00), // no diff, to test ... + (clone $start)->setTime(12, 27, 00), // ... that no rounding is applied + (clone $start)->setTime(12, 27, 00), // no diff, to test ... + (clone $start)->setTime(12, 27, 00), // ... that no rounding is applied + 0 + ], + [ + [ + 'default' => [ + 'days' => $day, + 'begin' => 0, + 'end' => 0, + 'duration' => 0, + 'mode' => 'default', + ], + 'foo' => [ + 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'begin' => 0, + 'end' => 0, + 'duration' => 0, + 'mode' => 'default', + ], + ], + (clone $start)->setTime(12, 27, 35), // no diff, to test ... + (clone $start)->setTime(12, 27, 35), // ... that no rounding is applied + (clone $start)->setTime(12, 27, 35), // no diff, to test ... + (clone $start)->setTime(12, 27, 35), // ... that no rounding is applied + 0 + ], + ]; + } +} diff --git a/tests/Timesheet/TrackingMode/DefaultModeTest.php b/tests/Timesheet/TrackingMode/DefaultModeTest.php index 72ab853c..192dbb0c 100644 --- a/tests/Timesheet/TrackingMode/DefaultModeTest.php +++ b/tests/Timesheet/TrackingMode/DefaultModeTest.php @@ -10,7 +10,9 @@ namespace App\Tests\Timesheet\TrackingMode; use App\Configuration\TimesheetConfiguration; +use App\Entity\Timesheet; use App\Tests\Configuration\TestConfigLoader; +use App\Tests\Mocks\RoundingServiceFactory; use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Timesheet\TrackingMode\DefaultMode; @@ -19,6 +21,12 @@ use App\Timesheet\TrackingMode\DefaultMode; */ class DefaultModeTest extends AbstractTrackingModeTest { + protected function assertDefaultBegin(Timesheet $timesheet) + { + self::assertNotNull($timesheet->getBegin()); + self::assertInstanceOf(\DateTime::class, $timesheet->getBegin()); + } + /** * @return DefaultMode */ @@ -28,7 +36,7 @@ class DefaultModeTest extends AbstractTrackingModeTest $dateTime = (new UserDateTimeFactoryFactory($this))->create(); $configuration = new TimesheetConfiguration($loader, ['default_begin' => '13:47']); - return new DefaultMode($dateTime, $configuration); + return new DefaultMode($dateTime, $configuration, (new RoundingServiceFactory($this))->create()); } public function testDefaultValues() diff --git a/tests/Timesheet/TrackingMode/PunchInOutModeTest.php b/tests/Timesheet/TrackingMode/PunchInOutModeTest.php index 2f975e38..06301e5c 100644 --- a/tests/Timesheet/TrackingMode/PunchInOutModeTest.php +++ b/tests/Timesheet/TrackingMode/PunchInOutModeTest.php @@ -10,6 +10,7 @@ namespace App\Tests\Timesheet\TrackingMode; use App\Entity\Timesheet; +use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Timesheet\TrackingMode\PunchInOutMode; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -21,7 +22,8 @@ class PunchInOutModeTest extends TestCase { public function testDefaultValues() { - $sut = new PunchInOutMode(); + $dateTime = (new UserDateTimeFactoryFactory($this))->create(); + $sut = new PunchInOutMode($dateTime); self::assertFalse($sut->canEditBegin()); self::assertFalse($sut->canEditEnd()); @@ -33,13 +35,25 @@ class PunchInOutModeTest extends TestCase public function testCreate() { + $startingTime = new \DateTime('22:54'); $timesheet = new Timesheet(); - $timesheet->setBegin(new \DateTime('22:54')); + $timesheet->setBegin($startingTime); $request = new Request(); - $timesheetNew = clone $timesheet; - $sut = new PunchInOutMode(); + $dateTime = (new UserDateTimeFactoryFactory($this))->create(); + $sut = new PunchInOutMode($dateTime); $sut->create($timesheet, $request); - self::assertEquals($timesheet, $timesheetNew); + self::assertEquals($timesheet->getBegin(), $startingTime); + } + + public function testCreateWithoutBegin() + { + $timesheet = new Timesheet(); + $request = new Request(); + + $dateTime = (new UserDateTimeFactoryFactory($this))->create(); + $sut = new PunchInOutMode($dateTime); + $sut->create($timesheet, $request); + self::assertInstanceOf(\DateTime::class, $timesheet->getBegin()); } } diff --git a/tests/Timesheet/TrackingModeServiceTest.php b/tests/Timesheet/TrackingModeServiceTest.php index d6e2075b..f30ef260 100644 --- a/tests/Timesheet/TrackingModeServiceTest.php +++ b/tests/Timesheet/TrackingModeServiceTest.php @@ -9,11 +9,8 @@ namespace App\Tests\Timesheet; -use App\Configuration\TimesheetConfiguration; -use App\Tests\Configuration\TestConfigLoader; -use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; +use App\Tests\Mocks\TrackingModeServiceFactory; use App\Timesheet\TrackingMode\PunchInOutMode; -use App\Timesheet\TrackingModeService; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; @@ -24,11 +21,7 @@ class TrackingModeServiceTest extends TestCase { public function testDefaultTrackingModesAreRegistered() { - $loader = new TestConfigLoader([]); - $dateTime = (new UserDateTimeFactoryFactory($this))->create(); - $configuration = new TimesheetConfiguration($loader, ['mode' => 'punch']); - - $sut = new TrackingModeService($dateTime, $configuration); + $sut = (new TrackingModeServiceFactory($this))->create('punch'); $modes = $sut->getModes(); self::assertGreaterThanOrEqual(4, $modes); @@ -46,11 +39,7 @@ class TrackingModeServiceTest extends TestCase public function testGetActiveMode() { - $loader = new TestConfigLoader([]); - $dateTime = (new UserDateTimeFactoryFactory($this))->create(); - $configuration = new TimesheetConfiguration($loader, ['mode' => 'punch']); - - $sut = new TrackingModeService($dateTime, $configuration); + $sut = (new TrackingModeServiceFactory($this))->create('punch'); self::assertInstanceOf(PunchInOutMode::class, $sut->getActiveMode()); } @@ -60,11 +49,7 @@ class TrackingModeServiceTest extends TestCase $this->expectException(ServiceNotFoundException::class); $this->expectExceptionMessage('You have requested a non-existent service "xxxxxx"'); - $loader = new TestConfigLoader([]); - $dateTime = (new UserDateTimeFactoryFactory($this))->create(); - $configuration = new TimesheetConfiguration($loader, ['mode' => 'xxxxxx']); - - $sut = new TrackingModeService($dateTime, $configuration); + $sut = (new TrackingModeServiceFactory($this))->create('xxxxxx'); $sut->getActiveMode(); } diff --git a/tests/Validator/Constraints/TimesheetValidatorTest.php b/tests/Validator/Constraints/TimesheetValidatorTest.php index cbc9744c..36e9d9b2 100644 --- a/tests/Validator/Constraints/TimesheetValidatorTest.php +++ b/tests/Validator/Constraints/TimesheetValidatorTest.php @@ -15,8 +15,7 @@ use App\Entity\Activity; use App\Entity\Customer; use App\Entity\Project; use App\Entity\Timesheet; -use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; -use App\Timesheet\TrackingModeService; +use App\Tests\Mocks\TrackingModeServiceFactory; use App\Validator\Constraints\Timesheet as TimesheetConstraint; use App\Validator\Constraints\TimesheetValidator; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -39,10 +38,8 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase 'rules' => [ 'allow_future_times' => false, ], - 'mode' => 'default', ]); - $dateTime = (new UserDateTimeFactoryFactory($this))->create(); - $service = new TrackingModeService($dateTime, $config); + $service = (new TrackingModeServiceFactory($this))->create('default'); return new TimesheetValidator($authMock, $config, $service); } diff --git a/translations/system-configuration.de.xliff b/translations/system-configuration.de.xliff index ff713ec2..298d61f7 100644 --- a/translations/system-configuration.de.xliff +++ b/translations/system-configuration.de.xliff @@ -118,6 +118,74 @@ label.theme.branding.title Browser Titel + + rounding + Zeitenrundung + + + label.timesheet.rounding.default.begin + Rundung des Startzeitpunkts in Minuten (0 = deaktiviert) + + + label.timesheet.rounding.default.end + Rundung des Endzeitpunkts in Minuten (0 = deaktiviert) + + + label.timesheet.rounding.default.duration + Rundung der Dauer in Minuten (0 = deaktiviert) + + + label.timesheet.rounding.default.mode + Rundungsmodus + + + label.timesheet.rounding.default.days + Tage der Woche an denen gerundet wird + + + Monday + Montag + + + Tuesday + Dienstag + + + Wednesday + Mittwoch + + + Thursday + Donnerstag + + + Friday + Freitag + + + Saturday + Samstag + + + Sunday + Sonntag + + + Ceil + Ceil: Start, Ende und Dauer werden nach oben gerundet + + + Closest + Closest: Mathematische Rundung zum nächsten Wert + + + Default + Standard: Start werden nach oben, Ende und Dauer nach oben gerundet + + + Floor + Floor: Start, Ende und Dauer werden nach unten gerundet + diff --git a/translations/system-configuration.en.xliff b/translations/system-configuration.en.xliff index afc5beca..e08916e4 100644 --- a/translations/system-configuration.en.xliff +++ b/translations/system-configuration.en.xliff @@ -118,6 +118,74 @@ label.theme.branding.title Browser Title + + rounding + Time rounding + + + label.timesheet.rounding.default.begin + Rounding of the start time in minutes (0 = deactivated) + + + label.timesheet.rounding.default.end + Rounding of the end time in minutes (0 = deactivated) + + + label.timesheet.rounding.default.duration + Rounding of the duration in minutes (0 = deactivated) + + + label.timesheet.rounding.default.mode + Rounding mode + + + label.timesheet.rounding.default.days + Days of the week when rounding will be applied + + + Monday + Monday + + + Tuesday + Tuesday + + + Wednesday + Wednesday + + + Thursday + Thursday + + + Friday + Friday + + + Saturday + Saturday + + + Sunday + Sunday + + + Ceil + Ceil: begin, end and duration will be rounded up + + + Closest + Closest: mathematical rounding to the nearest value + + + Default + Standard: begin will be rounded down, end and duration up + + + Floor + Floor: begin, end and duration will be rounded down +