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

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

View File

@@ -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.
*/
```

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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%"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App;
use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\Timesheet\CalculatorInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
@@ -27,6 +35,11 @@ class Kernel extends BaseKernel
return $this->getProjectDir().'/var/log';
}
protected function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(CalculatorInterface::class)->addTag('timesheet.calculator');
}
public function registerBundles()
{
$contents = require $this->getProjectDir().'/config/bundles.php';

View File

@@ -0,0 +1,139 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\Calculator;
use App\Entity\Timesheet;
use App\Timesheet\CalculatorInterface;
/**
* Implementation to calculate the durations for a timesheet record.
*
* This calculator takes the configuration %kimai.timesheet.rounding% as argument,
* so its rounding behaviour can be customized.
*/
class DurationCalculator implements CalculatorInterface
{
/**
* @var array
*/
protected $roundings;
/**
* DurationCalculator constructor.
* @param array $roundings
*/
public function __construct(array $roundings)
{
$this->roundings = $roundings;
}
/**
* @param Timesheet $record
*/
public function calculate(Timesheet $record)
{
if ($record->getEnd() === null) {
return;
}
$this->applyDuration($record);
$this->applyRoundings($record);
}
/**
* @param Timesheet $record
*/
protected function applyDuration(Timesheet $record)
{
$duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp();
$record->setDuration($duration);
}
/**
* @param Timesheet $record
*/
protected function applyRoundings(Timesheet $record)
{
foreach ($this->roundings as $rounding) {
$weekday = $record->getEnd()->format('l');
$days = array_map('strtolower', $rounding['days']);
if (in_array(strtolower($weekday), $days)) {
$this->roundBegin($record, $rounding['begin']);
$this->roundEnd($record, $rounding['end']);
$this->applyDuration($record);
$this->roundDuration($record, $rounding['duration']);
}
}
}
/**
* @param Timesheet $record
* @param int $minutes
*/
protected function roundBegin(Timesheet $record, $minutes)
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getBegin()->getTimestamp();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
if ($diff === 0) {
return;
}
$record->getBegin()->setTimestamp($timestamp - $diff);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
protected function roundEnd(Timesheet $record, $minutes)
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getEnd()->getTimestamp();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
if ($diff === 0) {
return;
}
$record->getEnd()->setTimestamp($timestamp - $diff + $seconds);
}
/**
* @param Timesheet $record
* @param int $minutes
*/
protected function roundDuration(Timesheet $record, $minutes)
{
if ($minutes <= 0) {
return;
}
$timestamp = $record->getDuration();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
if ($diff === 0) {
return;
}
$record->setDuration($timestamp - $diff + $seconds);
}
}

View File

@@ -0,0 +1,86 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\Calculator;
use App\Entity\Timesheet;
use App\Entity\UserPreference;
use App\Timesheet\CalculatorInterface;
/**
* Implementation to calculate the rate for a timesheet record.
*/
class RateCalculator implements CalculatorInterface
{
/**
* @var array
*/
protected $rates;
/**
* RateCalculator constructor.
* @param array $rates
*/
public function __construct(array $rates)
{
$this->rates = $rates;
}
/**
* @param Timesheet $record
*/
public function calculate(Timesheet $record)
{
if ($record->getEnd() === null) {
return;
}
$rate = $this->calculateRate($record);
$factor = $this->getRateFactor($record);
$record->setRate($rate * $factor);
}
/**
* @param Timesheet $record
* @return float
*/
protected function getRateFactor(Timesheet $record)
{
$factor = 0;
foreach ($this->rates as $rateFactor) {
$weekday = $record->getEnd()->format('l');
$days = array_map('strtolower', $rateFactor['days']);
if (in_array(strtolower($weekday), $days)) {
if ($rateFactor['factor'] <= 0) {
throw new \InvalidArgumentException(
'A rate factor smaller or equals 0 is not allowed, given: ' . $rateFactor['factor']
);
}
$factor += $rateFactor['factor'];
}
}
if ($factor <= 0) {
$factor = 1;
}
return $factor;
}
/**
* @param Timesheet $record
* @return float
*/
protected function calculateRate(Timesheet $record)
{
$hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
return (float) $hourlyRate * ($record->getDuration() / 3600);
}
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Entity\Timesheet;
/**
* A calculator is called before a Timesheet entity will be updated.
* These classes will normally be used when calculating duration or rates.
*/
interface CalculatorInterface
{
/**
* All necessary changes need to be applied on the given $record.
* The methods return value will not be evaluated.
*
* @param Timesheet $record
* @return void
*/
public function calculate(Timesheet $record);
}

View File

@@ -0,0 +1,152 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Timesheet\Calculator;
use App\Entity\Timesheet;
use App\Timesheet\Calculator\DurationCalculator;
use \PHPUnit\Framework\TestCase;
/**
* @covers \App\Timesheet\Calculator\DurationCalculator
*/
class DurationCalculatorTest extends TestCase
{
public function testCalculateWithEmptyEnd()
{
$record = new Timesheet();
$record->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
],
];
}
}

View File

@@ -0,0 +1,160 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Timesheet\Calculator;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Timesheet\Calculator\RateCalculator;
use \PHPUnit\Framework\TestCase;
/**
* @covers \App\Timesheet\Calculator\RateCalculator
*/
class RateCalculatorTest extends TestCase
{
const HOURLY_RATE = 75;
protected function getTestUser()
{
$pref = new UserPreference();
$pref->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);
}
}

11
var/docs/README.md Normal file
View File

@@ -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.

View File

@@ -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
```

View File

@@ -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.
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`.

View File

@@ -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.