From 6163f33abf347a8187b183035e8c16851ab18a66 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Wed, 5 Sep 2018 15:27:39 +0200 Subject: [PATCH] add hourly_rate and fixed_rate to timesheet entries (#302) --- .github_changelog_generator | 1 + UPGRADING.md | 2 +- composer.lock | 10 +-- src/Entity/Timesheet.php | 53 ++++++++++++++ src/Form/TimesheetEditForm.php | 9 +++ src/Migrations/Version20180730044139.php | 2 +- src/Migrations/Version20180805183527.php | 2 +- src/Migrations/Version20180903202256.php | 67 ++++++++++++++++++ src/Timesheet/Calculator/RateCalculator.php | 11 ++- tests/Entity/TimesheetTest.php | 20 ++++++ .../Calculator/RateCalculatorTest.php | 29 ++++++++ translations/messages.de.xliff | 4 ++ translations/messages.en.xliff | 4 ++ var/data/kimai_test.sqlite | Bin 770048 -> 770048 bytes var/docs/timesheet.md | 14 ++++ 15 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 src/Migrations/Version20180903202256.php diff --git a/.github_changelog_generator b/.github_changelog_generator index 83cde04d..35622407 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,2 +1,3 @@ unreleased=true future-release=0.4 +exclude-labels=duplicate,question,invalid,wontfix \ No newline at end of file diff --git a/UPGRADING.md b/UPGRADING.md index 7930638b..3425cfda 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -32,7 +32,7 @@ which leads to problems between Composer and Symfony Flex, resulting in an error This can be fixed by updating Composer and Flex before executing the Kimai update: ``` sudo composer self-update -sudo -u www-data composer update symfony/flex --no-plugins +sudo -u www-data composer update symfony/flex --no-plugins --no-scripts ``` Then the full update can be executed as usual: diff --git a/composer.lock b/composer.lock index 6f7720ed..af873bad 100644 --- a/composer.lock +++ b/composer.lock @@ -3599,16 +3599,16 @@ }, { "name": "symfony/flex", - "version": "v1.1.0", + "version": "v1.1.1", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "d6f5fed47ddad2eb25d5cc19316692203a2898eb" + "reference": "9fb60f232af0764d58002e7872acb43a74506d25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/d6f5fed47ddad2eb25d5cc19316692203a2898eb", - "reference": "d6f5fed47ddad2eb25d5cc19316692203a2898eb", + "url": "https://api.github.com/repos/symfony/flex/zipball/9fb60f232af0764d58002e7872acb43a74506d25", + "reference": "9fb60f232af0764d58002e7872acb43a74506d25", "shasum": "" }, "require": { @@ -3642,7 +3642,7 @@ } ], "description": "Composer plugin for Symfony", - "time": "2018-08-21T07:51:18+00:00" + "time": "2018-09-03T08:17:12+00:00" }, { "name": "symfony/form", diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index b2a1a424..f3b4fd18 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -89,9 +89,26 @@ class Timesheet * @var float * * @ORM\Column(name="rate", type="decimal", precision=10, scale=2, nullable=false) + * @Assert\GreaterThanOrEqual(0) */ private $rate = 0.00; + /** + * @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 entry id * @@ -259,6 +276,42 @@ class Timesheet return $this->rate; } + /** + * @return float + */ + public function getFixedRate(): ?float + { + return $this->fixedRate; + } + + /** + * @param float $fixedRate + * @return Timesheet + */ + public function setFixedRate(?float $fixedRate) + { + $this->fixedRate = $fixedRate; + return $this; + } + + /** + * @return float + */ + public function getHourlyRate(): ?float + { + return $this->hourlyRate; + } + + /** + * @param float $hourlyRate + * @return Timesheet + */ + public function setHourlyRate(?float $hourlyRate) + { + $this->hourlyRate = $hourlyRate; + return $this; + } + /** * @param ExecutionContextInterface $context * @param mixed $payload diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index 99fe4e12..93001708 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -16,6 +16,7 @@ use App\Form\Type\UserType; use App\Repository\ActivityRepository; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -74,6 +75,14 @@ class TimesheetEditForm extends AbstractType 'label' => 'label.description', 'required' => false, ]) + ->add('fixedRate', NumberType::class, [ + 'label' => 'label.fixed_rate', + 'required' => false, + ]) + ->add('hourlyRate', NumberType::class, [ + 'label' => 'label.hourly_rate', + 'required' => false, + ]) ; if ($options['include_user']) { diff --git a/src/Migrations/Version20180730044139.php b/src/Migrations/Version20180730044139.php index 6557e4f4..774cb293 100644 --- a/src/Migrations/Version20180730044139.php +++ b/src/Migrations/Version20180730044139.php @@ -74,7 +74,7 @@ final class Version20180730044139 extends AbstractMigration } $timesheet = $this->getTableName('timesheet'); - $user = $this->getTableName('user'); + $user = $this->getTableName('users'); if ($platform === 'sqlite') { $this->addSql('DROP INDEX IDX_4F60C6B18D93D649'); diff --git a/src/Migrations/Version20180805183527.php b/src/Migrations/Version20180805183527.php index 258a5d15..8860b8f8 100644 --- a/src/Migrations/Version20180805183527.php +++ b/src/Migrations/Version20180805183527.php @@ -54,7 +54,7 @@ final class Version20180805183527 extends AbstractMigration $this->abortIf(true, 'Unsupported database platform: ' . $platform); } - $user = $this->getTableName('user'); + $user = $this->getTableName('users'); if ($platform === 'sqlite') { $this->addSql('DROP INDEX UNIQ_B9AC5BCE92FC23A8'); diff --git a/src/Migrations/Version20180903202256.php b/src/Migrations/Version20180903202256.php new file mode 100644 index 00000000..04534c6f --- /dev/null +++ b/src/Migrations/Version20180903202256.php @@ -0,0 +1,67 @@ +getPlatform(); + + if (!in_array($platform, ['sqlite', 'mysql'])) { + $this->abortIf(true, 'Unsupported database platform: ' . $platform); + } + + $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'); + } + } + + public function down(Schema $schema) : void + { + $platform = $this->getPlatform(); + + if (!in_array($platform, ['sqlite', 'mysql'])) { + $this->abortIf(true, 'Unsupported database platform: ' . $platform); + } + + $timesheet = $this->getTableName('timesheet'); + $user = $this->getTableName('users'); + + if ($platform === 'sqlite') { + $this->addSql('DROP INDEX IDX_4F60C6B18D93D649'); + $this->addSql('DROP INDEX IDX_4F60C6B181C06096'); + $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, rate NUMERIC(10, 2) NOT NULL)'); + $this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, start_time, end_time, duration, description, rate) SELECT id, user, activity_id, start_time, end_time, duration, description, rate FROM __temp__' . $timesheet); + $this->addSql('DROP TABLE __temp__' . $timesheet); + $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)'); + } + } +} diff --git a/src/Timesheet/Calculator/RateCalculator.php b/src/Timesheet/Calculator/RateCalculator.php index 1a240718..f0e19d86 100644 --- a/src/Timesheet/Calculator/RateCalculator.php +++ b/src/Timesheet/Calculator/RateCalculator.php @@ -41,6 +41,11 @@ class RateCalculator implements CalculatorInterface return; } + if (null !== $record->getFixedRate()) { + $record->setRate($record->getFixedRate()); + return; + } + $rate = $this->calculateRate($record); $factor = $this->getRateFactor($record); @@ -80,7 +85,11 @@ class RateCalculator implements CalculatorInterface */ protected function calculateRate(Timesheet $record) { - $hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0); + if (null !== $record->getHourlyRate()) { + $hourlyRate = $record->getHourlyRate(); + } else { + $hourlyRate = (float)$record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0); + } return (float) $hourlyRate * ($record->getDuration() / 3600); } diff --git a/tests/Entity/TimesheetTest.php b/tests/Entity/TimesheetTest.php index 21b4bb30..1fbe5b3b 100644 --- a/tests/Entity/TimesheetTest.php +++ b/tests/Entity/TimesheetTest.php @@ -20,6 +20,26 @@ use App\Entity\User; */ class TimesheetTest extends AbstractEntityTest { + public function testDefaultValues() + { + $sut = new Timesheet(); + $this->assertNull($sut->getId()); + $this->assertNull($sut->getBegin()); + $this->assertNull($sut->getEnd()); + $this->assertSame(0, $sut->getDuration()); + $this->assertNull($sut->getUser()); + $this->assertNull($sut->getActivity()); + $this->assertNull($sut->getDescription()); + $this->assertSame(0.00, $sut->getRate()); + $this->assertNull($sut->getFixedRate()); + $this->assertNull($sut->getHourlyRate()); + + $this->assertInstanceOf(Timesheet::class, $sut->setFixedRate(13.47)); + $this->assertEquals(13.47, $sut->getFixedRate()); + $this->assertInstanceOf(Timesheet::class, $sut->setHourlyRate(99)); + $this->assertEquals(99, $sut->getHourlyRate()); + } + protected function getEntity() { $customer = new Customer(); diff --git a/tests/Timesheet/Calculator/RateCalculatorTest.php b/tests/Timesheet/Calculator/RateCalculatorTest.php index 57330284..312c3000 100644 --- a/tests/Timesheet/Calculator/RateCalculatorTest.php +++ b/tests/Timesheet/Calculator/RateCalculatorTest.php @@ -22,6 +22,32 @@ class RateCalculatorTest extends TestCase { public const HOURLY_RATE = 75; + public function testCalculateWithHourlyRate() + { + $record = new Timesheet(); + $record->setEnd(new \DateTime()); + $record->setDuration(1800); + $record->setHourlyRate(100); + + $sut = new RateCalculator([]); + $sut->calculate($record); + $this->assertEquals(50, $record->getRate()); + } + + public function testCalculateWithFixedRate() + { + $record = new Timesheet(); + $record->setEnd(new \DateTime()); + $record->setDuration(1800); + $record->setFixedRate(10); + // make sure that fixed rate is always applied, even if hourly rate is set + $record->setHourlyRate(99); + + $sut = new RateCalculator([]); + $sut->calculate($record); + $this->assertEquals(10, $record->getRate()); + } + protected function getTestUser() { $pref = new UserPreference(); @@ -38,6 +64,9 @@ class RateCalculatorTest extends TestCase { $record = new Timesheet(); $record->setBegin(new \DateTime()); + $record->setDuration(1800); + $record->setFixedRate(100); + $record->setHourlyRate(100); $this->assertEquals(0, $record->getRate()); $sut = new RateCalculator([]); diff --git a/translations/messages.de.xliff b/translations/messages.de.xliff index e931fccf..1031137d 100644 --- a/translations/messages.de.xliff +++ b/translations/messages.de.xliff @@ -319,6 +319,10 @@ modal.columns.description Beim Speichern werden die nicht ausgewählten Spalten ausgeblendet und diese Einstellungen in einem Browser Cookie hinterlegt. Sollten Sie Ihre Cookies löschen, werden die Einstellungen rückgängig gemacht. + + label.fixed_rate + Festbetrag +