* added unit tests and documentation #112 * fixed DashboardController * added CONTRIBUTION guidelines * fixed license year
This commit is contained in:
@@ -1,6 +1,23 @@
|
|||||||
Contributing
|
# Contributing
|
||||||
============
|
|
||||||
|
|
||||||
The Kimai application is an open source project. Contributions made by the community are welcome.
|
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.
|
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.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2016 Kevin Papst
|
Copyright (c) 2017-2018 Kevin Papst
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -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:
|
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)
|
- 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
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
kimai:
|
kimai:
|
||||||
timesheet:
|
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:
|
invoice:
|
||||||
renderer:
|
renderer:
|
||||||
default: 'App\Controller\InvoiceController::invoiceAction'
|
default: 'App\Controller\InvoiceController::invoiceAction'
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
|
|
||||||
App\Invoice\ServiceInvoice:
|
App\Invoice\ServiceInvoice:
|
||||||
class: 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
|
# a route listener, that injects the locale through a URL directory
|
||||||
App\EventSubscriber\RedirectToLocaleSubscriber:
|
App\EventSubscriber\RedirectToLocaleSubscriber:
|
||||||
@@ -53,20 +53,14 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: doctrine.event_subscriber }
|
- { 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:
|
App\Doctrine\TimesheetSubscriber:
|
||||||
class: App\Doctrine\TimesheetSubscriber
|
class: App\Doctrine\TimesheetSubscriber
|
||||||
|
arguments: [!tagged timesheet.calculator]
|
||||||
tags:
|
tags:
|
||||||
- { name: doctrine.event_listener, event: prePersist, lazy: true }
|
- { name: doctrine.event_listener, event: prePersist, lazy: true }
|
||||||
- { name: doctrine.event_listener, event: preUpdate, 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
|
# FORMS
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
@@ -85,17 +79,12 @@ services:
|
|||||||
App\Twig\Extensions:
|
App\Twig\Extensions:
|
||||||
arguments: ['%app_locales%']
|
arguments: ['%app_locales%']
|
||||||
|
|
||||||
# avanzu_admin_theme.navbar_task_listener:
|
# ================================================================================
|
||||||
# class: "%avanzu_admin_theme.navbar_task_listener.class%"
|
# TIMESHEET RECORD CALCULATOR
|
||||||
# tags:
|
# ================================================================================
|
||||||
# - { name: kernel.event_listener, event: theme.tasks, method: onListTasks }
|
|
||||||
#
|
App\Timesheet\Calculator\DurationCalculator:
|
||||||
# avanzu_admin_theme.navbar_notify_listener:
|
arguments: ["%kimai.timesheet.rounding%"]
|
||||||
# class: "%avanzu_admin_theme.navbar_notify_listener.class%"
|
|
||||||
# tags:
|
App\Timesheet\Calculator\RateCalculator:
|
||||||
# - { name: kernel.event_listener, event: theme.notifications, method: onListNotifications }
|
arguments: ["%kimai.timesheet.rates%"]
|
||||||
#
|
|
||||||
# avanzu_admin_theme.navbar_msg_listener:
|
|
||||||
# class: "%avanzu_admin_theme.navbar_msg_listener.class%"
|
|
||||||
# tags:
|
|
||||||
# - { name: kernel.event_listener, event: theme.messages, method: onListMessages }
|
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ class KimaiImporterCommand extends Command
|
|||||||
->setName('kimai:import-v1')
|
->setName('kimai:import-v1')
|
||||||
->setDescription('Import data from a Kimai v1 installation')
|
->setDescription('Import data from a Kimai v1 installation')
|
||||||
->setHelp('This command allows you to import the most important data from a Kimi 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('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables')
|
||||||
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
|
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
|
||||||
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer', 'de')
|
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer', 'de')
|
||||||
@@ -272,7 +276,8 @@ class KimaiImporterCommand extends Command
|
|||||||
'Start: ' . $this->bytesHumanReadable($bytesStart) . PHP_EOL .
|
'Start: ' . $this->bytesHumanReadable($bytesStart) . PHP_EOL .
|
||||||
'After caching: ' . $this->bytesHumanReadable($bytesCached) . PHP_EOL .
|
'After caching: ' . $this->bytesHumanReadable($bytesCached) . PHP_EOL .
|
||||||
'After import: ' . $this->bytesHumanReadable($bytesImported) . 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)
|
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
|
||||||
{
|
{
|
||||||
$version = $this->getImportConnection()->query('SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "version"')->fetchColumn();
|
$versionQuery = 'SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "version"';
|
||||||
$revision = $this->getImportConnection()->query('SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "revision"')->fetchColumn();
|
$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) {
|
if (version_compare($requiredVersion, $version) == 1) {
|
||||||
$io->error(
|
$io->error(
|
||||||
@@ -645,7 +653,9 @@ class KimaiImporterCommand extends Command
|
|||||||
$project = null;
|
$project = null;
|
||||||
|
|
||||||
if (!isset($this->projects[$projectId])) {
|
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];
|
$project = $this->projects[$projectId];
|
||||||
@@ -668,8 +678,12 @@ class KimaiImporterCommand extends Command
|
|||||||
* @return Activity
|
* @return Activity
|
||||||
* @throws \Exception
|
* @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'];
|
$activityId = $oldActivity['activityID'];
|
||||||
if (isset($this->activities[$activityId][$project->getId()])) {
|
if (isset($this->activities[$activityId][$project->getId()])) {
|
||||||
return $this->activities[$activityId][$project->getId()];
|
return $this->activities[$activityId][$project->getId()];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Entity\Activity;
|
|||||||
use App\Entity\Customer;
|
use App\Entity\Customer;
|
||||||
use App\Entity\Project;
|
use App\Entity\Project;
|
||||||
use App\Entity\Timesheet;
|
use App\Entity\Timesheet;
|
||||||
|
use App\Repository\Query\TimesheetQuery;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
|
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
|
||||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
|
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.amountThisMonth', timesheetGlobal.amountThisMonth|money, 'money', 'green') }}",
|
||||||
"{{ widgets.info_box_counter('stats.durationTotal', timesheetGlobal.durationTotal|duration(true), 'hourglass-o', 'yellow') }}",
|
"{{ 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.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."})) }}",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -88,15 +88,32 @@ class AppFixtures extends Fixture
|
|||||||
protected function getUserDefinition()
|
protected function getUserDefinition()
|
||||||
{
|
{
|
||||||
return [
|
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
|
// 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
|
// 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
|
// 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
|
||||||
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,14 +44,16 @@ class InvoiceFixtures extends Fixture
|
|||||||
->setVat(19)
|
->setVat(19)
|
||||||
->setDueDays(14)
|
->setDueDays(14)
|
||||||
->setPaymentTerms(
|
->setPaymentTerms(
|
||||||
'I would like to thank you for your confidence and will gladly be there for you in the future.
|
'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.'
|
PHP_EOL .
|
||||||
|
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
|
||||||
|
'as reference.'
|
||||||
)
|
)
|
||||||
->setAddress(
|
->setAddress(
|
||||||
'795 Folsom Ave, Suite 600
|
'795 Folsom Ave, Suite 600' . PHP_EOL .
|
||||||
San Francisco, CA 94107
|
'San Francisco, CA 94107' . PHP_EOL .
|
||||||
Phone: (804) 123-5432
|
'Phone: (804) 123-456789' . PHP_EOL .
|
||||||
Email: info@almasaeedstudio.com'
|
'Email: email@example.com'
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class AppExtension extends Extension
|
|||||||
}
|
}
|
||||||
|
|
||||||
$container->setParameter('kimai.invoice', $config['invoice']);
|
$container->setParameter('kimai.invoice', $config['invoice']);
|
||||||
|
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
|
||||||
|
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,7 +29,52 @@ class Configuration implements ConfigurationInterface
|
|||||||
->children()
|
->children()
|
||||||
->arrayNode('timesheet')
|
->arrayNode('timesheet')
|
||||||
->children()
|
->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()
|
||||||
->end()
|
->end()
|
||||||
->arrayNode('invoice')
|
->arrayNode('invoice')
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
namespace App\Doctrine;
|
namespace App\Doctrine;
|
||||||
|
|
||||||
use App\Entity\UserPreference;
|
use App\Timesheet\CalculatorInterface;
|
||||||
use Doctrine\Common\EventSubscriber;
|
use Doctrine\Common\EventSubscriber;
|
||||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
use Doctrine\ORM\Event\LifecycleEventArgs;
|
use Doctrine\ORM\Event\LifecycleEventArgs;
|
||||||
@@ -20,6 +20,27 @@ use App\Entity\Timesheet;
|
|||||||
*/
|
*/
|
||||||
class TimesheetSubscriber implements EventSubscriber
|
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
|
* @return array
|
||||||
@@ -55,26 +76,12 @@ class TimesheetSubscriber implements EventSubscriber
|
|||||||
{
|
{
|
||||||
$entity = $args->getObject();
|
$entity = $args->getObject();
|
||||||
|
|
||||||
if ($entity instanceof Timesheet) {
|
if (!($entity instanceof Timesheet)) {
|
||||||
if ($entity->getEnd() !== null) {
|
return;
|
||||||
$duration = $entity->getEnd()->getTimestamp() - $entity->getBegin()->getTimestamp();
|
}
|
||||||
$entity->setDuration($duration);
|
|
||||||
|
|
||||||
// TODO allow to set hourly rate on activity, project and customer and prefer these
|
foreach ($this->calculator as $calculator) {
|
||||||
|
$calculator->calculate($entity);
|
||||||
$rate = $this->calculateRate($entity);
|
|
||||||
$entity->setRate($rate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
<?php
|
<?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;
|
namespace App;
|
||||||
|
|
||||||
use App\DependencyInjection\AppExtension;
|
use App\DependencyInjection\AppExtension;
|
||||||
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
|
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
|
||||||
|
use App\Timesheet\CalculatorInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
use Symfony\Component\Config\Loader\LoaderInterface;
|
use Symfony\Component\Config\Loader\LoaderInterface;
|
||||||
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
|
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
|
||||||
@@ -27,6 +35,11 @@ class Kernel extends BaseKernel
|
|||||||
return $this->getProjectDir().'/var/log';
|
return $this->getProjectDir().'/var/log';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function build(ContainerBuilder $container)
|
||||||
|
{
|
||||||
|
$container->registerForAutoconfiguration(CalculatorInterface::class)->addTag('timesheet.calculator');
|
||||||
|
}
|
||||||
|
|
||||||
public function registerBundles()
|
public function registerBundles()
|
||||||
{
|
{
|
||||||
$contents = require $this->getProjectDir().'/config/bundles.php';
|
$contents = require $this->getProjectDir().'/config/bundles.php';
|
||||||
|
|||||||
139
src/Timesheet/Calculator/DurationCalculator.php
Normal file
139
src/Timesheet/Calculator/DurationCalculator.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Timesheet/Calculator/RateCalculator.php
Normal file
86
src/Timesheet/Calculator/RateCalculator.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Timesheet/CalculatorInterface.php
Normal file
28
src/Timesheet/CalculatorInterface.php
Normal 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);
|
||||||
|
}
|
||||||
152
tests/Timesheet/Calculator/DurationCalculatorTest.php
Normal file
152
tests/Timesheet/Calculator/DurationCalculatorTest.php
Normal 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
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
160
tests/Timesheet/Calculator/RateCalculatorTest.php
Normal file
160
tests/Timesheet/Calculator/RateCalculatorTest.php
Normal 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
11
var/docs/README.md
Normal 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.
|
||||||
81
var/docs/configurations.md
Normal file
81
var/docs/configurations.md
Normal 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
|
||||||
|
```
|
||||||
@@ -46,7 +46,7 @@ You can find more information at:
|
|||||||
### Rebuilding assets for use in subdirectory
|
### 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.
|
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:
|
After that re-compile the assets with:
|
||||||
```bash
|
```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"
|
## Adding tabs to the "control sidebar"
|
||||||
|
|
||||||
@@ -113,6 +113,72 @@ twig:
|
|||||||
template: sidebar/home.html.twig
|
template: sidebar/home.html.twig
|
||||||
|
|
||||||
```
|
```
|
||||||
You have to define the icon to be used and then one of controller action or template.
|
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 official.
|
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 only in your own bundle config.
|
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`.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ There are multiple pre-defined roles in Kimai, which define the ACLs. A user can
|
|||||||
|
|
||||||
If you have chosen to login with the `Remember me` option, your login will be extended to one week (default value).
|
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
|
- view your own timesheet
|
||||||
- start and stop new records
|
- start and stop new records
|
||||||
- edit existing 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
|
form when you try to access them. This is a security feature to prevent abuse in case you forgot to logout in public
|
||||||
environments.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user