Release 2.41 (#5653)

This commit is contained in:
Kevin Papst
2025-11-08 23:03:44 +01:00
committed by GitHub
parent 636a51e721
commit 1a38c7d7a3
57 changed files with 705 additions and 409 deletions

View File

@@ -161,7 +161,7 @@ final class ActivityController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveActivity($activity);
$this->activityService->saveActivity($activity);
$view = new View($activity, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -203,7 +203,7 @@ final class ActivityController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->repository->saveActivity($activity);
$this->activityService->saveActivity($activity);
$view = new View($activity, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -253,7 +253,7 @@ final class ActivityController extends BaseApiController
$meta->setValue($value);
$this->repository->saveActivity($activity);
$this->activityService->saveActivity($activity);
$view = new View($activity, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -12,13 +12,12 @@ namespace App\API;
use App\Entity\User;
use App\Repository\Query\BaseQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\Pagination;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
abstract class BaseApiController extends AbstractController
{
@@ -60,13 +59,19 @@ abstract class BaseApiController extends AbstractController
return DateTimeFactory::createByUser($user);
}
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): void
/**
* @template T of BaseQuery
* @param T $query
* @param ParamFetcherInterface $paramFetcher
* @return T
*/
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): BaseQuery
{
$query->setIsApiCall(true);
$query->setCurrentUser($this->getUser());
// there is no function has() in ParamFetcherInterface, so we need to use all() and check for the key
$all = $paramFetcher->all(true);
$all = $paramFetcher->all();
if (\array_key_exists('page', $all)) {
$page = $all['page'];
@@ -78,33 +83,28 @@ abstract class BaseApiController extends AbstractController
if (\array_key_exists('size', $all)) {
$size = $all['size'];
if (is_numeric($size)) {
$query->setPageSize((int) $size);
$size = (int) $size;
if ($size < 1 || $size > 500) {
throw new BadRequestHttpException('Size must be between 1 and 500');
}
$query->setPageSize($size);
}
}
if (\array_key_exists('pageSize', $all)) {
$size = $all['pageSize'];
if (is_numeric($size)) {
$query->setPageSize((int) $size);
if (\array_key_exists('order', $all)) {
$order = $all['order'];
if (\is_string($order) && $order !== '') {
$query->setOrder($order);
}
}
}
protected function createPaginatedView(Pagination $pagination): View
{
$results = (array) $pagination->getCurrentPageResults();
if (\array_key_exists('orderBy', $all)) {
$orderBy = $all['orderBy'];
if (\is_string($orderBy) && $orderBy !== '') {
$query->setOrderBy($orderBy);
}
}
$view = new View($results, 200);
$this->addPagination($view, $pagination);
return $view;
}
protected function addPagination(View $view, Pagination $pagination): void
{
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());
$view->setHeader('X-Total-Count', (string) $pagination->getNbResults());
$view->setHeader('X-Total-Pages', (string) $pagination->getNbPages());
$view->setHeader('X-Per-Page', (string) $pagination->getMaxPerPage());
return $query;
}
}

View File

@@ -136,7 +136,7 @@ final class CustomerController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveCustomer($customer);
$this->customerService->saveCustomer($customer);
$view = new View($customer, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -178,7 +178,7 @@ final class CustomerController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->repository->saveCustomer($customer);
$this->customerService->saveCustomer($customer);
$view = new View($customer, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -228,7 +228,7 @@ final class CustomerController extends BaseApiController
$meta->setValue($value);
$this->repository->saveCustomer($customer);
$this->customerService->saveCustomer($customer);
$view = new View($customer, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -80,7 +80,7 @@ final class InvoiceController extends BaseApiController
}
$data = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($data);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -82,7 +82,7 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'activity', requirements: '\d+', strict: true, nullable: true, description: 'Activity ID to filter timesheets')]
#[Rest\QueryParam(name: 'activities', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of activity IDs to filter, e.g.: activities[]=1&activities[]=2')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50, max: 500)')]
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
@@ -97,7 +97,7 @@ final class TimesheetController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{
$query = new TimesheetQuery(false);
$query->setCurrentUser($this->getUser());
$this->prepareQuery($query, $paramFetcher);
$seeAll = false;
if ($this->isGranted('view_other_timesheet')) {
@@ -169,16 +169,6 @@ final class TimesheetController extends BaseApiController
$query->addActivity($activity);
}
$page = $paramFetcher->get('page');
if (\is_string($page) && $page !== '') {
$query->setPage((int) $page);
}
$size = $paramFetcher->get('size');
if (\is_string($size) && $size !== '') {
$query->setPageSize((int) $size);
}
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
@@ -191,16 +181,6 @@ final class TimesheetController extends BaseApiController
}
}
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
$query->setOrder($order);
}
$orderBy = $paramFetcher->get('orderBy');
if (\is_string($orderBy) && $orderBy !== '') {
$query->setOrderBy($orderBy);
}
$factory = $this->getDateTimeFactory();
$begin = $paramFetcher->get('begin');
@@ -252,12 +232,9 @@ final class TimesheetController extends BaseApiController
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
}
$query->setIsApiCall(true);
$data = $this->repository->getPagerfantaForQuery($query);
$results = (array) $data->getCurrentPageResults();
$view = new View($results, 200);
$this->addPagination($view, $data);
$view = new View($data, 200);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {

78
src/API/ViewHandler.php Normal file
View File

@@ -0,0 +1,78 @@
<?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\API;
use App\Utils\Pagination;
use FOS\RestBundle\View\ConfigurableViewHandlerInterface;
use FOS\RestBundle\View\View;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ViewHandler implements ConfigurableViewHandlerInterface
{
public function __construct(private readonly ConfigurableViewHandlerInterface $baseViewHandler)
{
}
/**
* @param string[]|string $groups
*/
public function setExclusionStrategyGroups($groups): void
{
$this->baseViewHandler->setExclusionStrategyGroups($groups);
}
public function setExclusionStrategyVersion(string $version): void
{
$this->baseViewHandler->setExclusionStrategyVersion($version);
}
public function setSerializeNullStrategy(bool $isEnabled): void
{
$this->baseViewHandler->setSerializeNullStrategy($isEnabled);
}
public function supports(string $format): bool
{
return $this->baseViewHandler->supports($format);
}
public function registerHandler(string $format, callable $callable): void
{
$this->baseViewHandler->registerHandler($format, $callable);
}
public function handle(View $view, ?Request $request = null): Response
{
$data = $view->getData();
if ($data instanceof Pagination) {
$results = (array) $data->getCurrentPageResults();
$view->setData($results);
$view->setHeader('X-Page', (string) $data->getCurrentPage());
$view->setHeader('X-Total-Count', (string) $data->getNbResults());
$view->setHeader('X-Total-Pages', (string) $data->getNbPages());
$view->setHeader('X-Per-Page', (string) $data->getMaxPerPage());
}
return $this->baseViewHandler->handle($view, $request);
}
public function createRedirectResponse(View $view, string $location, string $format): Response
{
return $this->baseViewHandler->createRedirectResponse($view, $location, $format);
}
public function createResponse(View $view, Request $request, string $format): Response
{
return $this->baseViewHandler->createResponse($view, $request, $format);
}
}

View File

@@ -277,7 +277,7 @@ final class InvoiceCreateCommand extends Command
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(\sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
$io->warning('Could not find invoice template for project, skipping.');
continue;
}
$query->setTemplate($tpl);
@@ -295,7 +295,7 @@ final class InvoiceCreateCommand extends Command
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
$io->error(\sprintf('Failed to create invoice for project with: %s', $ex->getMessage()));
}
}
@@ -352,7 +352,7 @@ final class InvoiceCreateCommand extends Command
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(\sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName()));
$io->warning('Could not find invoice template for customer, skipping.');
continue;
}
$query->setTemplate($tpl);
@@ -370,7 +370,7 @@ final class InvoiceCreateCommand extends Command
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
$io->error(\sprintf('Failed to create invoice for customer with: %s', $ex->getMessage()));
}
}
@@ -416,7 +416,7 @@ final class InvoiceCreateCommand extends Command
$file = $this->serviceInvoice->getInvoiceFile($invoice);
if (null === $file) {
$io->warning(
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename())
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId() ?? 'unknown', $invoice->getInvoiceFilename() ?? 'unknown')
);
continue;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.40.0';
public const VERSION = '2.41.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24000;
public const VERSION_ID = 24100;
/**
* The software name
*/

