Configurable rate rounding (#5734)

* added invoice hydration of the issuer object
* added "rate calculator" mode
* new config to select rounding mode
* replace static calls with dependency injection
This commit is contained in:
Kevin Papst
2025-12-16 16:47:46 +01:00
committed by GitHub
parent 07ac6c308f
commit 595b5f4b25
43 changed files with 876 additions and 411 deletions

View File

@@ -86,6 +86,9 @@ services:
App\Timesheet\RateService: App\Timesheet\RateService:
arguments: ['%kimai.timesheet.rates%'] arguments: ['%kimai.timesheet.rates%']
App\Timesheet\RateCalculator\RateCalculatorMode:
factory: ['@App\Timesheet\RateCalculator\RateCalculatorFactory', getRateCalculatorMode]
# ================================================================================ # ================================================================================
# SECURITY & VOTER # SECURITY & VOTER
# ================================================================================ # ================================================================================

View File

@@ -969,11 +969,6 @@ parameters:
count: 1 count: 1
path: src/DataFixtures/TimesheetFixtures.php path: src/DataFixtures/TimesheetFixtures.php
-
message: "#^Parameter \\#1 \\$user of method App\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
count: 2
path: src/DataFixtures/TimesheetFixtures.php
- -
message: "#^Method App\\\\DependencyInjection\\\\AppExtension\\:\\:createPermissionParameter\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\DependencyInjection\\\\AppExtension\\:\\:createPermissionParameter\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
count: 1 count: 1

View File

@@ -449,6 +449,11 @@ final class SystemConfigurationController extends AbstractController
->setType(TextType::class) ->setType(TextType::class)
->setConstraints([new NotBlank()]) ->setConstraints([new NotBlank()])
->setTranslationDomain('system-configuration'), ->setTranslationDomain('system-configuration'),
(new Configuration('invoice.rounding_mode'))
->setLabel('invoice.rounding_mode')
->setType(ChoiceType::class)
->setOptions(['choices' => ['classic' => 'classic', 'decimal' => 'decimal']])
->setTranslationDomain('system-configuration'),
]), ]),
$authentication, $authentication,
(new SystemConfigurationModel('customer')) (new SystemConfigurationModel('customer'))

View File

