added fixed-rate and hourly-rate for customer, project and activity (#304)
This commit is contained in:
@@ -29,6 +29,8 @@ script:
|
||||
- cp tests/.env.dist.sqlite .env
|
||||
- bin/console doctrine:database:create -n
|
||||
- bin/console doctrine:migrations:migrate -n
|
||||
- bin/console doctrine:migrations:migrate first -n
|
||||
- cp tests/.env.dist.mysql .env
|
||||
- bin/console doctrine:database:create -n
|
||||
- bin/console doctrine:migrations:migrate -n
|
||||
- bin/console doctrine:migrations:migrate first -n
|
||||
|
||||
@@ -15,7 +15,7 @@ This is the reloaded version of the open source timetracker Kimai.
|
||||
The new version has not much in common with its predecessor [Kimai v1](http://www.kimai.org) besides the basic ideas of time-tracking and the current development team.
|
||||
|
||||
Right now its in an early development phase, its usable but some advanced features from Kimai v1 are missing by now (like export and ODT invoices).
|
||||
But we already support to [import your timesheets](migration_v1.md) from Kimai v1.
|
||||
But we already support to [import your timesheets](var/docs/migration_v1.md) from Kimai v1.
|
||||
|
||||
It is developed with modern frameworks like [Symfony v4](https://github.com/symfony/symfony), [Doctrine](https://github.com/doctrine/),
|
||||
[AdminLTE](https://github.com/kevinpapst/AdminLTEBundle/) and [many](composer.json) [more](package.json).
|
||||
@@ -29,7 +29,7 @@ If you want to support us in translating Kimai, please [read this documentation]
|
||||
- One PHP extension of [PDO-SQLite](https://php.net/manual/en/ref.pdo-sqlite.php) or [PDO-MySQL](https://php.net/manual/en/ref.pdo-mysql.php) enabled (it might work with PostgreSQL and Oracle as well, but that wasn't tested and is not officially supported)
|
||||
- The PHP extension [intl](https://php.net/manual/en/book.intl.php)
|
||||
- The [usual Symfony application requirements](http://symfony.com/doc/current/reference/requirements.html)
|
||||
- If you use MariaDB, make sure its at least v10.7.2 (see [FAQ](var/docs/faq.md))
|
||||
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md))
|
||||
- Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md)
|
||||
- A modern browser, Kimai v2 might be broken on old browsers like IE 10
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ class KimaiImporterCommand extends Command
|
||||
$activities = null;
|
||||
$records = null;
|
||||
$activityToProject = null;
|
||||
$fixedRates = null;
|
||||
$rates = null;
|
||||
|
||||
$bytesStart = memory_get_usage(true);
|
||||
|
||||
@@ -219,6 +221,22 @@ class KimaiImporterCommand extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$fixedRates = $this->fetchAllFromImport('fixedRates');
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to load fixedRates: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$rates = $this->fetchAllFromImport('rates');
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to load rates: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bytesCached = memory_get_usage(true);
|
||||
|
||||
$io->success('Fetched Kimai v1 data, trying to import now ...');
|
||||
@@ -246,7 +264,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importProjects($io, $projects);
|
||||
$counter = $this->importProjects($io, $projects, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported projects: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
@@ -256,7 +274,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importActivities($io, $activities, $activityToProject);
|
||||
$counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported activities: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
@@ -266,7 +284,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importTimesheetRecords($io, $records);
|
||||
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported timesheet records: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
@@ -275,10 +293,6 @@ class KimaiImporterCommand extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO support fixedRates (projectID, activityID, rate)
|
||||
// TODO support rates (userID, projectID, activityID, rate)
|
||||
// TODO dump yaml config from configuration (adminmail, currency_name, date_format_0, language, roundPrecision)
|
||||
// TODO support preferences (ui.lang, timezone)
|
||||
// TODO support expenses - new database required
|
||||
|
||||
$bytesImported = memory_get_usage(true);
|
||||
@@ -294,8 +308,8 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ghiven database connection for import has an underlying database with a compatible structure.
|
||||
* This is checked againstto the Kimai version and database revision.
|
||||
* Checks if the given database connection for import has an underlying database with a compatible structure.
|
||||
* This is checked against the Kimai version and database revision.
|
||||
*
|
||||
* @param SymfonyStyle $io
|
||||
* @param $requiredVersion
|
||||
@@ -508,8 +522,8 @@ class KimaiImporterCommand extends Command
|
||||
* ["phone"]=> NULL
|
||||
* ["fax"]=> NULL
|
||||
* ["mobile"]=> NULL
|
||||
* --- ["mail"]=> NULL
|
||||
* --- ["homepage"]=> NULL
|
||||
* ["mail"]=> NULL
|
||||
* ["homepage"]=> NULL
|
||||
* ["trash"]=> string(1) "0"
|
||||
* ["timezone"]=> string(13) "Europe/Berlin"
|
||||
*
|
||||
@@ -538,7 +552,9 @@ class KimaiImporterCommand extends Command
|
||||
->setComment($oldCustomer['comment'])
|
||||
->setCompany($oldCustomer['company'])
|
||||
->setFax($oldCustomer['fax'])
|
||||
->setHomepage($oldCustomer['homepage'])
|
||||
->setMobile($oldCustomer['mobile'])
|
||||
->setMail($oldCustomer['mail'])
|
||||
->setPhone($oldCustomer['phone'])
|
||||
->setContact($oldCustomer['contact'])
|
||||
->setAddress($oldCustomer['street'] . PHP_EOL . $oldCustomer['zipcode'] . ' ' . $oldCustomer['city'])
|
||||
@@ -586,10 +602,12 @@ class KimaiImporterCommand extends Command
|
||||
*
|
||||
* @param SymfonyStyle $io
|
||||
* @param array $projects
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function importProjects(SymfonyStyle $io, $projects)
|
||||
protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates)
|
||||
{
|
||||
$counter = 0;
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
@@ -611,6 +629,24 @@ class KimaiImporterCommand extends Command
|
||||
->setVisible($isActive)
|
||||
;
|
||||
|
||||
foreach ($fixedRates as $fixedRow) {
|
||||
if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($fixedRow['projectID'] == $oldProject['projectID']) {
|
||||
$project->setFixedRate($fixedRow['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rates as $ratesRow) {
|
||||
if ($ratesRow['userID'] !== null || $ratesRow['activityID'] !== null || $ratesRow['projectID'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($ratesRow['projectID'] == $oldProject['projectID']) {
|
||||
$project->setHourlyRate($ratesRow['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->validateImport($io, $project)) {
|
||||
throw new \Exception('Failed to validate project: ' . $project->getName());
|
||||
}
|
||||
@@ -654,10 +690,12 @@ class KimaiImporterCommand extends Command
|
||||
* @param SymfonyStyle $io
|
||||
* @param array $activities
|
||||
* @param array $activityToProject
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject)
|
||||
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject, array $fixedRates, array $rates)
|
||||
{
|
||||
$counter = 0;
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
@@ -680,7 +718,7 @@ class KimaiImporterCommand extends Command
|
||||
$project = $this->projects[$projectId];
|
||||
|
||||
$this->unassignedActivities[$oldActivity['activityID']] = $oldActivity;
|
||||
$this->createActivity($io, $entityManager, $project, $oldActivity);
|
||||
$this->createActivity($io, $entityManager, $project, $oldActivity, $fixedRates, $rates);
|
||||
++$counter;
|
||||
} else {
|
||||
$this->unassignedActivities[$oldActivity['activityID']] = $oldActivity;
|
||||
@@ -695,6 +733,8 @@ class KimaiImporterCommand extends Command
|
||||
* @param ObjectManager $entityManager
|
||||
* @param Project $project
|
||||
* @param array $oldActivity
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return Activity
|
||||
* @throws \Exception
|
||||
*/
|
||||
@@ -702,7 +742,9 @@ class KimaiImporterCommand extends Command
|
||||
SymfonyStyle $io,
|
||||
ObjectManager $entityManager,
|
||||
Project $project,
|
||||
array $oldActivity
|
||||
array $oldActivity,
|
||||
array $fixedRates,
|
||||
array $rates
|
||||
) {
|
||||
$activityId = $oldActivity['activityID'];
|
||||
if (isset($this->activities[$activityId][$project->getId()])) {
|
||||
@@ -724,6 +766,33 @@ class KimaiImporterCommand extends Command
|
||||
->setProject($project)
|
||||
;
|
||||
|
||||
foreach ($fixedRates as $fixedRow) {
|
||||
if ($fixedRow['activityID'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($fixedRow['projectID'] !== null && $fixedRow['projectID'] !== $project->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fixedRow['activityID'] == $oldActivity['activityID']) {
|
||||
$activity->setFixedRate($fixedRow['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rates as $ratesRow) {
|
||||
if ($ratesRow['userID'] !== null || $ratesRow['activityID'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($ratesRow['projectID'] !== null && $ratesRow['projectID'] !== $project->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ratesRow['activityID'] == $oldActivity['activityID']) {
|
||||
$activity->setHourlyRate($ratesRow['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!$this->validateImport($io, $activity)) {
|
||||
throw new \Exception('Failed to validate activity: ' . $activity->getName());
|
||||
}
|
||||
@@ -772,14 +841,19 @@ class KimaiImporterCommand extends Command
|
||||
*
|
||||
* @param SymfonyStyle $io
|
||||
* @param array $records
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function importTimesheetRecords(SymfonyStyle $io, array $records)
|
||||
protected function importTimesheetRecords(SymfonyStyle $io, array $records, array $fixedRates, array $rates)
|
||||
{
|
||||
$counter = 0;
|
||||
$activityCounter = 0;
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
$total = count($records);
|
||||
|
||||
$io->writeln('Importing timesheets, please wait');
|
||||
|
||||
foreach ($records as $oldRecord) {
|
||||
$activity = null;
|
||||
@@ -800,7 +874,7 @@ class KimaiImporterCommand extends Command
|
||||
|
||||
if (null === $activity && isset($this->unassignedActivities[$activityId])) {
|
||||
$oldActivity = $this->unassignedActivities[$activityId];
|
||||
$activity = $this->createActivity($io, $entityManager, $project, $oldActivity);
|
||||
$activity = $this->createActivity($io, $entityManager, $project, $oldActivity, $fixedRates, $rates);
|
||||
++$activityCounter;
|
||||
}
|
||||
|
||||
@@ -811,14 +885,26 @@ class KimaiImporterCommand extends Command
|
||||
|
||||
$duration = $oldRecord['end'] - $oldRecord['start'];
|
||||
|
||||
$rate = $oldRecord['fixedRate'];
|
||||
if ((empty($rate) || 0.00 == $rate) && !empty($oldRecord['rate'])) {
|
||||
$hourlyRate = (float) $oldRecord['rate'];
|
||||
$rate = (float) $hourlyRate * ($duration / 3600);
|
||||
$rate = round($rate, 2);
|
||||
$timesheet = new Timesheet();
|
||||
|
||||
$fixedRate = $oldRecord['fixedRate'];
|
||||
if (!empty($fixedRate) && 0.00 != $fixedRate) {
|
||||
$timesheet->setFixedRate($fixedRate);
|
||||
}
|
||||
|
||||
$hourlyRate = $oldRecord['rate'];
|
||||
if (!empty($hourlyRate) && 0.00 != $hourlyRate) {
|
||||
$timesheet->setHourlyRate($hourlyRate);
|
||||
}
|
||||
|
||||
if ($timesheet->getFixedRate() !== null) {
|
||||
$timesheet->setRate($timesheet->getFixedRate());
|
||||
} elseif ($timesheet->getHourlyRate() !== null) {
|
||||
$rate = $timesheet->getHourlyRate();
|
||||
$rate = (float) $hourlyRate * ($duration / 3600);
|
||||
$timesheet->setRate(round($rate, 2));
|
||||
}
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setDescription($oldRecord['description'] ?: ($oldRecord['comment'] ?: null))
|
||||
->setUser($this->users[$oldRecord['userID']])
|
||||
@@ -826,7 +912,6 @@ class KimaiImporterCommand extends Command
|
||||
->setEnd(new \DateTime('@' . $oldRecord['end']))
|
||||
->setDuration($duration)
|
||||
->setActivity($activity)
|
||||
->setRate($rate)
|
||||
;
|
||||
|
||||
if (!$this->validateImport($io, $timesheet)) {
|
||||
@@ -845,13 +930,20 @@ class KimaiImporterCommand extends Command
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
|
||||
if (0 == $counter % 500) {
|
||||
$io->writeln('Imported ' . $counter . ' timesheet records, import ongoing ...');
|
||||
$io->write('.');
|
||||
if (0 == $counter % 80) {
|
||||
$io->writeln(' ('.$counter.'/'.$total.')');
|
||||
$entityManager->clear(Timesheet::class);
|
||||
}
|
||||
}
|
||||
|
||||
for ($i = 0; $i < 80-($counter%80); $i++) {
|
||||
$io->write(' ');
|
||||
}
|
||||
$io->writeln(' ('.$counter.'/'.$total.')');
|
||||
|
||||
if ($activityCounter > 0) {
|
||||
$io->success('Created new (previously unattached) activities during timesheet import: ' . $activityCounter);
|
||||
$io->success('Created new (previously global) activities during timesheet import: ' . $activityCounter);
|
||||
}
|
||||
|
||||
return $counter;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Translation\DataCollectorTranslator;
|
||||
|
||||
/**
|
||||
* The abstract base controller.
|
||||
@@ -27,29 +27,13 @@ abstract class AbstractController extends Controller
|
||||
public const ROLE_ADMIN = 'ROLE_ADMIN';
|
||||
|
||||
/**
|
||||
* @return object|\Symfony\Component\Translation\DataCollectorTranslator|\Symfony\Component\Translation\IdentityTranslator
|
||||
* @return DataCollectorTranslator
|
||||
*/
|
||||
protected function getTranslator()
|
||||
private function getTranslator()
|
||||
{
|
||||
return $this->container->get('translator');
|
||||
}
|
||||
|
||||
/**
|
||||
* A translated helper for denyAccessUnlessGranted()
|
||||
*
|
||||
* @param mixed $attributes
|
||||
* @param mixed $subject
|
||||
* @param string $translation
|
||||
* @param array $parameter
|
||||
* @throws AccessDeniedException
|
||||
*/
|
||||
protected function denyUnlessGranted($attributes, $subject = null, $translation = 'access.denied', $parameter = [])
|
||||
{
|
||||
$error = $this->getTranslator()->trans($translation, $parameter, self::DOMAIN_ERROR);
|
||||
// TODO try & catch and add to audit log?
|
||||
$this->denyAccessUnlessGranted($attributes, $subject, $error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "successful" flash message to the stack.
|
||||
*
|
||||
@@ -72,7 +56,7 @@ abstract class AbstractController extends Controller
|
||||
/**
|
||||
* Adds a "warning" flash message to the stack.
|
||||
*
|
||||
* @param $translationKey
|
||||
* @param string $translationKey
|
||||
* @param array $parameter
|
||||
*/
|
||||
protected function flashWarning($translationKey, $parameter = [])
|
||||
@@ -91,7 +75,7 @@ abstract class AbstractController extends Controller
|
||||
/**
|
||||
* Adds a "error" flash message to the stack.
|
||||
*
|
||||
* @param $translationKey
|
||||
* @param string $translationKey
|
||||
* @param array $parameter
|
||||
*/
|
||||
protected function flashError($translationKey, $parameter = [])
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Entity\Activity;
|
||||
use App\Repository\ActivityRepository;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Controller used to manage activity contents in the public part of the site.
|
||||
@@ -32,7 +33,7 @@ class ActivityController extends Controller
|
||||
/**
|
||||
* The flyout to render recent activities and quick-start new recordings.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function recentActivitiesAction()
|
||||
{
|
||||
|
||||
@@ -70,7 +70,7 @@ class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->getProfileView($profile, 'settings', $form);
|
||||
@@ -95,7 +95,7 @@ class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->getProfileView($profile, 'password', null, $form);
|
||||
@@ -120,7 +120,7 @@ class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->getProfileView($profile, 'api-token', null, null, null, null, $form);
|
||||
@@ -142,7 +142,7 @@ class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->getProfileView($profile, 'roles', null, null, $form);
|
||||
@@ -186,7 +186,7 @@ class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_preferences', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->getProfileView($profile, 'preferences', null, null, null, $form);
|
||||
|
||||
@@ -99,7 +99,7 @@ class TimesheetController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* The route to stop a running entry.
|
||||
* The route to start a running entry.
|
||||
*
|
||||
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
|
||||
* @Security("is_granted('start', activity)")
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\Migrations\AbstractMigration as BaseAbstractMigration;
|
||||
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
@@ -50,7 +52,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @throws \Doctrine\DBAL\DBALException
|
||||
* @throws DBALException
|
||||
*/
|
||||
protected function getPlatform()
|
||||
{
|
||||
@@ -62,7 +64,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
|
||||
* $schema = $this->getClassMetaData(User::class);
|
||||
*
|
||||
* @param string $entityName
|
||||
* @return \Doctrine\Common\Persistence\Mapping\ClassMetadata
|
||||
* @return ClassMetadata
|
||||
*/
|
||||
protected function getClassMetaData($entityName)
|
||||
{
|
||||
@@ -77,7 +79,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
|
||||
*
|
||||
* @param string $indexName
|
||||
* @param string $tableName
|
||||
* @throws \Doctrine\DBAL\DBALException
|
||||
* @throws DBALException
|
||||
*/
|
||||
protected function addSqlDropIndex($indexName, $tableName)
|
||||
{
|
||||
|
||||
@@ -69,6 +69,22 @@ class Activity
|
||||
*/
|
||||
private $timesheets;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
|
||||
* @Assert\GreaterThanOrEqual(0)
|
||||
*/
|
||||
private $fixedRate = null;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
|
||||
* @Assert\GreaterThanOrEqual(0)
|
||||
*/
|
||||
private $hourlyRate = null;
|
||||
|
||||
/**
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
@@ -176,6 +192,44 @@ class Activity
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getFixedRate(): ?float
|
||||
{
|
||||
return $this->fixedRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $fixedRate
|
||||
* @return Activity
|
||||
*/
|
||||
public function setFixedRate(?float $fixedRate)
|
||||
{
|
||||
$this->fixedRate = $fixedRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getHourlyRate(): ?float
|
||||
{
|
||||
return $this->hourlyRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $hourlyRate
|
||||
* @return Activity
|
||||
*/
|
||||
public function setHourlyRate(?float $hourlyRate)
|
||||
{
|
||||
$this->hourlyRate = $hourlyRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -149,6 +149,22 @@ class Customer
|
||||
*/
|
||||
private $timezone;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
|
||||
* @Assert\GreaterThanOrEqual(0)
|
||||
*/
|
||||
private $fixedRate = null;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
|
||||
* @Assert\GreaterThanOrEqual(0)
|
||||
*/
|
||||
private $hourlyRate = null;
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
@@ -509,6 +525,44 @@ class Customer
|
||||
return $this->projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getFixedRate(): ?float
|
||||
{
|
||||
return $this->fixedRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $fixedRate
|
||||
* @return Customer
|
||||
*/
|
||||
public function setFixedRate(?float $fixedRate)
|
||||
{
|
||||
$this->fixedRate = $fixedRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getHourlyRate(): ?float
|
||||
{
|
||||
return $this->hourlyRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $hourlyRate
|
||||
* @return Customer
|
||||
*/
|
||||
public function setHourlyRate(?float $hourlyRate)
|
||||
{
|
||||
$this->hourlyRate = $hourlyRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -85,6 +85,22 @@ class Project
|
||||
*/
|
||||
private $activities;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
|
||||
* @Assert\GreaterThanOrEqual(0)
|
||||
*/
|
||||
private $fixedRate = null;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
|
||||
* @Assert\GreaterThanOrEqual(0)
|
||||
*/
|
||||
private $hourlyRate = null;
|
||||
|
||||
/**
|
||||
* Get projectid
|
||||
*
|
||||
@@ -244,6 +260,44 @@ class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getFixedRate(): ?float
|
||||
{
|
||||
return $this->fixedRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $fixedRate
|
||||
* @return Project
|
||||
*/
|
||||
public function setFixedRate(?float $fixedRate)
|
||||
{
|
||||
$this->fixedRate = $fixedRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getHourlyRate(): ?float
|
||||
{
|
||||
return $this->hourlyRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $hourlyRate
|
||||
* @return Project
|
||||
*/
|
||||
public function setHourlyRate(?float $hourlyRate)
|
||||
{
|
||||
$this->hourlyRate = $hourlyRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -291,6 +291,7 @@ class Timesheet
|
||||
public function setFixedRate(?float $fixedRate)
|
||||
{
|
||||
$this->fixedRate = $fixedRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -309,6 +310,7 @@ class Timesheet
|
||||
public function setHourlyRate(?float $hourlyRate)
|
||||
{
|
||||
$this->hourlyRate = $hourlyRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Form\Type\YesNoType;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -55,6 +56,14 @@ class ActivityEditForm extends AbstractType
|
||||
return $repo->builderForEntityType($project);
|
||||
},
|
||||
])
|
||||
->add('fixedRate', NumberType::class, [
|
||||
'label' => 'label.fixed_rate',
|
||||
'required' => false,
|
||||
])
|
||||
->add('hourlyRate', NumberType::class, [
|
||||
'label' => 'label.hourly_rate',
|
||||
'required' => false,
|
||||
])
|
||||
// boolean
|
||||
->add('visible', YesNoType::class, [
|
||||
'label' => 'label.visible',
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
@@ -45,9 +46,6 @@ class CustomerEditForm extends AbstractType
|
||||
'label' => 'label.comment',
|
||||
'required' => false,
|
||||
])
|
||||
->add('visible', YesNoType::class, [
|
||||
'label' => 'label.visible',
|
||||
])
|
||||
->add('company', TextType::class, [
|
||||
'label' => 'label.company',
|
||||
'required' => false,
|
||||
@@ -89,6 +87,17 @@ class CustomerEditForm extends AbstractType
|
||||
->add('timezone', TimezoneType::class, [
|
||||
'label' => 'label.timezone',
|
||||
])
|
||||
->add('fixedRate', NumberType::class, [
|
||||
'label' => 'label.fixed_rate',
|
||||
'required' => false,
|
||||
])
|
||||
->add('hourlyRate', NumberType::class, [
|
||||
'label' => 'label.hourly_rate',
|
||||
'required' => false,
|
||||
])
|
||||
->add('visible', YesNoType::class, [
|
||||
'label' => 'label.visible',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Repository\CustomerRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -58,14 +59,22 @@ class ProjectEditForm extends AbstractType
|
||||
return $repo->builderForEntityType($customer);
|
||||
},
|
||||
])
|
||||
->add('visible', YesNoType::class, [
|
||||
'label' => 'label.visible',
|
||||
->add('fixedRate', NumberType::class, [
|
||||
'label' => 'label.fixed_rate',
|
||||
'required' => false,
|
||||
])
|
||||
->add('hourlyRate', NumberType::class, [
|
||||
'label' => 'label.hourly_rate',
|
||||
'required' => false,
|
||||
])
|
||||
->add('budget', MoneyType::class, [
|
||||
'label' => 'label.budget',
|
||||
'currency' => $customer ? $customer->getCurrency() : $builder->getOption('currency'),
|
||||
'required' => false,
|
||||
])
|
||||
->add('visible', YesNoType::class, [
|
||||
'label' => 'label.visible',
|
||||
])
|
||||
;
|
||||
|
||||
if ($entry->getId() === null) {
|
||||
|
||||
@@ -107,7 +107,7 @@ final class Version20180715160326 extends AbstractMigration
|
||||
$this->addSql('INSERT INTO ' . $users . ' (id, name, mail, active, password, roles, alias, registration_date, title, avatar) SELECT id, username, email, enabled, password, roles, alias, registration_date, title, avatar FROM __temp__' . $users);
|
||||
$this->addSql('DROP TABLE __temp__' . $users);
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $users . ' CHANGE username name VARCHAR(60) NOT NULL COLLATE utf8mb4_unicode_ci, CHANGE email mail VARCHAR(160) NOT NULL COLLATE utf8mb4_unicode_ci, DROP username_canonical, DROP email_canonical, DROP salt, DROP last_login, DROP confirmation_token, DROP password_requested_at, CHANGE password password VARCHAR(254) DEFAULT \'NULL\' COLLATE utf8mb4_unicode_ci, CHANGE roles roles JSON NOT NULL COLLATE utf8mb4_bin COMMENT \'(DC2Type:json_array)\', CHANGE alias alias VARCHAR(60) DEFAULT \'NULL\' COLLATE utf8mb4_unicode_ci, CHANGE registration_date registration_date DATETIME DEFAULT \'NULL\', CHANGE title title VARCHAR(50) DEFAULT \'NULL\' COLLATE utf8mb4_unicode_ci, CHANGE avatar avatar VARCHAR(255) DEFAULT \'NULL\' COLLATE utf8mb4_unicode_ci, CHANGE enabled active TINYINT(1) NOT NULL');
|
||||
$this->addSql('ALTER TABLE ' . $users . ' CHANGE username name VARCHAR(60) NOT NULL COLLATE utf8mb4_unicode_ci, CHANGE email mail VARCHAR(160) NOT NULL COLLATE utf8mb4_unicode_ci, DROP username_canonical, DROP email_canonical, DROP salt, DROP last_login, DROP confirmation_token, DROP password_requested_at, CHANGE password password VARCHAR(254) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE roles roles JSON NOT NULL COLLATE utf8mb4_bin COMMENT \'(DC2Type:json_array)\', CHANGE alias alias VARCHAR(60) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE registration_date registration_date DATETIME DEFAULT NULL, CHANGE title title VARCHAR(50) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE avatar avatar VARCHAR(255) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE enabled active TINYINT(1) NOT NULL');
|
||||
}
|
||||
|
||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_SUPER_ADMIN"]\' WHERE roles LIKE "%ROLE_SUPER_ADMIN%"');
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<?php declare(strict_types=1);
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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 DoctrineMigrations;
|
||||
|
||||
@@ -19,22 +28,9 @@ final class Version20180903202256 extends AbstractMigration
|
||||
}
|
||||
|
||||
$timesheet = $this->getTableName('timesheet');
|
||||
$user = $this->getTableName('users');
|
||||
$activity = $this->getTableName('activities');
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, start_time, end_time, duration, description, rate FROM ' . $timesheet);
|
||||
$this->addSql('DROP TABLE ' . $timesheet);
|
||||
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER DEFAULT NULL, activity_id INTEGER DEFAULT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL COLLATE BINARY, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $user . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activity . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, start_time, end_time, duration, description, rate, null, null FROM __temp__' . $timesheet);
|
||||
$this->addSql('DROP TABLE __temp__' . $timesheet);
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B18D93D649');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD fixed_rate NUMERIC(10, 2) DEFAULT NULL, ADD hourly_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $user . ' (id) ON DELETE CASCADE');
|
||||
}
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
@@ -46,7 +42,6 @@ final class Version20180903202256 extends AbstractMigration
|
||||
}
|
||||
|
||||
$timesheet = $this->getTableName('timesheet');
|
||||
$user = $this->getTableName('users');
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
|
||||
@@ -59,9 +54,8 @@ final class Version20180903202256 extends AbstractMigration
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B18D93D649');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP fixed_rate, DROP hourly_rate');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $user . ' (id)');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP hourly_rate');
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP fixed_rate');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
src/Migrations/Version20180905190737.php
Normal file
90
src/Migrations/Version20180905190737.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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 DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Adding hourly_rate and fixed_rate to:
|
||||
* - Activities
|
||||
* - Projects
|
||||
* - Customer
|
||||
*/
|
||||
final class Version20180905190737 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$platform = $this->getPlatform();
|
||||
|
||||
if (!in_array($platform, ['sqlite', 'mysql'])) {
|
||||
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
|
||||
}
|
||||
|
||||
$activity = $this->getTableName('activities');
|
||||
$project = $this->getTableName('projects');
|
||||
$customer = $this->getTableName('customers');
|
||||
|
||||
$this->addSql('ALTER TABLE ' . $activity . ' ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE ' . $activity . ' ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE ' . $project . ' ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE ' . $project . ' ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE ' . $customer . ' ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE ' . $customer . ' ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$platform = $this->getPlatform();
|
||||
|
||||
if (!in_array($platform, ['sqlite', 'mysql'])) {
|
||||
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
|
||||
}
|
||||
|
||||
$activity = $this->getTableName('activities');
|
||||
$project = $this->getTableName('projects');
|
||||
$customer = $this->getTableName('customers');
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $customer . ' AS SELECT id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone FROM ' . $customer);
|
||||
$this->addSql('DROP TABLE ' . $customer);
|
||||
$this->addSql('CREATE TABLE ' . $customer . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, number VARCHAR(50) DEFAULT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, company VARCHAR(255) DEFAULT NULL, contact VARCHAR(255) DEFAULT NULL, address CLOB DEFAULT NULL, country VARCHAR(2) NOT NULL, currency VARCHAR(3) NOT NULL, phone VARCHAR(255) DEFAULT NULL, fax VARCHAR(255) DEFAULT NULL, mobile VARCHAR(255) DEFAULT NULL, mail VARCHAR(255) DEFAULT NULL, homepage VARCHAR(255) DEFAULT NULL, timezone VARCHAR(255) NOT NULL)');
|
||||
$this->addSql('INSERT INTO ' . $customer . ' (id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone) SELECT id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone FROM __temp__' . $customer);
|
||||
$this->addSql('DROP TABLE __temp__' . $customer);
|
||||
|
||||
$this->addSql('DROP INDEX IDX_407F12069395C3F3');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $project . ' AS SELECT id, customer_id, name, order_number, comment, visible, budget FROM ' . $project);
|
||||
$this->addSql('DROP TABLE ' . $project);
|
||||
$this->addSql('CREATE TABLE ' . $project . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, customer_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, order_number CLOB DEFAULT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, budget NUMERIC(10, 2) NOT NULL)');
|
||||
$this->addSql('INSERT INTO ' . $project . ' (id, customer_id, name, order_number, comment, visible, budget) SELECT id, customer_id, name, order_number, comment, visible, budget FROM __temp__' . $project);
|
||||
$this->addSql('DROP TABLE __temp__' . $project);
|
||||
$this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $project . ' (customer_id)');
|
||||
|
||||
$this->addSql('DROP INDEX IDX_8811FE1C166D1F9C');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $activity . ' AS SELECT id, project_id, name, comment, visible FROM ' . $activity);
|
||||
$this->addSql('DROP TABLE ' . $activity);
|
||||
$this->addSql('CREATE TABLE ' . $activity . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, project_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL)');
|
||||
$this->addSql('INSERT INTO ' . $activity . ' (id, project_id, name, comment, visible) SELECT id, project_id, name, comment, visible FROM __temp__' . $activity);
|
||||
$this->addSql('DROP TABLE __temp__' . $activity);
|
||||
$this->addSql('CREATE INDEX IDX_8811FE1C166D1F9C ON ' . $activity . ' (project_id)');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $customer . ' DROP hourly_rate');
|
||||
$this->addSql('ALTER TABLE ' . $customer . ' DROP fixed_rate');
|
||||
$this->addSql('ALTER TABLE ' . $project . ' DROP hourly_rate');
|
||||
$this->addSql('ALTER TABLE ' . $project . ' DROP fixed_rate');
|
||||
$this->addSql('ALTER TABLE ' . $activity . ' DROP hourly_rate');
|
||||
$this->addSql('ALTER TABLE ' . $activity . ' DROP fixed_rate');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,15 +41,79 @@ class RateCalculator implements CalculatorInterface
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $record->getFixedRate()) {
|
||||
$record->setRate($record->getFixedRate());
|
||||
$fixedRate = $this->findFixedRate($record);
|
||||
if (null !== $fixedRate) {
|
||||
$record->setRate($fixedRate);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rate = $this->calculateRate($record);
|
||||
$hourlyRate = $this->findHourlyRate($record);
|
||||
$factor = $this->getRateFactor($record);
|
||||
|
||||
$record->setRate($rate * $factor);
|
||||
$record->setRate(
|
||||
$this->calculateRate($record->getDuration(), $hourlyRate, $factor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $record
|
||||
* @return float
|
||||
*/
|
||||
protected function findHourlyRate(Timesheet $record)
|
||||
{
|
||||
if (null !== $record->getHourlyRate()) {
|
||||
return $record->getHourlyRate();
|
||||
}
|
||||
|
||||
$activity = $record->getActivity();
|
||||
if (null !== $activity->getHourlyRate()) {
|
||||
return $activity->getHourlyRate();
|
||||
}
|
||||
|
||||
$project = $activity->getProject();
|
||||
if (null !== $project) {
|
||||
if (null !== $project->getHourlyRate()) {
|
||||
return $project->getHourlyRate();
|
||||
}
|
||||
|
||||
$customer = $project->getCustomer();
|
||||
if (null !== $customer->getHourlyRate()) {
|
||||
return $customer->getHourlyRate();
|
||||
}
|
||||
}
|
||||
|
||||
return (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $record
|
||||
* @return float|null
|
||||
*/
|
||||
protected function findFixedRate(Timesheet $record)
|
||||
{
|
||||
if (null !== $record->getFixedRate()) {
|
||||
return $record->getFixedRate();
|
||||
}
|
||||
|
||||
$activity = $record->getActivity();
|
||||
if (null !== $activity->getFixedRate()) {
|
||||
return $activity->getFixedRate();
|
||||
}
|
||||
|
||||
$project = $activity->getProject();
|
||||
if (null !== $project) {
|
||||
if (null !== $project->getFixedRate()) {
|
||||
return $project->getFixedRate();
|
||||
}
|
||||
|
||||
$customer = $project->getCustomer();
|
||||
if (null !== $customer->getFixedRate()) {
|
||||
return $customer->getFixedRate();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,17 +144,13 @@ class RateCalculator implements CalculatorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $record
|
||||
* @param int $duration
|
||||
* @param float $hourlyRate
|
||||
* @param float $factor
|
||||
* @return float
|
||||
*/
|
||||
protected function calculateRate(Timesheet $record)
|
||||
protected function calculateRate($duration, $hourlyRate, $factor)
|
||||
{
|
||||
if (null !== $record->getHourlyRate()) {
|
||||
$hourlyRate = $record->getHourlyRate();
|
||||
} else {
|
||||
$hourlyRate = (float)$record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
|
||||
}
|
||||
|
||||
return (float) $hourlyRate * ($record->getDuration() / 3600);
|
||||
return (float) $hourlyRate * ($duration / 3600) * $factor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,45 @@
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Controller\ActivityController;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\Controller\ActivityController
|
||||
* @group integration
|
||||
*/
|
||||
class ActivityControllerTest extends ControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
public function testRecentActivitiesAction()
|
||||
{
|
||||
$this->markTestSkipped('no public route available');
|
||||
$kernel = self::bootKernel();
|
||||
$container = $kernel->getContainer();
|
||||
|
||||
$em = $container->get('doctrine.orm.entity_manager');
|
||||
$user = $em->getRepository(User::class)->getById(1);
|
||||
|
||||
$storage = new TokenStorage();
|
||||
$storage->setToken(new UsernamePasswordToken($user, [], 'foo'));
|
||||
$container->set('security.token_storage', $storage);
|
||||
|
||||
$fixture = new TimesheetFixtures();
|
||||
$fixture->setUser($user);
|
||||
$fixture->setAmount(1);
|
||||
$fixture->setStartDate(new \DateTime('-30 days'));
|
||||
$this->importFixture($em, $fixture);
|
||||
|
||||
$controller = $container->get(ActivityController::class);
|
||||
$controller->setContainer($container);
|
||||
$response = $controller->recentActivitiesAction();
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
$this->assertTrue($response->isSuccessful());
|
||||
$this->assertContains('<li class="dropdown notifications-menu">', $content);
|
||||
$this->assertContains('<span class="label label-success">1</span>', $content);
|
||||
$this->assertContains('<a href="/en/timesheet/start/1">', $content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,48 +48,59 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->assertFalse($client->getResponse()->isSuccessful());
|
||||
}
|
||||
|
||||
public function testUpdateApiToken()
|
||||
public function testEditAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/edit');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
/** @var EncoderFactoryInterface $passwordEncoder */
|
||||
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
$this->assertEquals('John Doe', $user->getAlias());
|
||||
$this->assertEquals('Developer', $user->getTitle());
|
||||
$this->assertEquals(UserFixtures::DEFAULT_AVATAR, $user->getAvatar());
|
||||
$this->assertEquals('john_user@example.com', $user->getEmail());
|
||||
$this->assertTrue($user->isEnabled());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=user_api_token]')->form();
|
||||
$form = $client->getCrawler()->filter('form[name=user_edit]')->form();
|
||||
$client->submit($form, [
|
||||
'user_api_token' => [
|
||||
'plainApiToken' => [
|
||||
'first' => 'test123',
|
||||
'second' => 'test123',
|
||||
]
|
||||
'user_edit' => [
|
||||
'alias' => 'Johnny',
|
||||
'title' => 'Code Monkey',
|
||||
'avatar' => '/fake/image.jpg',
|
||||
'email' => 'updated@example.com',
|
||||
'enabled' => 0,
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER)));
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/edit'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
$this->assertEquals('Johnny', $user->getAlias());
|
||||
$this->assertEquals('Code Monkey', $user->getTitle());
|
||||
$this->assertEquals('/fake/image.jpg', $user->getAvatar());
|
||||
$this->assertEquals('updated@example.com', $user->getEmail());
|
||||
$this->assertFalse($user->isEnabled());
|
||||
}
|
||||
|
||||
public function testUpdatePassword()
|
||||
public function testPasswordAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/password');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
/** @var EncoderFactoryInterface $passwordEncoder */
|
||||
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
|
||||
|
||||
@@ -107,14 +118,92 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER)));
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/password'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), UserFixtures::DEFAULT_PASSWORD, $user->getSalt()));
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), 'test123', $user->getSalt()));
|
||||
}
|
||||
|
||||
public function testApiTokenAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/api-token');
|
||||
|
||||
/** @var User $user */
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
/** @var EncoderFactoryInterface $passwordEncoder */
|
||||
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
|
||||
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=user_api_token]')->form();
|
||||
$client->submit($form, [
|
||||
'user_api_token' => [
|
||||
'plainApiToken' => [
|
||||
'first' => 'test123',
|
||||
'second' => 'test123',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/api-token'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
|
||||
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
|
||||
}
|
||||
|
||||
public function testRolesActionIsSecured()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/roles');
|
||||
$this->assertFalse($client->getResponse()->isSuccessful());
|
||||
}
|
||||
|
||||
public function testRolesAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/roles');
|
||||
|
||||
/** @var User $user */
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
$this->assertEquals(['ROLE_USER'], $user->getRoles());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=user_roles]')->form();
|
||||
$client->submit($form, [
|
||||
'user_roles[roles]' => [
|
||||
'ROLE_TEAMLEAD',
|
||||
'ROLE_SUPER_ADMIN',
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/roles'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
|
||||
$this->assertEquals(['ROLE_TEAMLEAD', 'ROLE_SUPER_ADMIN', 'ROLE_USER'], $user->getRoles());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
|
||||
@@ -36,7 +37,81 @@ class TimesheetControllerTest extends ControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
$this->request($client, '/timesheet/create');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
// TODO more tests
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
|
||||
$client->submit($form, [
|
||||
'timesheet_edit_form' => [
|
||||
'description' => 'Testing is fun!'
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $em->getRepository(Timesheet::class)->find(1);
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
|
||||
$this->assertNull($timesheet->getEnd());
|
||||
$this->assertEquals('Testing is fun!', $timesheet->getDescription());
|
||||
$this->assertEquals(0, $timesheet->getRate());
|
||||
$this->assertNull($timesheet->getHourlyRate());
|
||||
$this->assertNull($timesheet->getFixedRate());
|
||||
}
|
||||
|
||||
public function testStartAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
$this->request($client, '/timesheet/start/1');
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $em->getRepository(Timesheet::class)->find(1);
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
|
||||
$this->assertNull($timesheet->getEnd());
|
||||
$this->assertEquals(1, $timesheet->getActivity()->getId());
|
||||
}
|
||||
|
||||
public function testStopAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
$this->request($client, '/timesheet/create');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
|
||||
$client->submit($form, [
|
||||
'timesheet_edit_form' => [
|
||||
'description' => 'Testing is fun!',
|
||||
'fixedRate' => 100,
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$this->request($client, '/timesheet/1/stop');
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $em->getRepository(Timesheet::class)->find(1);
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getEnd());
|
||||
$this->assertEquals(100, $timesheet->getRate());
|
||||
$this->assertEquals(100, $timesheet->getFixedRate());
|
||||
$this->assertNull($timesheet->getHourlyRate());
|
||||
}
|
||||
|
||||
public function testCreateActionWithFromAndToValues()
|
||||
@@ -44,7 +119,55 @@ class TimesheetControllerTest extends ControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
$this->request($client, '/timesheet/create?from=2018-08-02T20%3A00%3A00&to=2018-08-02T20%3A30%3A00');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
// TODO more tests
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
|
||||
$client->submit($form, [
|
||||
'timesheet_edit_form' => [
|
||||
'hourlyRate' => 100,
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $em->getRepository(Timesheet::class)->find(1);
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getEnd());
|
||||
$this->assertEquals(50, $timesheet->getRate());
|
||||
$this->assertEquals('2018-08-02T20:00:00+00:00', $timesheet->getBegin()->format(\DateTime::ATOM));
|
||||
$this->assertEquals('2018-08-02T20:30:00+00:00', $timesheet->getEnd()->format(\DateTime::ATOM));
|
||||
}
|
||||
|
||||
public function testCreateActionWithBeginAndEndValues()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
$this->request($client, '/timesheet/create?begin=2018-08-02&end=2018-08-02');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
|
||||
$client->submit($form, [
|
||||
'timesheet_edit_form' => [
|
||||
'hourlyRate' => 100,
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $em->getRepository(Timesheet::class)->find(1);
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getEnd());
|
||||
$this->assertEquals(800, $timesheet->getRate());
|
||||
$this->assertEquals('2018-08-02T10:00:00+00:00', $timesheet->getBegin()->format(\DateTime::ATOM));
|
||||
$this->assertEquals('2018-08-02T18:00:00+00:00', $timesheet->getEnd()->format(\DateTime::ATOM));
|
||||
}
|
||||
|
||||
public function testEditAction()
|
||||
|
||||
@@ -44,10 +44,13 @@ class TimesheetFixtures extends Fixture
|
||||
protected $startDate = '2018-04-01';
|
||||
|
||||
/**
|
||||
* @param string $date
|
||||
* @param string|\DateTime $date
|
||||
*/
|
||||
public function setStartDate($date)
|
||||
{
|
||||
if ($date instanceof \DateTime) {
|
||||
$date = $date->format('Y-m-d');
|
||||
}
|
||||
$this->startDate = $date;
|
||||
}
|
||||
|
||||
|
||||
50
tests/Entity/ActivityTest.php
Normal file
50
tests/Entity/ActivityTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Entity\Activity;
|
||||
|
||||
/**
|
||||
* @covers \App\Entity\Activity
|
||||
*/
|
||||
class ActivityTest extends AbstractEntityTest
|
||||
{
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new Activity();
|
||||
$this->assertNull($sut->getId());
|
||||
$this->assertNull($sut->getProject());
|
||||
$this->assertNull($sut->getName());
|
||||
$this->assertNull($sut->getComment());
|
||||
$this->assertTrue($sut->getVisible());
|
||||
// timesheets
|
||||
$this->assertNull($sut->getFixedRate());
|
||||
$this->assertNull($sut->getHourlyRate());
|
||||
}
|
||||
|
||||
public function testSetterAndGetter()
|
||||
{
|
||||
$sut = new Activity();
|
||||
$this->assertInstanceOf(Activity::class, $sut->setName('foo-bar'));
|
||||
$this->assertEquals('foo-bar', $sut->getName());
|
||||
$this->assertEquals('foo-bar', (string) $sut);
|
||||
|
||||
$this->assertInstanceOf(Activity::class, $sut->setVisible(false));
|
||||
$this->assertFalse($sut->getVisible());
|
||||
|
||||
$this->assertInstanceOf(Activity::class, $sut->setComment('hello world'));
|
||||
$this->assertEquals('hello world', $sut->getComment());
|
||||
|
||||
$this->assertInstanceOf(Activity::class, $sut->setFixedRate(13.47));
|
||||
$this->assertEquals(13.47, $sut->getFixedRate());
|
||||
$this->assertInstanceOf(Activity::class, $sut->setHourlyRate(99));
|
||||
$this->assertEquals(99, $sut->getHourlyRate());
|
||||
}
|
||||
}
|
||||
90
tests/Entity/CustomerTest.php
Normal file
90
tests/Entity/CustomerTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
|
||||
/**
|
||||
* @covers \App\Entity\Customer
|
||||
*/
|
||||
class CustomerTest extends AbstractEntityTest
|
||||
{
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new Customer();
|
||||
$this->assertNull($sut->getId());
|
||||
$this->assertNull($sut->getName());
|
||||
$this->assertNull($sut->getNumber());
|
||||
$this->assertNull($sut->getComment());
|
||||
// projects
|
||||
$this->assertTrue($sut->getVisible());
|
||||
|
||||
$this->assertNull($sut->getCompany());
|
||||
$this->assertNull($sut->getContact());
|
||||
$this->assertNull($sut->getAddress());
|
||||
$this->assertNull($sut->getCountry());
|
||||
$this->assertEquals('EUR', $sut->getCurrency());
|
||||
$this->assertEquals('EUR', Customer::DEFAULT_CURRENCY);
|
||||
$this->assertNull($sut->getPhone());
|
||||
$this->assertNull($sut->getFax());
|
||||
$this->assertNull($sut->getMobile());
|
||||
$this->assertNull($sut->getMail());
|
||||
$this->assertNull($sut->getHomepage());
|
||||
$this->assertNull($sut->getTimezone());
|
||||
|
||||
$this->assertNull($sut->getFixedRate());
|
||||
$this->assertNull($sut->getHourlyRate());
|
||||
}
|
||||
|
||||
public function testSetterAndGetter()
|
||||
{
|
||||
$sut = new Customer();
|
||||
$this->assertInstanceOf(Customer::class, $sut->setName('foo-bar'));
|
||||
$this->assertEquals('foo-bar', $sut->getName());
|
||||
$this->assertEquals('foo-bar', (string) $sut);
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setVisible(false));
|
||||
$this->assertFalse($sut->getVisible());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setComment('hello world'));
|
||||
$this->assertEquals('hello world', $sut->getComment());
|
||||
|
||||
$projects = [(new Project())->setName('Test')];
|
||||
$this->assertInstanceOf(Customer::class, $sut->setProjects($projects));
|
||||
$this->assertSame($projects, $sut->getProjects());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setCompany('test company'));
|
||||
$this->assertEquals('test company', $sut->getCompany());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setContact('test contact'));
|
||||
$this->assertEquals('test contact', $sut->getContact());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setPhone('0123456789'));
|
||||
$this->assertEquals('0123456789', $sut->getPhone());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setFax('asdfghjkl'));
|
||||
$this->assertEquals('asdfghjkl', $sut->getFax());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setMobile('76576534'));
|
||||
$this->assertEquals('76576534', $sut->getMobile());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setMail('test@example.com'));
|
||||
$this->assertEquals('test@example.com', $sut->getMail());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com'));
|
||||
$this->assertEquals('https://www.example.com', $sut->getHomepage());
|
||||
|
||||
$this->assertInstanceOf(Customer::class, $sut->setFixedRate(13.47));
|
||||
$this->assertEquals(13.47, $sut->getFixedRate());
|
||||
$this->assertInstanceOf(Customer::class, $sut->setHourlyRate(99));
|
||||
$this->assertEquals(99, $sut->getHourlyRate());
|
||||
}
|
||||
}
|
||||
68
tests/Entity/ProjectTest.php
Normal file
68
tests/Entity/ProjectTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
|
||||
/**
|
||||
* @covers \App\Entity\Project
|
||||
*/
|
||||
class ProjectTest extends AbstractEntityTest
|
||||
{
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new Project();
|
||||
$this->assertNull($sut->getId());
|
||||
$this->assertNull($sut->getCustomer());
|
||||
$this->assertNull($sut->getName());
|
||||
$this->assertNull($sut->getOrderNumber());
|
||||
$this->assertNull($sut->getComment());
|
||||
$this->assertTrue($sut->getVisible());
|
||||
$this->assertEquals(0.0, $sut->getBudget());
|
||||
// activities
|
||||
$this->assertNull($sut->getFixedRate());
|
||||
$this->assertNull($sut->getHourlyRate());
|
||||
}
|
||||
|
||||
public function testSetterAndGetter()
|
||||
{
|
||||
$sut = new Project();
|
||||
|
||||
$customer = (new Customer())->setName('customer');
|
||||
$this->assertInstanceOf(Project::class, $sut->setCustomer($customer));
|
||||
$this->assertSame($customer, $sut->getCustomer());
|
||||
|
||||
$this->assertInstanceOf(Project::class, $sut->setName('123456789'));
|
||||
$this->assertEquals('123456789', (string) $sut);
|
||||
|
||||
$this->assertInstanceOf(Project::class, $sut->setOrderNumber('123456789'));
|
||||
$this->assertEquals('123456789', $sut->getOrderNumber());
|
||||
|
||||
$this->assertInstanceOf(Project::class, $sut->setComment('a comment'));
|
||||
$this->assertEquals('a comment', $sut->getComment());
|
||||
|
||||
$this->assertInstanceOf(Project::class, $sut->setVisible(false));
|
||||
$this->assertFalse($sut->getVisible());
|
||||
|
||||
$this->assertInstanceOf(Project::class, $sut->setBudget(12345.67));
|
||||
$this->assertEquals(12345.67, $sut->getBudget());
|
||||
|
||||
$activities = [(new Activity())->setName('foo')];
|
||||
$this->assertInstanceOf(Project::class, $sut->setActivities($activities));
|
||||
$this->assertSame($activities, $sut->getActivities());
|
||||
|
||||
$this->assertInstanceOf(Project::class, $sut->setFixedRate(13.47));
|
||||
$this->assertEquals(13.47, $sut->getFixedRate());
|
||||
$this->assertInstanceOf(Project::class, $sut->setHourlyRate(99));
|
||||
$this->assertEquals(99, $sut->getHourlyRate());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Tests\Timesheet\Calculator;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
@@ -22,19 +25,20 @@ class RateCalculatorTest extends TestCase
|
||||
{
|
||||
public const HOURLY_RATE = 75;
|
||||
|
||||
public function testCalculateWithHourlyRate()
|
||||
public function testCalculateWithTimesheetHourlyRate()
|
||||
{
|
||||
$record = new Timesheet();
|
||||
$record->setEnd(new \DateTime());
|
||||
$record->setDuration(1800);
|
||||
$record->setHourlyRate(100);
|
||||
$record->setActivity(new Activity());
|
||||
|
||||
$sut = new RateCalculator([]);
|
||||
$sut->calculate($record);
|
||||
$this->assertEquals(50, $record->getRate());
|
||||
}
|
||||
|
||||
public function testCalculateWithFixedRate()
|
||||
public function testCalculateWithTimesheetFixedRate()
|
||||
{
|
||||
$record = new Timesheet();
|
||||
$record->setEnd(new \DateTime());
|
||||
@@ -42,17 +46,94 @@ class RateCalculatorTest extends TestCase
|
||||
$record->setFixedRate(10);
|
||||
// make sure that fixed rate is always applied, even if hourly rate is set
|
||||
$record->setHourlyRate(99);
|
||||
$record->setActivity(new Activity());
|
||||
|
||||
$sut = new RateCalculator([]);
|
||||
$sut->calculate($record);
|
||||
$this->assertEquals(10, $record->getRate());
|
||||
}
|
||||
|
||||
protected function getTestUser()
|
||||
public function getRateTestData()
|
||||
{
|
||||
yield 'a0' => [0.0, 0, 0, null, null, null, null, null, null, null, null];
|
||||
yield 'a1' => [0.0, 1800, 0, null, null, null, null, null, null, null, null];
|
||||
yield 'a2' => [0.5, 1800, 1, null, null, null, null, null, null, null, null];
|
||||
yield 'a3' => [0.0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
yield 'b1' => [1.5, 1800, 1, 3, null, 5, null, 7, null, 9, null];
|
||||
yield 'b2' => [2.5, 1800, 1, null, null, 5, null, 7, null, 9, null];
|
||||
yield 'b3' => [3.5, 1800, 1, null, null, null, null, 7, null, 9, null];
|
||||
yield 'b4' => [4.5, 1800, 1, null, null, null, null, null, null, 9, null];
|
||||
yield 'b5' => [2.0, 1800, 1, null, 2, null, 3, null, 4, null, 5];
|
||||
yield 'b6' => [3.0, 1800, 1, null, null, null, 3, null, 4, null, 5];
|
||||
yield 'b7' => [4.0, 1800, 1, null, null, null, null, null, 4, null, 5];
|
||||
yield 'b8' => [3.0, 1800, 1, null, null, null, 3, null, null, null, 5];
|
||||
yield 'b9' => [2.0, 1800, 1, null, 2, null, null, null, null, null, 5];
|
||||
yield 'c0' => [5.0, 1800, 100, 10, null, null, null, null, null, null, null];
|
||||
yield 'd0' => [10, 1800, 100, null, 10, null, null, null, null, null, null];
|
||||
yield 'e0' => [10, 1800, 100, null, null, 20, null, null, null, null, null];
|
||||
yield 'f0' => [20, 1800, 100, null, null, null, 20, null, null, null, null];
|
||||
yield 'g0' => [15, 1800, 100, null, null, null, null, 30, null, null, null];
|
||||
yield 'h0' => [30, 1800, 100, null, null, null, null, null, 30, null, null];
|
||||
yield 'i0' => [20, 1800, 100, null, null, null, null, null, null, 40, null];
|
||||
yield 'j0' => [40, 1800, 100, null, null, null, null, null, null, null, 40];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getRateTestData
|
||||
*/
|
||||
public function testRates(
|
||||
$exptectedRate,
|
||||
$duration,
|
||||
$userRate,
|
||||
$timesheetHourly,
|
||||
$timesheetFixed,
|
||||
$activityHourly,
|
||||
$activityFixed,
|
||||
$projectHourly,
|
||||
$projectFixed,
|
||||
$customerHourly,
|
||||
$customerFixed
|
||||
) {
|
||||
$customer = new Customer();
|
||||
$customer
|
||||
->setHourlyRate($customerHourly)
|
||||
->setFixedRate($customerFixed)
|
||||
;
|
||||
|
||||
$project = new Project();
|
||||
$project
|
||||
->setHourlyRate($projectHourly)
|
||||
->setFixedRate($projectFixed)
|
||||
->setCustomer($customer)
|
||||
;
|
||||
|
||||
$activity = new Activity();
|
||||
$activity
|
||||
->setHourlyRate($activityHourly)
|
||||
->setFixedRate($activityFixed)
|
||||
->setProject($project)
|
||||
;
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setEnd(new \DateTime())
|
||||
->setHourlyRate($timesheetHourly)
|
||||
->setFixedRate($timesheetFixed)
|
||||
->setActivity($activity)
|
||||
->setDuration($duration)
|
||||
->setUser($this->getTestUser($userRate))
|
||||
;
|
||||
|
||||
$sut = new RateCalculator([]);
|
||||
$sut->calculate($timesheet);
|
||||
$this->assertEquals($exptectedRate, $timesheet->getRate());
|
||||
}
|
||||
|
||||
protected function getTestUser($rate = self::HOURLY_RATE)
|
||||
{
|
||||
$pref = new UserPreference();
|
||||
$pref->setName(UserPreference::HOURLY_RATE);
|
||||
$pref->setValue(self::HOURLY_RATE);
|
||||
$pref->setValue($rate);
|
||||
|
||||
$user = new User();
|
||||
$user->setPreferences([$pref]);
|
||||
@@ -67,6 +148,8 @@ class RateCalculatorTest extends TestCase
|
||||
$record->setDuration(1800);
|
||||
$record->setFixedRate(100);
|
||||
$record->setHourlyRate(100);
|
||||
$record->setActivity(new Activity());
|
||||
|
||||
$this->assertEquals(0, $record->getRate());
|
||||
|
||||
$sut = new RateCalculator([]);
|
||||
@@ -98,6 +181,8 @@ class RateCalculatorTest extends TestCase
|
||||
$record->setUser($this->getTestUser());
|
||||
$record->setBegin($start);
|
||||
$record->setDuration($seconds);
|
||||
$record->setActivity(new Activity());
|
||||
|
||||
$this->assertEquals(0, $record->getRate());
|
||||
|
||||
$record->setEnd($end);
|
||||
@@ -129,6 +214,8 @@ class RateCalculatorTest extends TestCase
|
||||
$record->setUser($this->getTestUser());
|
||||
$record->setBegin($start);
|
||||
$record->setDuration($seconds);
|
||||
$record->setActivity(new Activity());
|
||||
|
||||
$this->assertEquals(0, $record->getRate());
|
||||
|
||||
$record->setEnd($end);
|
||||
|
||||
@@ -12,14 +12,19 @@ namespace App\Tests\Validator\Constraints;
|
||||
use App\Entity\User;
|
||||
use App\Validator\Constraints\Role;
|
||||
use App\Validator\Constraints\RoleValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\RoleValidator
|
||||
*/
|
||||
class RoleValidatorTest extends TestCase
|
||||
class RoleValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return new RoleValidator();
|
||||
}
|
||||
|
||||
public function getValidRoles()
|
||||
{
|
||||
return [
|
||||
@@ -36,24 +41,59 @@ class RoleValidatorTest extends TestCase
|
||||
*/
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$validator = new RoleValidator();
|
||||
$validator->validate('foo', new NotBlank());
|
||||
$this->validator->validate('foo', new NotBlank());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getValidRoles
|
||||
* @param string $role
|
||||
*/
|
||||
public function testConstraintWithValidRole($role)
|
||||
{
|
||||
$constraint = new Role();
|
||||
$validator = new RoleValidator();
|
||||
$validator->validate($role, $constraint);
|
||||
// the above line would break if the role is invalid, we need the next assert to mark the test as valid
|
||||
$this->assertNull(null);
|
||||
$this->validator->validate($role, $constraint);
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testValidationError()
|
||||
public function testNullIsInvalid()
|
||||
{
|
||||
$this->markTestIncomplete(__CLASS__ . ': validation message not tested yet');
|
||||
$this->validator->validate(null, new Role(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('myMessage')
|
||||
->setParameter('{{ value }}', 'null')
|
||||
->setCode(Role::ROLE_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function getInvalidRoles()
|
||||
{
|
||||
return [
|
||||
['foo'],
|
||||
[0],
|
||||
['role_user'],
|
||||
['ROLE-CUSTOMER'],
|
||||
['anonymous'],
|
||||
[''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getInvalidRoles
|
||||
* @param mixed $role
|
||||
*/
|
||||
public function testValidationError($role)
|
||||
{
|
||||
$constraint = new Role([
|
||||
'message' => 'myMessage',
|
||||
]);
|
||||
|
||||
$this->validator->validate($role, $constraint);
|
||||
|
||||
$expectedFormat = is_string($role) ? '"' . $role . '"' : $role;
|
||||
|
||||
$this->buildViolation('myMessage')
|
||||
->setParameter('{{ value }}', $expectedFormat)
|
||||
->setCode(Role::ROLE_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -23,10 +23,10 @@ There is a [discussion in the issue tracker](https://github.com/kevinpapst/kimai
|
||||
|
||||
There are two solution for this:
|
||||
|
||||
- Update your MariaDB server to at least 10.7.2
|
||||
- Update your MariaDB server to at least 10.2.7
|
||||
- Switch to SQLite (that can be changed in your `.env` file)
|
||||
|
||||
Further readings:
|
||||
|
||||
- [MariaDB - JSON support was added with 10.7.2](https://mariadb.com/kb/en/library/json-data-type/)
|
||||
- [MariaDB - JSON support was added with 10.2.7](https://mariadb.com/kb/en/library/json-data-type/)
|
||||
- [Using JSON fields with Doctrine ORM on PostgreSQL & MySQL](https://symfony.fi/entry/using-json-fields-with-doctrine-orm-on-postgresql-mysql)
|
||||
|
||||
@@ -4,10 +4,14 @@ Before importing your data from a Kimai v1 installation, please read the followi
|
||||
|
||||
- Data from the existing v1 installation is only read and will never be changed
|
||||
- Data can only be imported from a Kimai installation with at least `v1.0.1` and database revision `1388` (check your `configuration` table)
|
||||
- Kimai v1 has support for activities without project assignment, which Kimai v2 doesn't support. Unattached activities will be created for every project that has a linked activity in any of the imported timesheet records
|
||||
- Rates and fixed-rates are handled in a completely different way and for now only the timesheet record total amounts are imported
|
||||
- Customers cannot login and no user accounts will be created for them
|
||||
- The customers country has to be manually assigned afterwards, as there is no field in Kimai v1 for that
|
||||
- Kimai v1 has support for activities without project assignment yet, which Kimai v2 doesn't support
|
||||
- global activities will be created for every project that has a linked activity in any of the imported timesheet records
|
||||
- User-specific rates are not yet supported in Kimai 2
|
||||
- fixed-rates and hourly-rates for projects and activities are imported
|
||||
- fixed-rates and hourly-rates and total rate for timesheet entries are imported
|
||||
- Customers in Kimai 2 are only used for recording
|
||||
- they cannot login and no user accounts will be created for them
|
||||
- country has to be manually assigned afterwards to customers, as there is no field in Kimai v1 for that
|
||||
- You have to supply the default password that is used for every imported user, as their password will be resetted
|
||||
- Data that was deleted in Kimai v1 (user, customer, projects, activities) will be imported and set to `invisible` (if you don't want that, you have to delete all entries that have the value `1` in the `trash` column before importing)
|
||||
|
||||
|
||||
@@ -20,14 +20,32 @@ Please note: if time rounding is activated (which is the default behaviour), the
|
||||
|
||||
## Rate calculation
|
||||
|
||||
The rate of a timesheet entry can be calculated from several settings.
|
||||
There are two rate types:
|
||||
|
||||
It can have two values dedicated to the entry itself:
|
||||
- if a fixed rate is set, the rate of a record is set to this exact value no matter how long the duration is
|
||||
- if a hourly rate is set, it will be used to calculate the record rate by using it multiplied with the records duration
|
||||
- each of the above can be set to 0, to set the records rate to 0
|
||||
- __Fixed rate__: the value will be used to set the records rate, no matter how long the duration is
|
||||
- __Hourly rate__: will be used to calculate the records rate by multiplying it with the duration (see below)
|
||||
|
||||
If none of the above was set the users rate will be used to calculate the records rate.
|
||||
If any of the above is set to 0, the records rate will be set to 0.
|
||||
|
||||
While calculating the rate of a timesheet entry, the first setting that is found will be used (in order of appearance):
|
||||
|
||||
- Timesheet fixed rate
|
||||
- Activity fixed rate
|
||||
- Project fixed rate
|
||||
- Customer fixed rate
|
||||
- Timesheet hourly rate
|
||||
- Activity hourly rate
|
||||
- Project hourly rate
|
||||
- Customer hourly rate
|
||||
- Users hourly rate
|
||||
|
||||
If neither a fixed nor a hourly rate can be found, the users rate will be used to calculate the records rate.
|
||||
In that case and the users rate is not set or equals 0, the records rate will be set to 0.
|
||||
|
||||
The calculation is based on the following formular:
|
||||
|
||||
- __Fixed rate__: `$fixedRate`
|
||||
- __Hourly rate__: `$hourlyRate * ($durationInSeconds / 3600) * $factor`
|
||||
|
||||
Please see also the configuration chapter about [hourly rates for timesheet records](configurations.md) to see how you
|
||||
can apply configurable multiplying factors based on day and time.
|
||||
|
||||
Reference in New Issue
Block a user