View File

@@ -35,6 +35,7 @@ use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
@@ -146,7 +147,7 @@ final class CustomerController extends AbstractController
{
$customer = $customerService->createNewCustomer('');
return $this->renderCustomerForm($customer, $request, true);
return $this->renderCustomerForm($customer, $request, true, $customerService);
}
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
@@ -281,7 +282,7 @@ final class CustomerController extends AbstractController
$query->setPage($page);
$query->setPageSize(5);
$query->addCustomer($customer);
$query->setShowBoth();
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
$query->addOrderGroup('visible', ProjectQuery::ORDER_DESC);
$query->addOrderGroup('name', ProjectQuery::ORDER_ASC);
@@ -421,9 +422,9 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'customer')]
public function editAction(Customer $customer, Request $request): Response
public function editAction(Customer $customer, Request $request, CustomerService $customerService): Response
{
return $this->renderCustomerForm($customer, $request);
return $this->renderCustomerForm($customer, $request, false, $customerService);
}
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
@@ -496,7 +497,7 @@ final class CustomerController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
private function renderCustomerForm(Customer $customer, Request $request, bool $create = false): Response
private function renderCustomerForm(Customer $customer, Request $request, bool $create, CustomerService $customerService): Response
{
$editForm = $this->createEditForm($customer);
@@ -504,7 +505,7 @@ final class CustomerController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->repository->saveCustomer($customer);
$customerService->saveCustomer($customer);
$this->flashSuccess('action.update.success');
if ($create) {

View File

@@ -321,7 +321,7 @@ final class InvoiceController extends AbstractController
if (null === $file) {
throw $this->createNotFoundException(
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
\sprintf('Invoice file could not be found for invoice ID "%s"', $invoice->getId())
);
}

View File

@@ -38,6 +38,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\Utils\Context;
use App\Utils\DataTable;
@@ -313,7 +314,7 @@ final class ProjectController extends AbstractController
$query->setPageSize(5);
$query->addProject($project);
$query->setExcludeGlobals(true);
$query->setShowBoth();
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
$query->addOrderGroup('visible', ActivityQuery::ORDER_DESC);
$query->addOrderGroup('name', ActivityQuery::ORDER_ASC);

View File

@@ -77,6 +77,7 @@ final class QuickEntryController extends AbstractController
$endWeek = $factory->getEndOfWeek($begin);
$tmpDay = clone $startWeek;
/** @var array<string, array{day: \DateTime}> $week */
$week = [];
while ($tmpDay < $endWeek) {
$nextDay = clone $tmpDay;
@@ -125,7 +126,7 @@ final class QuickEntryController extends AbstractController
if ($amount > 0) {
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
if (is_numeric($takeOverWeeks) && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', (string) $takeOverWeeks));
}

View File

@@ -22,6 +22,7 @@ use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -94,6 +95,10 @@ final class TeamController extends AbstractController
{
$newTeam = clone $team;
if ($team->getName() === null) {
throw new BadRequestHttpException('Team with empty name cannot be duplicated');
}
$i = 1;
do {
$newName = \sprintf('%s (%s)', $team->getName(), $i++);

View File

@@ -153,18 +153,18 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$qb = $manager->getRepository($class)->createQueryBuilder('entity');
/** @var array<int, T> $all */
$all = $qb->where($qb->expr()->in('entity.id', $ids))->setMaxResults($amount)->getQuery()->getResult();
/** @var array<int, T> $result */
$result = $qb->where($qb->expr()->in('entity.id', $ids))->setMaxResults($amount)->getQuery()->getResult();
if (\count($all) === 0) {
throw new \Exception('Need users to setup teams');
if (\count($result) === 0) {
throw new \Exception('Could not find any entity: ' . $class);
}
return $all;
return $result;
}
/**
* @return array<int|string, Tag>
* @return non-empty-array<int|string, Tag>
*/
private function getAllTags(ObjectManager $manager): array
{

View File

@@ -236,6 +236,8 @@ final class ColumnConverter
$columns[$column] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
} elseif ($column === 'project.order_number') {
$columns[$column] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
} elseif ($column === 'id') {
$columns[$column] = (new Column('id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getId());
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
$columns[$column] = $timesheetMeta[$column];
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {

View File

@@ -49,6 +49,7 @@ final class DurationType extends AbstractType
$class .= ' ' . $view->vars['attr']['class'];
}
$view->vars['attr']['class'] = $class;
$view->vars['attr']['autocomplete'] = 'off';
$view->vars['toggle'] = $options['toggle'];
if ($options['preset_hours'] !== null && $options['preset_minutes'] !== null) {

View File

@@ -49,6 +49,7 @@ final class ExportColumnsType extends AbstractType
{
$columns = [
'timesheet' => [
'id' => 'id',
'date' => 'date',
'begin' => 'begin',
'end' => 'end',

View File

@@ -12,15 +12,48 @@ namespace App\Invoice\Calculator;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow;
use App\Timesheet\Util;
abstract class AbstractCalculator
{
protected InvoiceModel $model;
/**
* @var InvoiceItem[]
*/
private array $cached = [];
/**
* TODO make this method abstract in 3.0
*
* @return InvoiceItem[]
*/
abstract public function getEntries(): array;
protected function calculateEntries(): array
{
return [];
}
/**
* TODO make this method final in 3.0
*
* @return InvoiceItem[]
*/
public function getEntries(): array
{
if (\count($this->cached) === 0) {
foreach ($this->calculateEntries() as $entry) {
if (!$entry->isFixedRate() && $entry->getHourlyRate() !== null && $entry->getHourlyRate() > 0) {
$entry->setDuration(Util::decimalizeDuration($entry->getDuration()));
// when merging many entries, we might run into rounding issues
// so we have to recalculate the hourly rate here
$entry->setRate(Util::calculateRate($entry->getHourlyRate(), $entry->getDuration()));
}
$this->cached[] = $entry;
}
}
return $this->cached;
}
/**
* @param array<InvoiceItem> $items
@@ -45,11 +78,12 @@ abstract class AbstractCalculator
public function getSubtotal(): float
{
$amount = 0.00;
foreach ($this->model->getEntries() as $entry) {
// using the entries and not the raw data, so we make sure to use the same base for everything
foreach ($this->getEntries() as $entry) {
$amount += $entry->getRate();
}
return round($amount, 2);
return round($amount, 2, PHP_ROUND_HALF_UP);
}
/**
@@ -80,12 +114,12 @@ abstract class AbstractCalculator
$tax += $row->getAmount();
}
return round($tax, 2);
return round($tax, 2, PHP_ROUND_HALF_UP);
}
public function getTotal(): float
{
return $this->getSubtotal() + $this->getTax();
return round($this->getSubtotal() + $this->getTax(), 2, PHP_ROUND_HALF_UP);
}
/**
@@ -94,7 +128,8 @@ abstract class AbstractCalculator
public function getTimeWorked(): int
{
$time = 0;
foreach ($this->model->getEntries() as $entry) {
// using the entries and not the raw data, so we make sure to use the same base for everything
foreach ($this->getEntries() as $entry) {
if (null !== $entry->getDuration()) {
$time += $entry->getDuration();
}

View File

@@ -43,7 +43,7 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
$invoiceItem->setAmount($invoiceItem->getAmount() + $amount);
$invoiceItem->setUser($entry->getUser());
$invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate());
$invoiceItem->setInternalRate($invoiceItem->getInternalRate() + ($entry->getInternalRate() ?? 0.00));
$invoiceItem->setInternalRate($invoiceItem->getInternalRate() + ($entry->getInternalRate() ?? 0.00)); // @phpstan-ignore method.deprecated,method.deprecated
$invoiceItem->setDuration($duration);
if (null !== $entry->getFixedRate()) {

View File

@@ -47,16 +47,16 @@ abstract class AbstractSumInvoiceCalculator extends AbstractMergedCalculator imp
$prefix = $this->calculateSumIdentifier($entry);
if (null !== $entry->getFixedRate()) {
return $prefix . '_fixed_' . (string) $entry->getFixedRate();
return $prefix . '_fixed_' . $entry->getFixedRate();
}
return $prefix . '_hourly_' . (string) $entry->getHourlyRate();
return $prefix . '_hourly_' . ($entry->getHourlyRate() ?? '__NULL__');
}
/**
* @return InvoiceItem[]
*/
public function getEntries(): array
protected function calculateEntries(): array
{
$entries = $this->model->getEntries();
if (empty($entries)) {

View File

@@ -23,7 +23,7 @@ final class DefaultCalculator extends AbstractMergedCalculator implements Calcul
/**
* @return InvoiceItem[]
*/
public function getEntries(): array
protected function calculateEntries(): array
{
$entries = [];

View File

@@ -23,7 +23,7 @@ final class PriceInvoiceCalculator extends AbstractSumInvoiceCalculator implemen
return ['fixed_' . $invoiceItem->getFixedRate()];
}
return ['hourly_' . $invoiceItem->getHourlyRate()];
return ['hourly_' . ($invoiceItem->getHourlyRate() ?? '__NULL__')];
}
public function getId(): string

View File

@@ -21,7 +21,7 @@ final class ShortInvoiceCalculator extends AbstractMergedCalculator implements C
/**
* @return InvoiceItem[]
*/
public function getEntries(): array
protected function calculateEntries(): array
{
$entries = $this->model->getEntries();
if (empty($entries)) {

View File

@@ -29,7 +29,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
$formatter = $this->model->getFormatter();
$rate = $item->getRate();
$internalRate = $item->getInternalRate();
$internalRate = $item->getInternalRate(); // @phpstan-ignore method.deprecated
$appliedRate = $item->getHourlyRate();
$amount = $formatter->getFormattedDecimalDuration($item->getDuration());
$description = $item->getDescription();
@@ -63,9 +63,9 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.rate' => $formatter->getFormattedMoney($appliedRate, $currency),
'entry.rate_nc' => $formatter->getFormattedMoney($appliedRate, $currency, false),
'entry.rate_plain' => $appliedRate,
'entry.rate_internal' => $formatter->getFormattedMoney($internalRate, $currency),
'entry.rate_internal_nc' => $formatter->getFormattedMoney($internalRate, $currency, false),
'entry.rate_internal_plain' => $internalRate,
'entry.rate_internal' => $formatter->getFormattedMoney($internalRate, $currency), // @deprecated since 2.41
'entry.rate_internal_nc' => $formatter->getFormattedMoney($internalRate, $currency, false), // @deprecated since 2.41
'entry.rate_internal_plain' => $internalRate, // @deprecated since 2.41
'entry.rate_fixed' => ($item->isFixedRate() ? $item->getFixedRate() : null),
'entry.total' => $formatter->getFormattedMoney($rate, $currency),
'entry.total_nc' => $formatter->getFormattedMoney($rate, $currency, false),

View File

@@ -19,6 +19,9 @@ final class InvoiceItem
private ?float $fixedRate = null;
private ?float $hourlyRate = null;
private float $rate = 0.00;
/**
* @deprecated since 2.41 - internal rate is not needed in invoices
*/
private float $rateInternal = 0.00;
private float $amount = 0.00;
private ?string $description = null;
@@ -85,9 +88,14 @@ final class InvoiceItem
return $this;
}
public function getAppliedRate(): float
{
return $this->fixedRate ?? $this->hourlyRate ?? 0.00;
}
public function isFixedRate(): bool
{
return null !== $this->getFixedRate();
return $this->fixedRate !== null;
}
public function getFixedRate(): ?float
@@ -126,11 +134,17 @@ final class InvoiceItem
return $this;
}
/**
* @deprecated since 2.41 - internal rate is not needed in invoices
*/
public function getInternalRate(): float
{
return $this->rateInternal;
}
/**
* @deprecated since 2.41 - internal rate is not needed in invoices
*/
public function setInternalRate(float $rateInternal): InvoiceItem
{
$this->rateInternal = $rateInternal;

View File

@@ -9,6 +9,7 @@
namespace App\Project;
use App\Activity\ActivityService;
use App\Entity\Project;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
@@ -21,7 +22,8 @@ final class ProjectDuplicationService
private readonly ProjectService $projectService,
private readonly ActivityRepository $activityRepository,
private readonly ProjectRateRepository $projectRateRepository,
private readonly ActivityRateRepository $activityRateRepository
private readonly ActivityRateRepository $activityRateRepository,
private readonly ActivityService $activityService
) {
}
@@ -68,7 +70,7 @@ final class ProjectDuplicationService
$newActivity->setMetaField($newMetaField);
}
$this->activityRepository->saveActivity($newActivity);
$this->activityService->saveActivity($newActivity);
foreach ($this->activityRateRepository->getRatesForActivity($activity) as $rate) {
$newRate = clone $rate;

View File

@@ -9,7 +9,7 @@
namespace App\Repository\Query;
class TagQuery extends BaseQuery
class TagQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;

View File

@@ -20,10 +20,9 @@ trait VisibilityTrait
public function setVisibility(int $visibility): void
{
if (!\in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
throw new \InvalidArgumentException('Unknown visibility given');
if (\in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
$this->visibility = $visibility;
}
$this->visibility = $visibility;
}
public function isShowHidden(): bool
@@ -36,6 +35,9 @@ trait VisibilityTrait
return $this->visibility === VisibilityInterface::SHOW_VISIBLE;
}
/**
* @deprecated since 2.41
*/
public function setShowBoth(): void
{
$this->setVisibility(VisibilityInterface::SHOW_BOTH);

View File

@@ -43,8 +43,10 @@ final class SearchHelper
$rootAlias = $aliases[0];
$searchAnd = $qb->expr()->andX();
$metaFieldClass = $this->configuration->getMetaFieldClass();
$metaFieldName = $this->configuration->getMetaFieldName();
if ($this->supportsMetaFields()) {
if ($metaFieldClass !== null && $metaFieldName !== null && $this->supportsMetaFields()) {
$metaFieldRef = $rootAlias . '.' . $this->configuration->getEntityFieldName();
$i = 0;
$c = 0;
@@ -69,7 +71,7 @@ final class SearchHelper
$and->add($qb->expr()->isNotNull($field));
} elseif ($metaValue === '~') {
$and->add(
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $this->configuration->getMetaFieldClass(), $subqueryName, $subqueryName, $this->configuration->getMetaFieldName(), $rootAlias, $subqueryName, $paramName)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $metaFieldClass, $subqueryName, $subqueryName, $metaFieldName, $rootAlias, $subqueryName, $paramName)
);
} elseif ($metaValue === '') {
$and->add(
@@ -78,7 +80,7 @@ final class SearchHelper
$qb->expr()->eq($alias . '.name', ':' . $paramName),
$qb->expr()->isNull($field)
),
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $this->configuration->getMetaFieldClass(), $subqueryName, $subqueryName, $this->configuration->getMetaFieldName(), $rootAlias, $subqueryName, $paramName)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $metaFieldClass, $subqueryName, $subqueryName, $metaFieldName, $rootAlias, $subqueryName, $paramName)
)
);
} else {

View File

@@ -54,7 +54,7 @@ final class SamlProvider
} catch (\Exception $ex) {
$this->logger->error($ex->getMessage());
throw new AuthenticationException(
\sprintf('Failed creating or hydrating user "%s": %s', $token->getUserIdentifier(), $ex->getMessage())
\sprintf('Failed creating or hydrating user "%s": %s', $token->getUserIdentifier() ?? '*unknown*', $ex->getMessage())
);
}

View File

@@ -23,8 +23,18 @@ final class Util
*/
public static function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = $hourlyRate * ($seconds / 3600);
$rate = $hourlyRate * round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return round($rate, 4);
return round($rate, 2, PHP_ROUND_HALF_UP);
}
/**
* Makes sure tha the duration is full compatible with decimal format, stripping away overflowing seconds.
*/
public static function decimalizeDuration(int $seconds): int
{
$decimal = round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return (int) round(($decimal * 3600), 0, PHP_ROUND_HALF_UP);
}
}

View File

@@ -22,12 +22,15 @@ class Parsedown extends \Parsedown
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
if (\is_string($text) && $text !== '') {
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
}
return $block;
}
@@ -67,7 +70,7 @@ class Parsedown extends \Parsedown
return $text;
}
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);

View File

@@ -45,7 +45,7 @@ final class WorkingTimeModeFactory
return $this->getMode($user->getWorkContractMode());
} catch (\InvalidArgumentException $ex) {
$this->logger->error(
\sprintf('Unknown mode "%s" requested for user %s', $user->getWorkContractMode(), $user->getId())
\sprintf('Unknown mode "%s" requested for user %s', $user->getWorkContractMode(), $user->getUserIdentifier())
);
return new WorkingTimeModeNone(); // @CloudRequired