@@ -15,7 +15,8 @@ use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Timesheet\Util; use App\Timesheet\RateCalculator\ClassicRateCalculator;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
@@ -60,10 +61,14 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$allUser = $this->getAllUsers($manager); $allUser = $this->getAllUsers($manager);
$faker = Factory::create(); $faker = Factory::create();
$all = 0; $all = 0;
$calculator = new ClassicRateCalculator();
foreach ($allUser as $user) { foreach ($allUser as $user) {
// reload, because the manager might have been cleared // reload, because the manager might have been cleared
$user = $manager->find(User::class, $user->getId()); $user = $manager->find(User::class, $user->getId());
if ($user === null) {
continue;
}
// random amount of timesheet entries for every user // random amount of timesheet entries for every user
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER); $timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
@@ -84,6 +89,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
} }
$entry = $this->createTimesheetEntry( $entry = $this->createTimesheetEntry(
$calculator,
$user, $user,
$activities[array_rand($activities)], $activities[array_rand($activities)],
$projects[array_rand($projects)], $projects[array_rand($projects)],
@@ -98,6 +104,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
// create active records // create active records
if ($all % 3 === 0) { if ($all % 3 === 0) {
$entry = $this->createTimesheetEntry( $entry = $this->createTimesheetEntry(
$calculator,
$user, $user,
$activities[array_rand($activities)], $activities[array_rand($activities)],
$projects[array_rand($projects)], $projects[array_rand($projects)],
@@ -208,7 +215,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
return $this->findRandom($manager, Activity::class, 50); return $this->findRandom($manager, Activity::class, 50);
} }
private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate): Timesheet private function createTimesheetEntry(RateCalculatorMode $calculatorMode, User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate): Timesheet
{ {
$start = $this->getRandomFirstDay(); $start = $this->getRandomFirstDay();
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds'); $start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
@@ -227,7 +234,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$duration = $end->getTimestamp() - $start->getTimestamp(); $duration = $end->getTimestamp() - $start->getTimestamp();
$hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE); $hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE);
$rate = Util::calculateRate($hourlyRate, $duration); $rate = $calculatorMode->calculateRate($hourlyRate, $duration);
$entry->setEnd($end); $entry->setEnd($end);
$entry->setRate($rate); $entry->setRate($rate);

View File

@@ -369,6 +369,10 @@ final class Configuration implements ConfigurationInterface
->booleanNode('upload_twig') ->booleanNode('upload_twig')
->defaultFalse() ->defaultFalse()
->end() ->end()
->enumNode('rounding_mode')
->values(['decimal', 'classic'])
->defaultValue('classic')
->end()
->end() ->end()
; ;

View File

@@ -12,7 +12,6 @@ namespace App\Invoice\Calculator;
use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow; use App\Invoice\TaxRow;
use App\Timesheet\Util;
abstract class AbstractCalculator abstract class AbstractCalculator
{ {
@@ -42,10 +41,10 @@ abstract class AbstractCalculator
if (\count($this->cached) === 0) { if (\count($this->cached) === 0) {
foreach ($this->calculateEntries() as $entry) { foreach ($this->calculateEntries() as $entry) {
if (!$entry->isFixedRate() && $entry->getHourlyRate() !== null && $entry->getHourlyRate() > 0) { if (!$entry->isFixedRate() && $entry->getHourlyRate() !== null && $entry->getHourlyRate() > 0) {
$entry->setDuration(Util::decimalizeDuration($entry->getDuration())); $entry->setDuration($this->model->getRateCalculatorMode()->roundDuration($entry->getDuration()));
// when merging many entries, we might run into rounding issues // when merging many entries, we might run into rounding issues
// so we have to recalculate the hourly rate here // so we have to recalculate the hourly rate here
$entry->setRate(Util::calculateRate($entry->getHourlyRate(), $entry->getDuration())); $entry->setRate($this->model->getRateCalculatorMode()->calculateRate($entry->getHourlyRate(), $entry->getDuration()));
} }
$this->cached[] = $entry; $this->cached[] = $entry;

View File

@@ -32,7 +32,7 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
$values = [ $values = [
$prefix . 'id' => $customer->getId(), $prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getFormattedAddress() ?? '', $prefix . 'address' => $customer->getFormattedAddress() ?? '', // deprecated since 2.44
$prefix . 'address_line1' => $customer->getAddressLine1() ?? '', $prefix . 'address_line1' => $customer->getAddressLine1() ?? '',
$prefix . 'address_line2' => $customer->getAddressLine2() ?? '', $prefix . 'address_line2' => $customer->getAddressLine2() ?? '',
$prefix . 'address_line3' => $customer->getAddressLine3() ?? '', $prefix . 'address_line3' => $customer->getAddressLine3() ?? '',

View File

@@ -62,12 +62,12 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.subtotal_plain' => $subtotal, 'invoice.subtotal_plain' => $subtotal,
'template.name' => $template->getName() ?? '', 'template.name' => $template->getName() ?? '',
'template.company' => $template->getCompany() ?? '', 'template.company' => $template->getCompany() ?? '', // deprecated since 2.45
'template.address' => $template->getAddress() ?? '', 'template.address' => $template->getAddress() ?? '',
'template.title' => $template->getTitle() ?? '', 'template.title' => $template->getTitle() ?? '',
'template.payment_terms' => $template->getPaymentTerms() ?? '', 'template.payment_terms' => $template->getPaymentTerms() ?? '',
'template.due_days' => $template->getDueDays(), 'template.due_days' => $template->getDueDays(),
'template.vat_id' => $template->getVatId() ?? '', 'template.vat_id' => $template->getVatId() ?? '', // deprecated since 2.45
'template.contact' => $template->getContact() ?? '', 'template.contact' => $template->getContact() ?? '',
'template.country' => null, 'template.country' => null,
'template.country_name' => null, 'template.country_name' => null,
@@ -105,8 +105,8 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
if ($seller !== null) { if ($seller !== null) {
$country = $seller->getCountry(); $country = $seller->getCountry();
if ($country !== null) { if ($country !== null) {
$values['template.country'] = $country; $values['template.country'] = $country; // deprecated since 2.45
$values['template.country_name'] = Countries::getName($country, $language); $values['template.country_name'] = Countries::getName($country, $language); // deprecated since 2.45
} }
} }
@@ -116,14 +116,10 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
if ($begin !== null) { if ($begin !== null) {
$values = array_merge($values, [ $values = array_merge($values, [
'query.day' => $begin->format('d'), 'query.day' => $begin->format('d'),
// @deprecated - but impossible to delete 'query.month' => $formatter->getFormattedMonthName($begin), // @deprecated - but impossible to delete
'query.month' => $formatter->getFormattedMonthName($begin), 'query.month_number' => $begin->format('m'), // @deprecated - but impossible to delete
// @deprecated - but impossible to delete 'query.year' => $begin->format('Y'), // @deprecated - but impossible to delete
'query.month_number' => $begin->format('m'), 'query.begin' => $formatter->getFormattedDateTime($begin), // @deprecated - but impossible to delete
// @deprecated - but impossible to delete
'query.year' => $begin->format('Y'),
// @deprecated - but impossible to delete
'query.begin' => $formatter->getFormattedDateTime($begin),
'query.begin_process' => $begin->format(self::DATE_PROCESS_FORMAT), // since 2.14 'query.begin_process' => $begin->format(self::DATE_PROCESS_FORMAT), // since 2.14
'query.begin_day' => $begin->format('d'), 'query.begin_day' => $begin->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($begin), 'query.begin_month' => $formatter->getFormattedMonthName($begin),

View File

@@ -0,0 +1,65 @@
<?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\Invoice\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use Symfony\Component\Intl\Countries;
final class InvoiceModelIssuerHydrator implements InvoiceModelHydrator
{
/**
* @return array<string, mixed>
*/
public function hydrate(InvoiceModel $model): array
{
$customer = $model->getTemplate()->getCustomer();
if (null === $customer) {
return [];
}
$prefix = 'issuer.';
$language = $model->getTemplate()->getLanguage();
$country = $customer->getCountry();
$values = [
$prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getFormattedAddress() ?? '',
$prefix . 'address_line1' => $customer->getAddressLine1() ?? '',
$prefix . 'address_line2' => $customer->getAddressLine2() ?? '',
$prefix . 'address_line3' => $customer->getAddressLine3() ?? '',
$prefix . 'postcode' => $customer->getPostCode() ?? '',
$prefix . 'city' => $customer->getCity() ?? '',
$prefix . 'name' => $customer->getName() ?? '',
$prefix . 'contact' => $customer->getContact() ?? '',
$prefix . 'company' => $customer->getCompany() ?? '',
$prefix . 'vat_id' => $customer->getVatId() ?? '',
$prefix . 'number' => $customer->getNumber() ?? '',
$prefix . 'country' => $country,
$prefix . 'country_name' => $country !== null ? Countries::getName($country, $language) : null,
$prefix . 'homepage' => $customer->getHomepage() ?? '',
$prefix . 'comment' => $customer->getComment() ?? '',
$prefix . 'email' => $customer->getEmail() ?? '',
$prefix . 'fax' => $customer->getFax() ?? '',
$prefix . 'phone' => $customer->getPhone() ?? '',
$prefix . 'mobile' => $customer->getMobile() ?? '',
$prefix . 'invoice_text' => $customer->getInvoiceText() ?? '',
$prefix . 'buyer_reference' => $customer->getBuyerReference() ?? '',
];
foreach ($customer->getMetaFields() as $metaField) {
$values = array_merge($values, [
$prefix . 'meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
}
}

View File

@@ -19,10 +19,12 @@ use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator; use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator; use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator; use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator; use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator; use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Project\ProjectStatisticService; use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\DependencyInjection\Attribute\Exclude;
/** /**
@@ -68,12 +70,14 @@ final class InvoiceModel
ActivityStatisticService $activityStatistic, ActivityStatisticService $activityStatistic,
private readonly Customer $customer, private readonly Customer $customer,
private readonly InvoiceTemplate $template, private readonly InvoiceTemplate $template,
private readonly RateCalculatorMode $rateCalculatorMode
) )
{ {
$this->invoiceDate = new \DateTimeImmutable(); $this->invoiceDate = new \DateTimeImmutable();
$this->formatter = $formatter; $this->formatter = $formatter;
$this->addModelHydrator(new InvoiceModelDefaultHydrator()); $this->addModelHydrator(new InvoiceModelDefaultHydrator());
$this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic)); $this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic));
$this->addModelHydrator(new InvoiceModelIssuerHydrator());
$this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic)); $this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic));
$this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic)); $this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic));
$this->addModelHydrator(new InvoiceModelUserHydrator()); $this->addModelHydrator(new InvoiceModelUserHydrator());
@@ -207,6 +211,11 @@ final class InvoiceModel
return $this->calculator; return $this->calculator;
} }
public function getRateCalculatorMode(): RateCalculatorMode
{
return $this->rateCalculatorMode;
}
/** /**
* Returns the user currently creating the invoice. * Returns the user currently creating the invoice.
*/ */

View File

@@ -15,19 +15,21 @@ use App\Entity\Customer;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Project\ProjectStatisticService; use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
final class InvoiceModelFactory final class InvoiceModelFactory
{ {
public function __construct( public function __construct(
private readonly CustomerStatisticService $customerStatisticService, private readonly CustomerStatisticService $customerStatisticService,
private readonly ProjectStatisticService $projectStatisticService, private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService private readonly ActivityStatisticService $activityStatisticService,
private readonly RateCalculatorMode $rateCalculatorMode
) { ) {
} }
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
{ {
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template); $model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode);
$model->setQuery($query); $model->setQuery($query);

View File

@@ -49,11 +49,11 @@ final class ServiceInvoice
private array $invoiceItemRepositories = []; private array $invoiceItemRepositories = [];
public function __construct( public function __construct(
private InvoiceDocumentRepository $documents, private readonly InvoiceDocumentRepository $documents,
private FileHelper $fileHelper, private readonly FileHelper $fileHelper,
private InvoiceRepository $invoiceRepository, private readonly InvoiceRepository $invoiceRepository,
private LocaleService $formatter, private readonly LocaleService $formatter,
private InvoiceModelFactory $invoiceModelFactory private readonly InvoiceModelFactory $invoiceModelFactory
) { ) {
} }

View File

@@ -18,7 +18,7 @@ use App\Timesheet\RateServiceInterface;
*/ */
final class RateCalculator implements CalculatorInterface final class RateCalculator implements CalculatorInterface
{ {
public function __construct(private RateServiceInterface $service) public function __construct(private readonly RateServiceInterface $service)
{ {
} }

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
final class ClassicRateCalculator implements RateCalculatorMode
{
/**
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = $hourlyRate * ($seconds / 3600);
return round($rate, 4);
}
/**
* Does not round the duration, we keep the original sum.
*/
public function roundDuration(int $seconds): int
{
return $seconds;
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
final class DecimalRateCalculator implements RateCalculatorMode
{
/**
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = $hourlyRate * round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return round($rate, 2, PHP_ROUND_HALF_UP);
}
/**
* Makes sure tha the duration is full compatible with decimal format, stripping away overflowing seconds.
*/
public function roundDuration(int $seconds): int
{
$decimal = round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return (int) round(($decimal * 3600), 0, PHP_ROUND_HALF_UP);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
use App\Configuration\SystemConfiguration;
/**
* @final
*/
class RateCalculatorFactory
{
public function __construct(private readonly SystemConfiguration $configuration)
{
}
/**
* @internal do not call, use RateCalculatorMode injection
*/
public function getRateCalculatorMode(): RateCalculatorMode
{
if ($this->configuration->find('invoice.rounding_mode') === 'decimal') {
return new DecimalRateCalculator();
}
return new ClassicRateCalculator();
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
interface RateCalculatorMode
{
/**
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public function calculateRate(float $hourlyRate, int $seconds): float;
/**
* * Makes sure tha the duration is fully rounded.
*/
public function roundDuration(int $seconds): int;
}

View File

@@ -13,13 +13,18 @@ use App\Entity\RateInterface;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\RateCalculator\RateCalculatorMode;
/** /**
* Implementation to calculate the rate for a timesheet record. * Implementation to calculate the rate for a timesheet record.
*/ */
final class RateService implements RateServiceInterface final class RateService implements RateServiceInterface
{ {
public function __construct(private array $rates, private TimesheetRepository $repository) public function __construct(
private readonly array $rates,
private readonly TimesheetRepository $repository,
private readonly RateCalculatorMode $calculatorMode
)
{ {
} }
@@ -79,8 +84,8 @@ final class RateService implements RateServiceInterface
$totalInternalRate = 0; $totalInternalRate = 0;
if (null !== $record->getDuration()) { if (null !== $record->getDuration()) {
$totalRate = Util::calculateRate($factoredHourlyRate, $record->getDuration()); $totalRate = $this->calculatorMode->calculateRate($factoredHourlyRate, $record->getDuration());
$totalInternalRate = Util::calculateRate($factoredInternalRate, $record->getDuration()); $totalInternalRate = $this->calculatorMode->calculateRate($factoredInternalRate, $record->getDuration());
} }
return new Rate($totalRate, $totalInternalRate, $factoredHourlyRate, null); return new Rate($totalRate, $totalInternalRate, $factoredHourlyRate, null);

View File

@@ -10,7 +10,7 @@
namespace App\Timesheet; namespace App\Timesheet;
/** /**
* A static helper class for re-usable functionality. * @deprecated use RateCalculatorMode instead
*/ */
final class Util final class Util
{ {
@@ -29,7 +29,7 @@ final class Util
} }
/** /**
* Makes sure tha the duration is full compatible with decimal format, stripping away overflowing seconds. * Makes sure that the duration is full compatible with decimal format, stripping away overflowing seconds.
*/ */
public static function decimalizeDuration(int $seconds): int public static function decimalizeDuration(int $seconds): int
{ {

View File

@@ -15,9 +15,6 @@ use App\Entity\ProjectRate;
use App\Entity\RateInterface; use App\Entity\RateInterface;
use App\Entity\User; use App\Entity\User;
/**
* @group integration
*/
trait RateControllerTestTrait trait RateControllerTestTrait
{ {
abstract protected function getRateUrl(?int $id = 1, ?int $rateId = null): string; abstract protected function getRateUrl(?int $id = 1, ?int $rateId = null): string;

View File

@@ -426,8 +426,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
'exported' => true, 'exported' => true,
'metaFields' => [], 'metaFields' => [],
'hourlyRate' => 137.21, 'hourlyRate' => 137.21,
'rate' => 1772.75, // 12,92 * 137,21 'rate' => 1772.2958,
'internalRate' => 1772.75, 'internalRate' => 1772.2958,
]; ];
foreach ($expected as $key => $value) { foreach ($expected as $key => $value) {

View File

@@ -16,7 +16,6 @@ use App\Form\Type\TagsType;
use App\Tests\DataFixtures\TagFixtures; use App\Tests\DataFixtures\TagFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Timesheet\DateTimeFactory; use App\Timesheet\DateTimeFactory;
use App\Timesheet\Util;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
#[Group('integration')] #[Group('integration')]
@@ -414,7 +413,7 @@ class TimesheetTeamControllerTest extends AbstractControllerBaseTestCase
self::assertCount(3, $timesheet->getTags()); self::assertCount(3, $timesheet->getTags());
self::assertEquals($newUser->getId(), $timesheet->getUser()->getId()); self::assertEquals($newUser->getId(), $timesheet->getUser()->getId());
self::assertTrue($timesheet->isExported()); self::assertTrue($timesheet->isExported());
self::assertEquals(Util::calculateRate(13.78, $timesheet->getDuration()), $timesheet->getRate()); self::assertGreaterThan(0, $timesheet->getRate());
} }
} }

View File

@@ -15,7 +15,8 @@ use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Timesheet\Util; use App\Timesheet\RateCalculator\ClassicRateCalculator;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
@@ -205,6 +206,7 @@ final class TimesheetFixtures implements TestFixture
} }
} }
$manager->flush(); $manager->flush();
$calculator = new ClassicRateCalculator();
for ($i = 0; $i < $this->amount; $i++) { for ($i = 0; $i < $this->amount; $i++) {
$description = $faker->text(); $description = $faker->text();
@@ -217,6 +219,9 @@ final class TimesheetFixtures implements TestFixture
} }
$user = $users[array_rand($users)]; $user = $users[array_rand($users)];
if ($user === null) {
continue;
}
$activity = $activities[array_rand($activities)]; $activity = $activities[array_rand($activities)];
$project = $activity->getProject(); $project = $activity->getProject();
@@ -225,6 +230,7 @@ final class TimesheetFixtures implements TestFixture
} }
$timesheet = $this->createTimesheetEntry( $timesheet = $this->createTimesheetEntry(
$calculator,
$user, $user,
$activity, $activity,
$project, $project,
@@ -244,12 +250,16 @@ final class TimesheetFixtures implements TestFixture
$activity = $activities[array_rand($activities)]; $activity = $activities[array_rand($activities)];
$project = $activity->getProject(); $project = $activity->getProject();
$user = $users[array_rand($users)]; $user = $users[array_rand($users)];
if ($user === null) {
continue;
}
if (null === $project) { if ($project === null) {
$project = $projects[array_rand($projects)]; $project = $projects[array_rand($projects)];
} }
$timesheet = $this->createTimesheetEntry( $timesheet = $this->createTimesheetEntry(
$calculator,
$user, $user,
$activity, $activity,
$project, $project,
@@ -310,17 +320,25 @@ final class TimesheetFixtures implements TestFixture
} }
/** /**
* @param \DateTime $start
* @param array<Tag> $tagArray * @param array<Tag> $tagArray
*/ */
private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, \DateTime $start, array $tagArray = [], bool $setEndDate = true): Timesheet private function createTimesheetEntry(
RateCalculatorMode $calculatorMode,
User $user,
Activity $activity,
Project $project,
?string $description,
\DateTime $start,
array $tagArray = [],
bool $setEndDate = true
): Timesheet
{ {
$end = clone $start; $end = clone $start;
$end = $end->modify('+ ' . (rand(1, 86400)) . ' seconds'); $end = $end->modify('+ ' . (rand(1, 86400)) . ' seconds');
$duration = $end->getTimestamp() - $start->getTimestamp(); $duration = $end->getTimestamp() - $start->getTimestamp();
$hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE); $hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE);
$rate = Util::calculateRate($hourlyRate, $duration); $rate = $calculatorMode->calculateRate($hourlyRate, $duration);
$entry = new Timesheet(); $entry = new Timesheet();
$entry->setActivity($activity); $entry->setActivity($activity);

View File

@@ -310,6 +310,7 @@ class ConfigurationTest extends TestCase
], ],
'number_format' => '{Y}/{cy,3}', 'number_format' => '{Y}/{cy,3}',
'upload_twig' => false, 'upload_twig' => false,
'rounding_mode' => 'classic',
], ],
'export' => [ 'export' => [
'documents' => [ 'documents' => [

View File

@@ -48,9 +48,7 @@ class HtmlRendererTest extends AbstractRendererTestCase
self::assertFalse($sut->isInternal()); self::assertFalse($sut->isInternal());
} }
/** #[Group('legacy')]
* @group legacy
*/
public function testLegacy(): void public function testLegacy(): void
{ {
$sut = $this->getAbstractRenderer(); $sut = $this->getAbstractRenderer();

View File

@@ -58,9 +58,7 @@ class PdfRendererTest extends AbstractRendererTestCase
self::assertFalse($sut->isInternal()); self::assertFalse($sut->isInternal());
} }
/** #[Group('legacy')]
* @group legacy
*/
public function testLegacy(): void public function testLegacy(): void
{ {
$sut = $this->getAbstractRenderer(); $sut = $this->getAbstractRenderer();

View File

@@ -0,0 +1,96 @@
<?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\Invoice\Hydrator;
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoiceModelIssuerHydrator::class)]
class InvoiceModelIssuerHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate(): void
{
$model = $this->getInvoiceModel();
$sut = new InvoiceModelIssuerHydrator();
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
self::assertEquals([
'issuer.id' => null,
'issuer.address' => "Foo\nStreet\n1111 City",
'issuer.address_line1' => '',
'issuer.address_line2' => '',
'issuer.address_line3' => '',
'issuer.buyer_reference' => '',
'issuer.city' => '',
'issuer.postcode' => '',
'issuer.name' => 'customer,with/special#name',
'issuer.contact' => '',
'issuer.company' => '',
'issuer.vat_id' => '',
'issuer.number' => '',
'issuer.country' => 'AT',
'issuer.country_name' => 'Austria',
'issuer.homepage' => '',
'issuer.comment' => '',
'issuer.email' => '',
'issuer.fax' => '',
'issuer.phone' => '',
'issuer.mobile' => '',
'issuer.invoice_text' => '',
'issuer.meta.foo-customer' => 'bar-customer',
], $result);
}
protected function assertModelStructure(array $model): void
{
$keys = [
'issuer.id',
'issuer.address',
'issuer.address_line1',
'issuer.address_line2',
'issuer.address_line3',
'issuer.buyer_reference',
'issuer.city',
'issuer.postcode',
'issuer.name',
'issuer.contact',
'issuer.company',
'issuer.vat_id',
'issuer.country',
'issuer.country_name',
'issuer.number',
'issuer.homepage',
'issuer.comment',
'issuer.email',
'issuer.fax',
'issuer.phone',
'issuer.mobile',
'issuer.meta.foo-customer',
'issuer.invoice_text',
];
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);
self::assertEquals($keys, $givenKeys);
}
}

View File

@@ -109,6 +109,29 @@ class DebugRendererTest extends TestCase
'invoice.subtotal', 'invoice.subtotal',
'invoice.subtotal_nc', 'invoice.subtotal_nc',
'invoice.subtotal_plain', 'invoice.subtotal_plain',
'issuer.address',
'issuer.address_line1',
'issuer.address_line2',
'issuer.address_line3',
'issuer.buyer_reference',
'issuer.city',
'issuer.comment',
'issuer.company',
'issuer.contact',
'issuer.country',
'issuer.country_name',
'issuer.email',
'issuer.fax',
'issuer.homepage',
'issuer.id',
'issuer.invoice_text',
'issuer.meta.foo-customer',
'issuer.mobile',
'issuer.name',
'issuer.number',
'issuer.phone',
'issuer.postcode',
'issuer.vat_id',
'template.name', 'template.name',
'template.company', 'template.company',
'template.country', 'template.country',

View File

@@ -54,9 +54,17 @@ trait RendererTestTrait
); );
} }
/**
* @param class-string $classname
*/
protected function getAbstractRenderer(string $classname): AbstractRenderer protected function getAbstractRenderer(string $classname): AbstractRenderer
{ {
return new $classname(); $t = new $classname();
if (!$t instanceof AbstractRenderer) {
throw new \InvalidArgumentException('Not an instance of AbstractRenderer: ' . \get_class($t));
}
return $t;
} }
protected function getFormatter(): InvoiceFormatter protected function getFormatter(): InvoiceFormatter

View File

@@ -10,11 +10,12 @@
namespace App\Tests\Mocks; namespace App\Tests\Mocks;
use PHPUnit\Framework\MockObject\MockBuilder; use PHPUnit\Framework\MockObject\MockBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
abstract class AbstractMockFactory abstract class AbstractMockFactory
{ {
public function __construct(private TestCase $testCase) public function __construct(private readonly TestCase $testCase)
{ {
} }
@@ -23,11 +24,19 @@ abstract class AbstractMockFactory
return $this->testCase; return $this->testCase;
} }
protected function createMock(string $className) /**
* @template T of object
* @param class-string<T> $className
* @return T&MockObject
*/
protected function createMock(string $className): object
{ {
return $this->getMockBuilder($className)->disableOriginalConstructor()->getMock(); return $this->getMockBuilder($className)->disableOriginalConstructor()->getMock(); // @phpstan-ignore return.type
} }
/**
* @param class-string $className
*/
protected function getMockBuilder(string $className): MockBuilder protected function getMockBuilder(string $className): MockBuilder
{ {
return new MockBuilder($this->testCase, $className); return new MockBuilder($this->testCase, $className);

View File

@@ -13,6 +13,7 @@ use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService; use App\Customer\CustomerStatisticService;
use App\Invoice\InvoiceModelFactory; use App\Invoice\InvoiceModelFactory;
use App\Project\ProjectStatisticService; use App\Project\ProjectStatisticService;
use App\Timesheet\RateCalculator\DecimalRateCalculator;
class InvoiceModelFactoryFactory extends AbstractMockFactory class InvoiceModelFactoryFactory extends AbstractMockFactory
{ {
@@ -24,7 +25,8 @@ class InvoiceModelFactoryFactory extends AbstractMockFactory
$projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock(); $projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock();
/** @var ActivityStatisticService $activityStatistic */ /** @var ActivityStatisticService $activityStatistic */
$activityStatistic = $this->getMockBuilder(ActivityStatisticService::class)->disableOriginalConstructor()->getMock(); $activityStatistic = $this->getMockBuilder(ActivityStatisticService::class)->disableOriginalConstructor()->getMock();
$rateMode = new DecimalRateCalculator();
return new InvoiceModelFactory($customerStatistic, $projectStatistic, $activityStatistic); return new InvoiceModelFactory($customerStatistic, $projectStatistic, $activityStatistic, $rateMode);
} }
} }

View File

@@ -0,0 +1,27 @@
<?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\Mocks;
use App\Repository\TimesheetRepository;
use App\Timesheet\RateCalculator\ClassicRateCalculator;
use App\Timesheet\RateService;
class RateServiceFactory extends AbstractMockFactory
{
public function create(array $rules = [], array $rates = []): RateService
{
$mock = $this->createMock(TimesheetRepository::class);
if (!empty($rates)) {
$mock->expects($this->getTestCase()->any())->method('findMatchingRates')->willReturn($rates);
}
return new RateService($rules, $mock, new ClassicRateCalculator());
}
}

View File

@@ -17,7 +17,6 @@ class SystemConfigurationFactory
{ {
/** /**
* @param array<mixed> $settings * @param array<mixed> $settings
* @return SystemConfiguration
*/ */
public static function create(ConfigLoaderInterface $repository, array $settings): SystemConfiguration public static function create(ConfigLoaderInterface $repository, array $settings): SystemConfiguration
{ {
@@ -26,7 +25,6 @@ class SystemConfigurationFactory
/** /**
* @param array<mixed> $settings * @param array<mixed> $settings
* @return SystemConfiguration
*/ */
public static function createStub(array $settings = []): SystemConfiguration public static function createStub(array $settings = []): SystemConfiguration
{ {

View File

@@ -18,7 +18,7 @@ use App\Entity\ProjectRate;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Repository\TimesheetRepository; use App\Tests\Mocks\RateServiceFactory;
use App\Timesheet\Calculator\RateCalculator; use App\Timesheet\Calculator\RateCalculator;
use App\Timesheet\RateService; use App\Timesheet\RateService;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
@@ -28,14 +28,11 @@ use PHPUnit\Framework\TestCase;
#[CoversClass(RateCalculator::class)] #[CoversClass(RateCalculator::class)]
class RateCalculatorTest extends TestCase class RateCalculatorTest extends TestCase
{ {
protected function getRateRepositoryMock(array $rates = []): TimesheetRepository private function getRateService(array $rules = [], array $rates = []): RateService
{ {
$mock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); $factory = new RateServiceFactory($this);
if (!empty($rates)) {
$mock->expects($this->any())->method('findMatchingRates')->willReturn($rates);
}
return $mock; return $factory->create($rules, $rates);
} }
private function assertRateByTimesheetHourlyRate(int $duration, float $hourlyRate, float $rate): void private function assertRateByTimesheetHourlyRate(int $duration, float $hourlyRate, float $rate): void
@@ -47,7 +44,7 @@ class RateCalculatorTest extends TestCase
$record->setActivity(new Activity()); $record->setActivity(new Activity());
$record->setUser($this->getTestUser()); $record->setUser($this->getTestUser());
$sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock())); $sut = new RateCalculator($this->getRateService());
$sut->calculate($record, []); $sut->calculate($record, []);
self::assertEquals($rate, $record->getRate()); self::assertEquals($rate, $record->getRate());
} }
@@ -55,9 +52,9 @@ class RateCalculatorTest extends TestCase
public function testCalculateWithTimesheetHourlyRate(): void public function testCalculateWithTimesheetHourlyRate(): void
{ {
$this->assertRateByTimesheetHourlyRate(1800, 100, 50); $this->assertRateByTimesheetHourlyRate(1800, 100, 50);
$this->assertRateByTimesheetHourlyRate(400, 100, 11); $this->assertRateByTimesheetHourlyRate(400, 100, 11.1111);
$this->assertRateByTimesheetHourlyRate(1234, 100, 34); $this->assertRateByTimesheetHourlyRate(1234, 100, 34.2778);
$this->assertRateByTimesheetHourlyRate(2739, 100, 76); $this->assertRateByTimesheetHourlyRate(2739, 100, 76.0833);
} }
public function testCalculateWithTimesheetFixedRate(): void public function testCalculateWithTimesheetFixedRate(): void
@@ -71,7 +68,7 @@ class RateCalculatorTest extends TestCase
$record->setActivity(new Activity()); $record->setActivity(new Activity());
$record->setUser($this->getTestUser()); $record->setUser($this->getTestUser());
$sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock())); $sut = new RateCalculator($this->getRateService());
$sut->calculate($record, []); $sut->calculate($record, []);
self::assertEquals(10, $record->getRate()); self::assertEquals(10, $record->getRate());
} }
@@ -112,22 +109,22 @@ class RateCalculatorTest extends TestCase
#[DataProvider('getRateTestData')] #[DataProvider('getRateTestData')]
public function testRates( public function testRates(
$expectedRate, float $expectedRate,
$expectedInternalRate, float $expectedInternalRate,
$duration, int $duration,
$userRate, float $userRate,
$userInternalRate, ?float $userInternalRate,
$timesheetHourly, ?float $timesheetHourly,
$timesheetFixed, ?float $timesheetFixed,
$activityRate, ?float $activityRate,
$activityInternal, ?float $activityInternal,
$activityIsFixed, bool $activityIsFixed,
$projectRate, ?float $projectRate,
$projectInternal, ?float $projectInternal,
$projectIsFixed, bool $projectIsFixed,
$customerRate, ?float $customerRate,
$customerInternal, ?float $customerInternal,
$customerIsFixed bool $customerIsFixed
) { ) {
$customer = new Customer('foo'); $customer = new Customer('foo');
@@ -178,7 +175,7 @@ class RateCalculatorTest extends TestCase
$rates[] = $rate; $rates[] = $rate;
} }
$sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock($rates))); $sut = new RateCalculator($this->getRateService([], $rates));
$sut->calculate($timesheet, []); $sut->calculate($timesheet, []);
self::assertEquals($expectedRate, $timesheet->getRate()); self::assertEquals($expectedRate, $timesheet->getRate());
self::assertEquals($expectedInternalRate, $timesheet->getInternalRate()); self::assertEquals($expectedInternalRate, $timesheet->getInternalRate());
@@ -207,7 +204,7 @@ class RateCalculatorTest extends TestCase
self::assertEquals(0, $record->getRate()); self::assertEquals(0, $record->getRate());
$sut = new RateCalculator(new RateService([], $this->getRateRepositoryMock())); $sut = new RateCalculator($this->getRateService());
$sut->calculate($record, []); $sut->calculate($record, []);
self::assertEquals(0, $record->getRate()); self::assertEquals(0, $record->getRate());
} }
@@ -216,7 +213,7 @@ class RateCalculatorTest extends TestCase
* Uses the hourly rate from user_preferences to calculate the rate. * Uses the hourly rate from user_preferences to calculate the rate.
*/ */
#[DataProvider('getRuleDefinitions')] #[DataProvider('getRuleDefinitions')]
public function testCalculateWithRulesByUsersHourlyRate($duration, $rules, $expectedRate): void public function testCalculateWithRulesByUsersHourlyRate(int $duration, array $rules, float $expectedRate): void
{ {
$end = new \DateTime('12:00:00', new \DateTimeZone('UTC')); $end = new \DateTime('12:00:00', new \DateTimeZone('UTC'));
$start = clone $end; $start = clone $end;
@@ -232,7 +229,7 @@ class RateCalculatorTest extends TestCase
$record->setEnd($end); $record->setEnd($end);
$sut = new RateCalculator(new RateService($rules, $this->getRateRepositoryMock())); $sut = new RateCalculator($this->getRateService($rules));
$sut->calculate($record, []); $sut->calculate($record, []);
self::assertEquals($expectedRate, $record->getRate()); self::assertEquals($expectedRate, $record->getRate());
@@ -247,7 +244,7 @@ class RateCalculatorTest extends TestCase
[ [
31837, // 31824 = 8,84 31837, // 31824 = 8,84
[], [],
663 663.2708
], ],
[ [
31837, // 31824 = 8,84 31837, // 31824 = 8,84
@@ -261,7 +258,7 @@ class RateCalculatorTest extends TestCase
'factor' => 1.5 'factor' => 1.5
], ],
], ],
1326 // 8,84 * 75 (see user) * 2 1326.5417
], ],
[ [
31837, // 31824 = 8,84 31837, // 31824 = 8,84
@@ -275,7 +272,7 @@ class RateCalculatorTest extends TestCase
'factor' => 1.5 'factor' => 1.5
], ],
], ],
2320.5 // 75 * 8,84 * 3,5 2321.4479
], ],
]; ];
} }

View File

@@ -0,0 +1,130 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Timesheet\RateCalculator;
use App\Timesheet\RateCalculator\ClassicRateCalculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(ClassicRateCalculator::class)]
class ClassicRateCalculatorTest extends TestCase
{
#[DataProvider('provideRates')]
public function testCalculateRate(float $hourlyRate, int $seconds, float $expected): void
{
$sut = new ClassicRateCalculator();
$result = $sut->calculateRate($hourlyRate, $seconds);
$this->assertEquals($expected, $result);
}
public static function provideRates(): array
{
return [
'zero duration' => [100.0, 0, 0.0],
'full hour' => [100.0, 3600, 100.0],
'half hour' => [100.0, 1800, 50.0],
'one minute with rounding' => [123.4567, 60, 2.0576],
'one second tiny amount' => [1.23, 1, 0.0003],
];
}
public function testRoundDurationKeepsOriginalSeconds(): void
{
$sut = new ClassicRateCalculator();
$this->assertSame(0, $sut->roundDuration(0));
$this->assertSame(59, $sut->roundDuration(59));
$this->assertSame(3601, $sut->roundDuration(3601));
}
#[DataProvider('getRateCalculationData')]
public function testCalculateRates(float $hourlyRate, int $duration, float $expectedRate): void
{
$sut = new ClassicRateCalculator();
self::assertEquals($expectedRate, $sut->calculateRate($hourlyRate, $duration));
}
/**
* @return array<int, array<float, int, >>|\Generator
*/
public static function getRateCalculationData()
{
yield [0, 0, 0];
yield [1, 100, 0.0278];
yield [1, 900, 0.25];
yield [1, 1800, 0.5];
yield [10000, 1, 2.7778];
yield [736, 123, 25.1467];
yield [7360, 1234, 2522.8444];
yield [7360.34, 1234, 2522.961];
yield [7360.01, 1234, 2522.8479];
yield [7360.99, 1234, 2523.1838];
}
public function testCalculateRateWithRounding(): void
{
$total = 0.00;
$seconds = 0;
$repeat = 130;
$sut = new ClassicRateCalculator();
for ($a = 0; $a < $repeat; $a++) {
$inputs = [
900,
1600,
4200,
8763,
3300,
600,
1300,
1837,
4217,
5400,
3283,
600,
];
foreach ($inputs as $i) {
$seconds += $i;
$total += $sut->calculateRate(114.75, $i);
}
}
self::assertEquals(36000 * $repeat, $seconds);
self::assertEquals(1147.50 * $repeat, $total);
}
public function testDecimalDuration(): void
{
$inputs = [
900,
1600,
4200,
8763,
3300,
600,
1300,
1837,
4217,
5400,
3283,
600,
7200,
];
$sut = new ClassicRateCalculator();
foreach ($inputs as $row) {
self::assertEquals($row, $sut->roundDuration($row));
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Timesheet\RateCalculator;
use App\Timesheet\RateCalculator\DecimalRateCalculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(DecimalRateCalculator::class)]
class DecimalRateCalculatorTest extends TestCase
{
#[DataProvider('provideRates')]
public function testCalculateRate(float $hourlyRate, int $seconds, float $expected): void
{
$sut = new DecimalRateCalculator();
$result = $sut->calculateRate($hourlyRate, $seconds);
$this->assertEquals($expected, $result);
}
public static function provideRates(): array
{
return [
'zero duration' => [100.0, 0, 0.0],
'full hour' => [100.0, 3600, 100.0],
'half hour' => [100.0, 1800, 50.0],
'one minute with rounding' => [123.4567, 60, 2.47],
'one second tiny amount' => [1.23, 1, 0],
];
}
public function testRoundDurationKeepsOriginalSeconds(): void
{
$sut = new DecimalRateCalculator();
$this->assertSame(0, $sut->roundDuration(0));
$this->assertSame(36, $sut->roundDuration(45));
$this->assertSame(72, $sut->roundDuration(59));
$this->assertSame(1224, $sut->roundDuration(1234));
$this->assertSame(3600, $sut->roundDuration(3601));
}
#[DataProvider('getRateCalculationData')]
public function testCalculateRates(float $hourlyRate, int $duration, float $expectedRate): void
{
$sut = new DecimalRateCalculator();
self::assertEquals($expectedRate, $sut->calculateRate($hourlyRate, $duration));
}
/**
* @return array<int, array<float, int, >>|\Generator
*/
public static function getRateCalculationData()
{
yield [0.00, 0, 0.00];
yield [10.00, 7260, 20.2];
yield [1.00, 3600, 1.00];
yield [1.00, 100, 0.03];
yield [1.00, 900, 0.25];
yield [1.00, 1800, 0.5];
yield [10000.00, 60, 200.00];
yield [736.00, 123, 22.08];
yield [7360.00, 1234, 2502.4];
yield [7360.34, 1234, 2502.52];
yield [7360.01, 1234, 2502.4];
yield [7360.99, 1234, 2502.74];
}
public function testCalculateRateWithRounding(): void
{
$total = 0.00;
$seconds = 0;
$repeat = 130;
$inputs = [
[900, 28.69, 0],
[1600, 50.49, 0],
[4200, 134.26, 0],
[8763, 278.84, 0],
[3300, 105.57, 0],
[600, 19.51, 0],
[1300, 41.31, 0],
[1837, 58.52, 0],
[4217, 134.26, 0],
[5400, 172.13, 0],
[3283, 104.42, 0],
[600, 19.51, 0],
];
$totalExpected = 0.00;
$sut = new DecimalRateCalculator();
for ($a = 0; $a < $repeat; $a++) {
foreach ($inputs as $row) {
[$duration, $rate] = $row;
$seconds += $duration;
$totalExpected += $rate;
$tmp = $sut->calculateRate(114.75, $duration);
self::assertEquals($rate, $tmp);
$total += $tmp;
}
}
self::assertEquals(36000 * $repeat, $seconds);
self::assertEquals($totalExpected, $total);
self::assertEqualsWithDelta(1147.51 * $repeat, $total, 0.00001);
self::assertEqualsWithDelta(149176.3, $total, 0.00001);
}
public function testDecimalDuration(): void
{
$inputs = [
[900, 900],
[1600, 1584],
[4200, 4212],
[8763, 8748],
[3300, 3312],
[600, 612],
[1300, 1296],
[1837, 1836],
[4217, 4212],
[5400, 5400],
[3283, 3276],
[600, 612],
[7200, 7200],
];
$sut = new DecimalRateCalculator();
foreach ($inputs as $row) {
self::assertEquals($row[1], $sut->roundDuration($row[0]));
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Timesheet\RateCalculator;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Timesheet\RateCalculator\ClassicRateCalculator;
use App\Timesheet\RateCalculator\DecimalRateCalculator;
use App\Timesheet\RateCalculator\RateCalculatorFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(RateCalculatorFactory::class)]
class RateCalculatorFactoryTest extends TestCase
{
private function assertCreatesClassicConfig(array $config): void
{
$config = SystemConfigurationFactory::createStub($config);
$sut = new RateCalculatorFactory($config);
$mode = $sut->getRateCalculatorMode();
self::assertInstanceOf(ClassicRateCalculator::class, $mode);
}
public function testCreatesClassic(): void
{
$this->assertCreatesClassicConfig([]);
$this->assertCreatesClassicConfig(['invoice' => ['rounding_mode' => 'classic']]);
$this->assertCreatesClassicConfig(['invoice' => ['rounding_mode' => '']]);
$this->assertCreatesClassicConfig(['invoice' => ['rounding_mode' => 'foo']]);
$this->assertCreatesClassicConfig(['invoice' => ['rounding_mode' => 'DECIMAL']]);
}
public function testCreateWithDecimalConfig(): void
{
$config = SystemConfigurationFactory::createStub(['invoice' => ['rounding_mode' => 'decimal']]);
$sut = new RateCalculatorFactory($config);
$mode = $sut->getRateCalculatorMode();
self::assertInstanceOf(DecimalRateCalculator::class, $mode);
}
}

View File

@@ -18,7 +18,7 @@ use App\Entity\ProjectRate;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Repository\TimesheetRepository; use App\Tests\Mocks\RateServiceFactory;
use App\Timesheet\RateService; use App\Timesheet\RateService;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
@@ -27,14 +27,11 @@ use PHPUnit\Framework\TestCase;
#[CoversClass(RateService::class)] #[CoversClass(RateService::class)]
class RateServiceTest extends TestCase class RateServiceTest extends TestCase
{ {
protected function getRateRepositoryMock(array $rates = []): TimesheetRepository private function getSut(array $rules = [], array $rates = []): RateService
{ {
$mock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); $factory = new RateServiceFactory($this);
if (!empty($rates)) {
$mock->expects($this->any())->method('findMatchingRates')->willReturn($rates);
}
return $mock; return $factory->create($rules, $rates);
} }
private static function createDateTime(?string $datetime = null): \DateTime private static function createDateTime(?string $datetime = null): \DateTime
@@ -51,7 +48,7 @@ class RateServiceTest extends TestCase
$record->setActivity(new Activity()); $record->setActivity(new Activity());
$record->setUser($this->getTestUser()); $record->setUser($this->getTestUser());
$sut = new RateService([], $this->getRateRepositoryMock()); $sut = $this->getSut();
$rate = $sut->calculate($record); $rate = $sut->calculate($record);
self::assertEquals(50, $rate->getRate()); self::assertEquals(50, $rate->getRate());
} }
@@ -67,7 +64,7 @@ class RateServiceTest extends TestCase
$record->setActivity(new Activity()); $record->setActivity(new Activity());
$record->setUser($this->getTestUser()); $record->setUser($this->getTestUser());
$sut = new RateService([], $this->getRateRepositoryMock()); $sut = $this->getSut();
$rate = $sut->calculate($record); $rate = $sut->calculate($record);
self::assertEquals(10, $rate->getRate()); self::assertEquals(10, $rate->getRate());
} }
@@ -108,22 +105,22 @@ class RateServiceTest extends TestCase
#[DataProvider('getRateTestData')] #[DataProvider('getRateTestData')]
public function testRates( public function testRates(
$expectedRate, float $expectedRate,
$expectedInternalRate, float $expectedInternalRate,
$duration, int $duration,
$userRate, float $userRate,
$userInternalRate, ?float $userInternalRate,
$timesheetHourly, ?float $timesheetHourly,
$timesheetFixed, ?float $timesheetFixed,
$activityRate, ?float $activityRate,
$activityInternal, ?float $activityInternal,
$activityIsFixed, bool $activityIsFixed,
$projectRate, ?float $projectRate,
$projectInternal, ?float $projectInternal,
$projectIsFixed, bool $projectIsFixed,
$customerRate, ?float $customerRate,
$customerInternal, ?float $customerInternal,
$customerIsFixed bool $customerIsFixed
): void { ): void {
$customer = new Customer('foo'); $customer = new Customer('foo');
@@ -174,7 +171,7 @@ class RateServiceTest extends TestCase
$rates[] = $rate; $rates[] = $rate;
} }
$sut = new RateService([], $this->getRateRepositoryMock($rates)); $sut = $this->getSut([], $rates);
$rate = $sut->calculate($timesheet); $rate = $sut->calculate($timesheet);
self::assertEquals($expectedRate, $rate->getRate()); self::assertEquals($expectedRate, $rate->getRate());
self::assertEquals($expectedInternalRate, $rate->getInternalRate()); self::assertEquals($expectedInternalRate, $rate->getInternalRate());
@@ -203,7 +200,7 @@ class RateServiceTest extends TestCase
self::assertEquals(0, $record->getRate()); self::assertEquals(0, $record->getRate());
$sut = new RateService([], $this->getRateRepositoryMock()); $sut = $this->getSut();
$rate = $sut->calculate($record); $rate = $sut->calculate($record);
self::assertEquals(0, $rate->getRate()); self::assertEquals(0, $rate->getRate());
} }
@@ -212,7 +209,7 @@ class RateServiceTest extends TestCase
* Uses the hourly rate from user_preferences to calculate the rate. * Uses the hourly rate from user_preferences to calculate the rate.
*/ */
#[DataProvider('getRuleDefinitions')] #[DataProvider('getRuleDefinitions')]
public function testCalculateWithRulesByUsersHourlyRate($duration, $rules, $expectedRate): void public function testCalculateWithRulesByUsersHourlyRate(int $duration, array $rules, float $expectedRate): void
{ {
$end = self::createDateTime('12:00:00'); $end = self::createDateTime('12:00:00');
$start = clone $end; $start = clone $end;
@@ -228,7 +225,7 @@ class RateServiceTest extends TestCase
$record->setEnd($end); $record->setEnd($end);
$sut = new RateService($rules, $this->getRateRepositoryMock()); $sut = $this->getSut($rules);
$rate = $sut->calculate($record); $rate = $sut->calculate($record);
self::assertEquals($expectedRate, $rate->getRate()); self::assertEquals($expectedRate, $rate->getRate());
@@ -243,7 +240,7 @@ class RateServiceTest extends TestCase
[ [
31837, 31837,
[], [],
663 663.2708
], ],
[ [
31837, 31837,
@@ -257,7 +254,7 @@ class RateServiceTest extends TestCase
'factor' => 1.5 'factor' => 1.5
], ],
], ],
1326 // 8,84 * 75 (see user) * 2 1326.5417
], ],
[ [
31837, 31837,
@@ -271,7 +268,7 @@ class RateServiceTest extends TestCase
'factor' => 1.5 'factor' => 1.5
], ],
], ],
2320.5 // 75 * 8,84 * 3,5 2321.4479
], ],
]; ];
} }

