Release 2.21.0 (#5014)

This commit is contained in:
Kevin Papst
2024-08-28 17:16:37 +02:00
committed by GitHub
parent 75fa7a20b8
commit b1903ba183
54 changed files with 284 additions and 430 deletions

View File

@@ -46,6 +46,7 @@ final class TagController extends BaseApiController
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
// not using a query, as these are too expensive to fetch, the simple "name" list is easier to use
$filter = $paramFetcher->get('name');
$data = $this->repository->findAllTagNames($filter);
@@ -57,7 +58,7 @@ final class TagController extends BaseApiController
}
/**
* Fetch tags by filter as entities
* Fetch tags by filter (as full entities)
*/
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.20.0';
public const VERSION = '2.21.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22000;
public const VERSION_ID = 22100;
/**
* The software name
*/

View File

@@ -41,12 +41,14 @@ final class PermissionController extends AbstractController
{
public const TOKEN_NAME = 'user_role_permissions';
public function __construct(private RolePermissionManager $manager, private RoleRepository $roleRepository)
public function __construct(
private readonly RolePermissionManager $manager,
private readonly RoleRepository $roleRepository
)
{
}
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService, UserRepository $userRepository): Response
{
$all = $this->roleRepository->findAll();
@@ -167,7 +169,6 @@ final class PermissionController extends AbstractController
}
#[Route(path: '/roles/create', name: 'admin_user_roles', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function createRole(Request $request): Response
{
$role = new Role();
@@ -200,8 +201,7 @@ final class PermissionController extends AbstractController
]);
}
#[Route(path: '/roles/{id}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
#[Route(path: '/roles/{role}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
@@ -230,8 +230,7 @@ final class PermissionController extends AbstractController
return $this->redirectToRoute('admin_user_permissions');
}
#[Route(path: '/roles/{id}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
#[IsGranted('role_permissions')]
#[Route(path: '/roles/{role}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, PermissionService $permissionService, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Date extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Day extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Month extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Year extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -10,8 +10,6 @@
namespace App\Entity;
use App\Utils\Color;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -53,21 +51,8 @@ class Tag
use ColorTrait;
/**
* This is ONLY here, so we can count the amount of timesheets.
*
* See TagRepository "SIZE(t.timesheets)"
* Removing this makes the count more complicated.
* Should be refactored at some point in the future.
*
* @var Collection<Timesheet>
*/
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]
private Collection $timesheets;
public function __construct()
{
$this->timesheets = new ArrayCollection();
}
public function getId(): ?int

View File

