From 8cae5e8b3fa606ced11a60ed5125cf1600e26b8a Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Thu, 6 Sep 2018 23:37:25 +0200 Subject: [PATCH] added fixed-rate and hourly-rate for customer, project and activity (#304) --- .travis.yml | 2 + README.md | 4 +- src/Command/KimaiImporterCommand.php | 146 ++++++++++++++---- src/Controller/AbstractController.php | 26 +--- src/Controller/ActivityController.php | 3 +- src/Controller/ProfileController.php | 10 +- src/Controller/TimesheetController.php | 2 +- src/Doctrine/AbstractMigration.php | 8 +- src/Entity/Activity.php | 54 +++++++ src/Entity/Customer.php | 54 +++++++ src/Entity/Project.php | 54 +++++++ src/Entity/Timesheet.php | 2 + src/Form/ActivityEditForm.php | 9 ++ src/Form/CustomerEditForm.php | 15 +- src/Form/ProjectEditForm.php | 13 +- src/Migrations/Version20180715160326.php | 2 +- src/Migrations/Version20180903202256.php | 38 ++--- src/Migrations/Version20180905190737.php | 90 +++++++++++ src/Timesheet/Calculator/RateCalculator.php | 86 +++++++++-- tests/Controller/ActivityControllerTest.php | 35 ++++- tests/Controller/ProfileControllerTest.php | 133 +++++++++++++--- tests/Controller/TimesheetControllerTest.php | 127 ++++++++++++++- tests/DataFixtures/TimesheetFixtures.php | 5 +- tests/Entity/ActivityTest.php | 50 ++++++ tests/Entity/CustomerTest.php | 90 +++++++++++ tests/Entity/ProjectTest.php | 68 ++++++++ .../Calculator/RateCalculatorTest.php | 95 +++++++++++- .../Constraints/RoleValidatorTest.php | 60 +++++-- var/data/kimai_test.sqlite | Bin 770048 -> 770048 bytes var/docs/faq.md | 4 +- var/docs/migration_v1.md | 12 +- var/docs/timesheet.md | 30 +++- 32 files changed, 1173 insertions(+), 154 deletions(-) create mode 100644 src/Migrations/Version20180905190737.php create mode 100644 tests/Entity/ActivityTest.php create mode 100644 tests/Entity/CustomerTest.php create mode 100644 tests/Entity/ProjectTest.php diff --git a/.travis.yml b/.travis.yml index 2c0813fc..0d4dbdaf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/README.md b/README.md index 397e9900..afe027ea 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Command/KimaiImporterCommand.php b/src/Command/KimaiImporterCommand.php index a6ef2ac3..e44923a4 100644 --- a/src/Command/KimaiImporterCommand.php +++ b/src/Command/KimaiImporterCommand.php @@ -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; diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index e2e4e8f5..8f2f9b9f 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -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 = []) diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 635ef1db..a325840d 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -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() { diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 4a630f9a..f95b264d 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -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); diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index a1b76091..89938cd5 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -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)") diff --git a/src/Doctrine/AbstractMigration.php b/src/Doctrine/AbstractMigration.php index 3d7c99e0..1be48a11 100644 --- a/src/Doctrine/AbstractMigration.php +++ b/src/Doctrine/AbstractMigration.php @@ -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) { diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 5febc090..9f2dd97f 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -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 */ diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 5e79175c..5ef52785 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -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 */ diff --git a/src/Entity/Project.php b/src/Entity/Project.php index b82c3e58..65e7d372 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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 */ diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index f3b4fd18..83f0975c 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -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; } diff --git a/src/Form/ActivityEditForm.php b/src/Form/ActivityEditForm.php index de5661ba..ea4c98ac 100644 --- a/src/Form/ActivityEditForm.php +++ b/src/Form/ActivityEditForm.php @@ -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', diff --git a/src/Form/CustomerEditForm.php b/src/Form/CustomerEditForm.php index 176b97a4..efc820b5 100644 --- a/src/Form/CustomerEditForm.php +++ b/src/Form/CustomerEditForm.php @@ -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', + ]) ; } diff --git a/src/Form/ProjectEditForm.php b/src/Form/ProjectEditForm.php index 82dc275d..34e0fca4 100644 --- a/src/Form/ProjectEditForm.php +++ b/src/Form/ProjectEditForm.php @@ -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) { diff --git a/src/Migrations/Version20180715160326.php b/src/Migrations/Version20180715160326.php index 7391fc53..6b2834b8 100644 --- a/src/Migrations/Version20180715160326.php +++ b/src/Migrations/Version20180715160326.php @@ -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%"'); diff --git a/src/Migrations/Version20180903202256.php b/src/Migrations/Version20180903202256.php index 04534c6f..f851ac19 100644 --- a/src/Migrations/Version20180903202256.php +++ b/src/Migrations/Version20180903202256.php @@ -1,4 +1,13 @@ -getPlatform(); @@ -19,25 +28,12 @@ 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 + public function down(Schema $schema): void { $platform = $this->getPlatform(); @@ -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'); } } } diff --git a/src/Migrations/Version20180905190737.php b/src/Migrations/Version20180905190737.php new file mode 100644 index 00000000..f4680293 --- /dev/null +++ b/src/Migrations/Version20180905190737.php @@ -0,0 +1,90 @@ +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'); + } + } +} diff --git a/src/Timesheet/Calculator/RateCalculator.php b/src/Timesheet/Calculator/RateCalculator.php index f0e19d86..7cbed6b4 100644 --- a/src/Timesheet/Calculator/RateCalculator.php +++ b/src/Timesheet/Calculator/RateCalculator.php @@ -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; } } diff --git a/tests/Controller/ActivityControllerTest.php b/tests/Controller/ActivityControllerTest.php index a85575ca..e3a68367 100644 --- a/tests/Controller/ActivityControllerTest.php +++ b/tests/Controller/ActivityControllerTest.php @@ -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('