View File

@@ -12,11 +12,14 @@ namespace App\Tests\Timesheet;
use App\Timesheet\Util; use App\Timesheet\Util;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
#[Group('legacy')]
#[CoversClass(Util::class)] #[CoversClass(Util::class)]
class UtilTest extends TestCase class UtilTest extends TestCase
{ {
#[Group('legacy')]
#[DataProvider('getRateCalculationData')] #[DataProvider('getRateCalculationData')]
public function testCalculateRate(float $hourlyRate, int $duration, float $expectedRate): void public function testCalculateRate(float $hourlyRate, int $duration, float $expectedRate): void
{ {
@@ -42,6 +45,7 @@ class UtilTest extends TestCase
yield [7360.99, 1234, 2502.74]; yield [7360.99, 1234, 2502.74];
} }
#[Group('legacy')]
public function testCalculateRateWithRounding(): void public function testCalculateRateWithRounding(): void
{ {
$total = 0.00; $total = 0.00;
@@ -82,6 +86,7 @@ class UtilTest extends TestCase
self::assertEqualsWithDelta(149176.3, $total, 0.00001); self::assertEqualsWithDelta(149176.3, $total, 0.00001);
} }
#[Group('legacy')]
public function testDecimalDuration(): void public function testDecimalDuration(): void
{ {
$inputs = [ $inputs = [

View File

@@ -27,6 +27,7 @@ use App\Project\ProjectStatisticService;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Tests\Mocks\SystemConfigurationFactory; use App\Tests\Mocks\SystemConfigurationFactory;
use App\Timesheet\Rate; use App\Timesheet\Rate;
use App\Timesheet\RateCalculator\ClassicRateCalculator;
use App\Timesheet\RateService; use App\Timesheet\RateService;
use App\Timesheet\RateServiceInterface; use App\Timesheet\RateServiceInterface;
use App\Validator\Constraints\TimesheetBudgetUsed; use App\Validator\Constraints\TimesheetBudgetUsed;
@@ -92,7 +93,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
$rateService = $this->createMock(RateServiceInterface::class); $rateService = $this->createMock(RateServiceInterface::class);
$rateService->method('calculate')->willReturn($rate); $rateService->method('calculate')->willReturn($rate);
} else { } else {
$rateService = new RateService([], $timesheetRepository); $rateService = new RateService([], $timesheetRepository, new ClassicRateCalculator());
} }
$auth = $this->createMock(AuthorizationCheckerInterface::class); $auth = $this->createMock(AuthorizationCheckerInterface::class);

View File

@@ -966,11 +966,6 @@ parameters:
count: 1 count: 1
path: Controller/TimesheetTeamControllerTest.php path: Controller/TimesheetTeamControllerTest.php
-
message: "#^Parameter \\#2 \\$seconds of static method App\\\\Timesheet\\\\Util\\:\\:calculateRate\\(\\) expects int, int\\|null given\\.$#"
count: 1
path: Controller/TimesheetTeamControllerTest.php
- -
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#" message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 2 count: 2
@@ -1411,61 +1406,31 @@ parameters:
count: 1 count: 1
path: Invoice/Hydrator/InvoiceItemDefaultHydratorTest.php path: Invoice/Hydrator/InvoiceItemDefaultHydratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceItemDefaultHydratorTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Hydrator/InvoiceItemDefaultHydratorTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelActivityHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelActivityHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Invoice/Hydrator/InvoiceModelActivityHydratorTest.php path: Invoice/Hydrator/InvoiceModelActivityHydratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelActivityHydratorTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Hydrator/InvoiceModelActivityHydratorTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelCustomerHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelCustomerHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php path: Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelCustomerHydratorTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Invoice/Hydrator/InvoiceModelDefaultHydratorTest.php path: Invoice/Hydrator/InvoiceModelDefaultHydratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydratorTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Hydrator/InvoiceModelDefaultHydratorTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelProjectHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelProjectHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Invoice/Hydrator/InvoiceModelProjectHydratorTest.php path: Invoice/Hydrator/InvoiceModelProjectHydratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelProjectHydratorTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Hydrator/InvoiceModelProjectHydratorTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelUserHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelUserHydratorTest\\:\\:assertModelStructure\\(\\) has parameter \\$model with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Invoice/Hydrator/InvoiceModelUserHydratorTest.php path: Invoice/Hydrator/InvoiceModelUserHydratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Hydrator\\\\InvoiceModelUserHydratorTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Hydrator/InvoiceModelUserHydratorTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\DateNumberGeneratorTest\\:\\:getSut\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\DateNumberGeneratorTest\\:\\:getSut\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -1521,11 +1486,6 @@ parameters:
count: 1 count: 1
path: Invoice/Renderer/DebugRendererTest.php path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -1576,16 +1536,6 @@ parameters:
count: 1 count: 1
path: Invoice/Renderer/DebugRendererTest.php path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DocxRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Renderer/DocxRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\OdsRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Renderer/OdsRendererTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\OdsRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\OdsRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -1621,11 +1571,6 @@ parameters:
count: 1 count: 1
path: Invoice/Renderer/OdsRendererTest.php path: Invoice/Renderer/OdsRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\PdfRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Renderer/PdfRendererTest.php
- -
message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#"
count: 1 count: 1
@@ -1636,11 +1581,6 @@ parameters:
count: 3 count: 3
path: Invoice/Renderer/PdfRendererTest.php path: Invoice/Renderer/PdfRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\TwigRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Renderer/TwigRendererTest.php
- -
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#" message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
count: 1 count: 1
@@ -1651,11 +1591,6 @@ parameters:
count: 3 count: 3
path: Invoice/Renderer/TwigRendererTest.php path: Invoice/Renderer/TwigRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\XlsxRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#"
count: 1
path: Invoice/Renderer/XlsxRendererTest.php
- -
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\XlsxRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\XlsxRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -1731,20 +1666,11 @@ parameters:
count: 1 count: 1
path: Ldap/LdapManagerTest.php path: Ldap/LdapManagerTest.php
-
message: "#^Method App\\\\Tests\\\\Mocks\\\\AbstractMockFactory\\:\\:createMock\\(\\) has no return type specified\\.$#"
count: 1
path: Mocks/AbstractMockFactory.php
- -
message: "#^Method App\\\\Tests\\\\Mocks\\\\AbstractMockFactory\\:\\:getMockBuilder\\(\\) return type with generic class PHPUnit\\\\Framework\\\\MockObject\\\\MockBuilder does not specify its types\\: TMockedClass$#" message: "#^Method App\\\\Tests\\\\Mocks\\\\AbstractMockFactory\\:\\:getMockBuilder\\(\\) return type with generic class PHPUnit\\\\Framework\\\\MockObject\\\\MockBuilder does not specify its types\\: TMockedClass$#"
count: 1 count: 1
path: Mocks/AbstractMockFactory.php path: Mocks/AbstractMockFactory.php
-
message: "#^Parameter \\#2 \\$type of class PHPUnit\\\\Framework\\\\MockObject\\\\MockBuilder constructor expects class\\-string\\<TMockedClass\\>, string given\\.$#"
count: 1
path: Mocks/AbstractMockFactory.php
- -
message: "#^Parameter \\#1 \\$dataDir of class App\\\\Utils\\\\FileHelper constructor expects string, string\\|false given\\.$#" message: "#^Parameter \\#1 \\$dataDir of class App\\\\Utils\\\\FileHelper constructor expects string, string\\|false given\\.$#"
count: 1 count: 1
@@ -1935,116 +1861,16 @@ parameters:
count: 1 count: 1
path: Timesheet/Calculator/DurationCalculatorTest.php path: Timesheet/Calculator/DurationCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:getRateRepositoryMock\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
- -
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:getRateTestData\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:getRateTestData\\(\\) has no return type specified\\.$#"
count: 1 count: 1
path: Timesheet/Calculator/RateCalculatorTest.php path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testCalculateWithRulesByUsersHourlyRate\\(\\) has parameter \\$duration with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testCalculateWithRulesByUsersHourlyRate\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testCalculateWithRulesByUsersHourlyRate\\(\\) has parameter \\$rules with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
- -
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has no return type specified\\.$#"
count: 1 count: 1
path: Timesheet/Calculator/RateCalculatorTest.php path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$activityInternal with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$activityIsFixed with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$activityRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$customerInternal with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$customerIsFixed with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$customerRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$duration with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$expectedInternalRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$projectInternal with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$projectIsFixed with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$projectRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$timesheetFixed with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$timesheetHourly with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$userInternalRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\RateCalculatorTest\\:\\:testRates\\(\\) has parameter \\$userRate with no type specified\\.$#"
count: 1
path: Timesheet/Calculator/RateCalculatorTest.php
- -
message: "#^Method App\\\\Tests\\\\Timesheet\\\\DateTimeFactoryTest\\:\\:getEndOfWeekData\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Timesheet\\\\DateTimeFactoryTest\\:\\:getEndOfWeekData\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -2065,111 +1891,11 @@ parameters:
count: 1 count: 1
path: Timesheet/LockdownServiceTest.php path: Timesheet/LockdownServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:getRateRepositoryMock\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
- -
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:getRateTestData\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:getRateTestData\\(\\) has no return type specified\\.$#"
count: 1 count: 1
path: Timesheet/RateServiceTest.php path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testCalculateWithRulesByUsersHourlyRate\\(\\) has parameter \\$duration with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testCalculateWithRulesByUsersHourlyRate\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testCalculateWithRulesByUsersHourlyRate\\(\\) has parameter \\$rules with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$activityInternal with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$activityIsFixed with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$activityRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$customerInternal with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$customerIsFixed with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$customerRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$duration with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$expectedInternalRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$projectInternal with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$projectIsFixed with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$projectRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$timesheetFixed with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$timesheetHourly with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$userInternalRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\RateServiceTest\\:\\:testRates\\(\\) has parameter \\$userRate with no type specified\\.$#"
count: 1
path: Timesheet/RateServiceTest.php
- -
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#" message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 6 count: 6
@@ -2924,3 +2650,29 @@ parameters:
message: "#^Method App\\\\Tests\\\\Widget\\\\Type\\\\UserDurationYearTest\\:\\:getDefaultOptions\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Widget\\\\Type\\\\UserDurationYearTest\\:\\:getDefaultOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Widget/Type/UserDurationYearTest.php path: Widget/Type/UserDurationYearTest.php
-
message: '''
#^Access to constant on deprecated class App\\Timesheet\\Util\:
use RateCalculatorMode instead$#
'''
identifier: classConstant.deprecatedClass
count: 1
path: Timesheet/UtilTest.php
-
message: '''
#^Call to method calculateRate\(\) of deprecated class App\\Timesheet\\Util\:
use RateCalculatorMode instead$#
'''
identifier: staticMethod.deprecatedClass
count: 2
path: Timesheet/UtilTest.php
-
message: '''
#^Call to method decimalizeDuration\(\) of deprecated class App\\Timesheet\\Util\:
use RateCalculatorMode instead$#
'''
identifier: staticMethod.deprecatedClass
count: 1
path: Timesheet/UtilTest.php

View File

@@ -310,6 +310,10 @@
<source>copy_teams_on_create</source> <source>copy_teams_on_create</source>
<target>Übernehme Teams beim Erstellen neuer Einträge vom angemeldeten Benutzer</target> <target>Übernehme Teams beim Erstellen neuer Einträge vom angemeldeten Benutzer</target>
</trans-unit> </trans-unit>
<trans-unit id="6AaVxB2" resname="invoice.rounding_mode" approved="yes">
<source>invoice.rounding_mode</source>
<target>Rundungsmodus</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -310,6 +310,10 @@
<source>copy_teams_on_create</source> <source>copy_teams_on_create</source>
<target>Take over teams from the logged-in user when creating new entries</target> <target>Take over teams from the logged-in user when creating new entries</target>
</trans-unit> </trans-unit>
<trans-unit id="6AaVxB2" resname="invoice.rounding_mode" approved="yes">
<source>invoice.rounding_mode</source>
<target>Rounding mode</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>