@@ -200,7 +200,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[ORM\JoinTable(name: 'kimai2_timesheet_tags')]
#[ORM\JoinColumn(name: 'timesheet_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'tag_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'timesheets', cascade: ['persist'])]
#[ORM\ManyToMany(targetEntity: Tag::class, cascade: ['persist'])]
#[Assert\Valid]
private Collection $tags;
/**

View File

@@ -16,14 +16,17 @@ use App\Entity\User;
* - once per side load for page actions
* - once for every entity item (table row)
*
* @property array{'actions': array, 'view': string} $payload
* @property array{actions: array<string, string|array<mixed>>, view: string} $payload
*/
class PageActionsEvent extends ThemeEvent
{
private int $divider = 0;
private ?string $locale = null;
public function __construct(User $user, array $payload, private string $action, private string $view)
/**
* @param array<mixed> $payload
*/
public function __construct(User $user, array $payload, private readonly string $action, private readonly string $view)
{
// only for BC reasons, do not access it directly!
if (!\array_key_exists('actions', $payload)) {
@@ -58,8 +61,6 @@ class PageActionsEvent extends ThemeEvent
/**
* Custom view can only be table listings.
*
* @return bool
*/
public function isCustomView(): bool
{
@@ -71,6 +72,9 @@ class PageActionsEvent extends ThemeEvent
return $this->view;
}
/**
* @return array<string, array<mixed>>
*/
public function getActions(): array
{
$actions = $this->payload['actions'];
@@ -99,28 +103,38 @@ class PageActionsEvent extends ThemeEvent
public function hasSubmenu(string $submenu): bool
{
if (!$this->hasAction($submenu)) {
if (!\array_key_exists($submenu, $this->payload['actions'])) {
return false;
}
return \array_key_exists('children', $this->payload['actions'][$submenu]);
}
/**
* @param array<string, mixed> $action
*/
public function addActionToSubmenu(string $submenu, string $key, array $action): void
{
if ($this->hasAction($submenu)) {
if (!\array_key_exists('children', $this->payload['actions'][$submenu])) {
$this->payload['actions'][$submenu]['children'] = [];
}
if (!\array_key_exists($submenu, $this->payload['actions'])) {
$this->payload['actions'][$submenu] = ['children' => []];
}
if (!\array_key_exists('children', $this->payload['actions'][$submenu])) {
$this->payload['actions'][$submenu]['children'] = [];
}
$this->payload['actions'][$submenu]['children'][$key] = $action;
}
/**
* @param array<string, mixed> $action
*/
public function replaceAction(string $key, array $action): void
{
$this->payload['actions'][$key] = $action;
}
/**
* @param array<string, mixed> $action
*/
public function addAction(string $key, array $action): void
{
if (!$this->hasAction($key)) {

View File

@@ -28,7 +28,7 @@ class ThemeEvent extends Event
/**
* @param array<string, mixed|array<mixed>> $payload
*/
public function __construct(private ?User $user = null, protected array $payload = [])
public function __construct(private readonly ?User $user = null, protected array $payload = [])
{
}

View File

@@ -14,42 +14,54 @@ use App\Validator\Constraints\Duration as DurationConstraint;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* @implements DataTransformerInterface<string|int|null, string|null>
*/
final class DurationStringToSecondsTransformer implements DataTransformerInterface
{
/**
* @param int $intToFormat
* @return string|null
*/
public function transform(mixed $intToFormat): ?string
public function transform(mixed $value): ?string
{
if ($value === null) {
return null;
}
try {
return (new Duration())->format($intToFormat);
if (!\is_int($value) && is_numeric($value)) {
$value = (int) $value;
}
if (!\is_int($value)) {
// do not throw an exception, that would break the frontend, make it null / empty instead
return null;
}
return (new Duration())->format($value);
} catch (\Exception | \TypeError $e) {
throw new TransformationFailedException($e->getMessage());
}
}
/**
* @param string|null $formatToInt
* @return int|null
*/
public function reverseTransform(mixed $formatToInt): ?int
public function reverseTransform(mixed $value): ?int
{
if (null === $formatToInt) {
if ($value === null) {
return null;
}
if (empty($formatToInt)) {
if ($value === '') {
return 0;
}
if (\is_int($value) || \is_float($value)) {
$value = (string) $value;
}
// we need this one here, because the data transformer is executed BEFORE the constraint is called
if (!preg_match((new DurationConstraint())->pattern, $formatToInt)) {
if (!preg_match((new DurationConstraint())->pattern, $value)) {
throw new TransformationFailedException('Invalid duration format given');
}
try {
$seconds = (new Duration())->parseDurationString($formatToInt);
$seconds = (new Duration())->parseDurationString($value);
// DateTime throws if a duration with too many seconds is passed and an amount of so
// many seconds is likely not required in a time-tracking application ;-)

View File

@@ -143,8 +143,8 @@ class ProjectStatisticService
)
)
)
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
->setParameter('end', $end, Types::DATETIME_MUTABLE)
->setParameter('begin', DateTimeImmutable::createFromInterface($begin), Types::DATETIME_IMMUTABLE)
->setParameter('end', DateTimeImmutable::createFromInterface($end), Types::DATETIME_IMMUTABLE)
;
if (!$query->isIncludeNoWork()) {
@@ -323,14 +323,14 @@ class ProjectStatisticService
if ($begin !== null) {
$qb
->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
->setParameter('begin', DateTimeImmutable::createFromInterface($begin), Types::DATETIME_IMMUTABLE)
;
}
if ($end !== null) {
$qb
->andWhere($qb->expr()->lte('t.begin', ':end'))
->setParameter('end', $end, Types::DATETIME_MUTABLE)
->setParameter('end', DateTimeImmutable::createFromInterface($end), Types::DATETIME_IMMUTABLE)
;
}
@@ -646,7 +646,7 @@ class ProjectStatisticService
)
)
->addGroupBy('p')
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
->setParameter('project_end', DateTimeImmutable::createFromInterface($today), Types::DATETIME_IMMUTABLE)
;
if ($query->getCustomer() !== null) {
@@ -732,7 +732,7 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) = :start_date')
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
->setParameter('start_date', DateTimeImmutable::createFromInterface($today), Types::DATETIME_IMMUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -744,8 +744,8 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
->setParameter('start_date', DateTimeImmutable::createFromInterface($startOfWeek), Types::DATETIME_IMMUTABLE)
->setParameter('end_date', DateTimeImmutable::createFromInterface($endOfWeek), Types::DATETIME_IMMUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -757,8 +757,8 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
->setParameter('start_date', DateTimeImmutable::createFromInterface($startMonth), Types::DATETIME_IMMUTABLE)
->setParameter('end_date', DateTimeImmutable::createFromInterface($endMonth), Types::DATETIME_IMMUTABLE)
;
$result = $qb->getQuery()->getScalarResult();

View File

@@ -91,6 +91,9 @@ class ActivityRepository extends EntityRepository
return $this->count([]);
}
/**
* @param array<Team> $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
@@ -99,6 +102,9 @@ class ActivityRepository extends EntityRepository
}
}
/**
* @param array<Team> $teams
*/
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): Andx
{
$andX = $qb->expr()->andX();

View File

@@ -14,35 +14,45 @@ use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends \Doctrine\ORM\EntityRepository<Bookmark>
* @extends EntityRepository<Bookmark>
*/
class BookmarkRepository extends EntityRepository
{
/** @var array<string, array<string, array<string, Bookmark>>> */
private array $userCache = [];
public function saveBookmark(Bookmark $bookmark)
public function saveBookmark(Bookmark $bookmark): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($bookmark);
$entityManager->flush();
$this->clearCache($bookmark->getUser());
if ($bookmark->getUser()) {
$this->clearCache($bookmark->getUser());
}
}
private function clearCache(User $user): void
private function clearCache(?User $user): void
{
if ($user === null || $user->getId() === null) {
return;
}
$key = 'user_' . $user->getId();
if (\array_key_exists($key, $this->userCache)) {
unset($this->userCache[$key]);
}
}
public function deleteBookmark(Bookmark $bookmark)
public function deleteBookmark(Bookmark $bookmark): void
{
$em = $this->getEntityManager();
$em->remove($bookmark);
$em->flush();
$this->clearCache($bookmark->getUser());
if ($bookmark->getUser()) {
$this->clearCache($bookmark->getUser());
}
}
public function getSearchDefaultOptions(User $user, string $name): ?Bookmark
@@ -59,7 +69,7 @@ class BookmarkRepository extends EntityRepository
$this->userCache[$key] = [];
$all = $this->findBy(['user' => $user->getId()]);
foreach ($all as $item) {
$this->userCache[$key][$item->getType()][mb_substr($item->getName(), 0, 50)] = $item;
$this->userCache[$key][$item->getType()][mb_substr($item->getName() ?? '__DEFAULT__', 0, 50)] = $item;
}
}

View File

@@ -79,6 +79,9 @@ class CustomerRepository extends EntityRepository
return $this->count([]);
}
/**
* @param array<Team> $teams
*/
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -87,6 +90,9 @@ class CustomerRepository extends EntityRepository
}
}
/**
* @param array<Team> $teams
*/
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
@@ -349,6 +355,9 @@ class CustomerRepository extends EntityRepository
}
}
/**
* @return array<CustomerComment>
*/
public function getComments(Customer $customer): array
{
$qb = $this->getEntityManager()->createQueryBuilder();

View File

@@ -83,13 +83,14 @@ class InvoiceRepository extends EntityRepository
;
}
/** @var array{'counter': int|numeric-string}|null $result */
$result = $qb->getQuery()->getOneOrNullResult();
if ($result === null) {
return 0;
}
return $result['counter'];
return (int) $result['counter'];
}
public function getCounterForDay(\DateTimeInterface $date, ?Customer $customer = null, ?User $user = null): int
@@ -137,7 +138,10 @@ class InvoiceRepository extends EntityRepository
return $this->count([]);
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
/**
* @param array<Team> $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {

View File

@@ -71,7 +71,7 @@ final class TimesheetLoader implements LoaderInterface
if ($this->fullyHydrated) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
return $project->getCustomer()?->getId();
}, $projects)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();

View File

@@ -13,6 +13,7 @@ use Pagerfanta\Adapter\AdapterInterface;
/**
* @template-covariant T
* @extends AdapterInterface<T>
*/
interface PaginatorInterface extends AdapterInterface
{

View File

@@ -85,6 +85,9 @@ class ProjectRepository extends EntityRepository
return $this->count([]);
}
/**
* @param array<Team> $teams
*/
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -93,6 +96,9 @@ class ProjectRepository extends EntityRepository
}
}
/**
* @param array<Team> $teams
*/
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
@@ -429,6 +435,9 @@ class ProjectRepository extends EntityRepository
}
}
/**
* @return array<ProjectComment>
*/
public function getComments(Project $project): array
{
$qb = $this->getEntityManager()->createQueryBuilder();

View File

@@ -40,12 +40,12 @@ trait RepositorySearchTrait
private function addSearchTerm(QueryBuilder $qb, BaseQuery $query): void
{
if (!$query->hasSearchTerm()) {
$searchTerm = $query->getSearchTerm();
if ($searchTerm === null) {
return;
}
$searchTerm = $query->getSearchTerm();
if (!$this->supportsMetaFields() && !$searchTerm->hasSearchTerm()) {
return;
}

View File

@@ -67,6 +67,9 @@ final class TimesheetResult
return $this->statisticCache;
}
/**
* @return iterable<Timesheet>
*/
public function toIterable(): iterable
{
return $this->query->toIterable();

View File

@@ -10,6 +10,7 @@
namespace App\Repository;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\Query\TagQuery;
@@ -131,7 +132,11 @@ class TagRepository extends EntityRepository
{
$qb = $this->createQueryBuilder('tag');
$qb->select('tag.id, tag.name, tag.color, tag.visible, SIZE(tag.timesheets) as amount');
$qb1 = $this->getEntityManager()->createQueryBuilder();
$qb1->from(Timesheet::class, 't')->select('COUNT(tags)')->innerJoin('t.tags', 'tags')->where('tags.id = tag.id');
$qb->select('tag.id, tag.name, tag.color, tag.visible');
$qb->addSelect('(' . $qb1->getDQL() . ') as amount');
$orderBy = $query->getOrderBy();
$orderBy = match ($orderBy) {
@@ -149,8 +154,8 @@ class TagRepository extends EntityRepository
$qb->addOrderBy($orderBy, $query->getOrder());
if ($query->hasSearchTerm()) {
$searchTerm = $query->getSearchTerm();
$searchTerm = $query->getSearchTerm();
if ($searchTerm !== null) {
$searchAnd = $qb->expr()->andX();
if ($searchTerm->hasSearchTerm()) {

View File

@@ -117,14 +117,14 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
*/
public function findOneBy(array $criteria, array $orderBy = null): ?object
{
if (\count($criteria) === 1 && isset($criteria['username'])) {
if (\count($criteria) === 1 && isset($criteria['username']) && \is_string($criteria['username'])) {
return $this->loadUserByIdentifier($criteria['username']);
}
return parent::findOneBy($criteria, $orderBy);
}
public function findByUsername($username): ?User
public function findByUsername(string $username): ?User
{
return parent::findOneBy(['username' => $username]);
}
@@ -321,9 +321,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
$qb->setParameter('system', $query->getSystemAccount(), Types::BOOLEAN);
}
if ($query->hasSearchTerm()) {
$searchTerm = $query->getSearchTerm();
if ($searchTerm !== null) {
$searchAnd = $qb->expr()->andX();
$searchTerm = $query->getSearchTerm();
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
$qb->leftJoin('u.preferences', 'meta');

View File

@@ -17,13 +17,13 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class RoundingService
{
/**
* @var array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
* @var null|non-empty-array<array-key, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private ?array $rulesCache = null;
/**
* @param RoundingInterface[] $roundingModes
* @param array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
* @param array<string, array{'days': string, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
*/
public function __construct(
private readonly SystemConfiguration $configuration,
@@ -35,13 +35,13 @@ final class RoundingService
}
/**
* @return array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
* @return non-empty-array<array-key, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private function getRoundingRules(): array
{
if ($this->rulesCache === null) {
$rules = $this->rules;
$rules['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays();
$rules['default']['days'] = $this->parseDays($this->configuration->getTimesheetDefaultRoundingDays());
$rules['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin();
$rules['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd();
$rules['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration();
@@ -49,17 +49,13 @@ final class RoundingService
// see AppExtension, conversion from string to array due to system configuration not allowing to store arrays
foreach ($rules as $key => $settings) {
if (\is_array($settings['days'])) {
continue;
if (\is_string($settings['days'])) {
if ($settings['days'] === '') {
$rules[$key]['days'] = [];
continue;
}
$rules[$key]['days'] = array_map('strtolower', array_map('trim', explode(',', $settings['days'])));
}
if ($settings['days'] === '') {
$rules[$key]['days'] = [];
continue;
}
$days = explode(',', $settings['days']);
$days = array_map('trim', $days);
$days = array_map('strtolower', $days);
$rules[$key]['days'] = $days;
}
$this->rulesCache = $rules; // @phpstan-ignore-line
}
@@ -67,6 +63,14 @@ final class RoundingService
return $this->rulesCache; // @phpstan-ignore-line
}
/**
* @return array<string>
*/
private function parseDays(string $days): array
{
return array_map('strtolower', array_map('trim', explode(',', $days)));
}
public function roundBegin(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {