Configurable activity and project number (#4729)

* added configurable activity number
* added configurable project number
* fix deprecations
* added some tests for entity exporter
* better configuration of dropdown pattern for customer, project and activity
This commit is contained in:
Kevin Papst
2024-04-04 17:43:22 +02:00
committed by GitHub
parent c1f5d3def4
commit a636683dee
55 changed files with 920 additions and 136 deletions

View File

@@ -0,0 +1,36 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.14
*/
final class Version20240326125247 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the number columns to activity and project';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_activities ADD number VARCHAR(10) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_projects ADD number VARCHAR(10) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_projects DROP number');
$this->addSql('ALTER TABLE kimai2_activities DROP number');
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Activity;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\Project;
use App\Event\ActivityCreateEvent;
@@ -18,6 +19,7 @@ use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\ActivityUpdatePreEvent;
use App\Repository\ActivityRepository;
use App\Utils\NumberGenerator;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -28,13 +30,19 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
*/
class ActivityService
{
public function __construct(private ActivityRepository $repository, private EventDispatcherInterface $dispatcher, private ValidatorInterface $validator)
public function __construct(
private readonly ActivityRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ValidatorInterface $validator
)
{
}
public function createNewActivity(?Project $project = null): Activity
{
$activity = new Activity();
$activity->setNumber($this->calculateNextActivityNumber());
if ($project !== null) {
$activity->setProject($project);
@@ -90,4 +98,41 @@ class ActivityService
{
return $this->repository->findOneBy(['project' => $project?->getId(), 'name' => $name]);
}
public function findActivityByNumber(string $number): ?Activity
{
return $this->repository->findOneBy(['number' => $number]);
}
private function calculateNextActivityNumber(): ?string
{
$format = $this->configuration->find('activity.number_format');
if (empty($format) || !\is_string($format)) {
return null;
}
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countActivity();
$i = 0;
do {
$start++;
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
return match ($format) {
'ac' => $start + $increaseBy,
default => $originalFormat,
};
});
$number = $numberGenerator->getNumber();
$activity = $this->findActivityByNumber($number);
} while ($activity !== null && $i++ < 100);
if ($activity !== null) {
return null;
}
return $number;
}
}

View File

@@ -81,6 +81,7 @@ final class ActivityController extends AbstractController
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('project', ['class' => 'd-none']);
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
$table->addColumn('number', ['class' => 'd-none w-min', 'title' => 'activity_number']);
foreach ($metaColumns as $metaColumn) {
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);

View File

@@ -88,6 +88,7 @@ final class ProjectController extends AbstractController
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('customer', ['class' => 'd-none']);
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
$table->addColumn('number', ['class' => 'd-none w-min', 'title' => 'project_number']);
$table->addColumn('orderNumber', ['class' => 'd-none']);
$table->addColumn('orderDate', ['class' => 'd-none']);
$table->addColumn('project_start', ['class' => 'd-none']);

View File

@@ -469,7 +469,7 @@ final class SystemConfigurationController extends AbstractController
->setType(TextType::class)
->setTranslationDomain('system-configuration'),
(new Configuration('customer.rules.allow_duplicate_number'))
->setLabel('customer.allow_duplicate_number')
->setLabel('allow_duplicate_number')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
@@ -482,12 +482,32 @@ final class SystemConfigurationController extends AbstractController
->setLabel('copy_teams_on_create')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
(new Configuration('project.number_format'))
->setLabel('project.number_format')
->setOptions(['help' => 'allowed_replacer', 'help_translation_parameters' => ['%replacer%' => '{pc}']])
->setRequired(false)
->setType(TextType::class)
->setTranslationDomain('system-configuration'),
(new Configuration('project.allow_duplicate_number'))
->setLabel('allow_duplicate_number')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel('activity'))
->setConfiguration([
(new Configuration('activity.choice_pattern'))
->setLabel('choice_pattern')
->setType(ActivityTypePatternType::class),
(new Configuration('activity.number_format'))
->setLabel('activity.number_format')
->setOptions(['help' => 'allowed_replacer', 'help_translation_parameters' => ['%replacer%' => '{ac}']])
->setRequired(false)
->setType(TextType::class)
->setTranslationDomain('system-configuration'),
(new Configuration('activity.allow_duplicate_number'))
->setLabel('allow_duplicate_number')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
// TODO see DependencyInjection/Configuration::getActivityNode()
/*
(new Configuration('activity.allow_inline_create'))

View File

@@ -120,33 +120,35 @@ final class CustomerService
return $this->repository->countCustomer($visible);
}
public function calculateNextCustomerNumber(): string
{
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countCustomer();
do {
$number = $this->getNextNumber($start++);
$customer = $this->findCustomerByNumber($number);
} while ($customer !== null);
return $number;
}
private function getNextNumber(int $counter): string
private function calculateNextCustomerNumber(): ?string
{
$format = $this->configuration->find('customer.number_format');
if (empty($format) || !\is_string($format)) {
$format = '{cc,4}';
return null;
}
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($counter): string|int {
return match ($format) {
'cc' => $counter + $increaseBy,
default => $originalFormat,
};
});
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countCustomer();
$i = 0;
return $numberGenerator->getNumber();
do {
$start++;
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
return match ($format) {
'cc' => $start + $increaseBy,
default => $originalFormat,
};
});
$number = $numberGenerator->getNumber();
$customer = $this->findCustomerByNumber($number);
} while ($customer !== null && $i++ < 100);
if ($customer !== null) {
return null;
}
return $number;
}
}

View File

@@ -11,6 +11,9 @@ namespace App\DependencyInjection;
use App\Entity\Customer;
use App\Entity\User;
use App\Form\Helper\ActivityHelper;
use App\Form\Helper\CustomerHelper;
use App\Form\Helper\ProjectHelper;
use App\Repository\InvoiceDocumentRepository;
use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
@@ -114,9 +117,18 @@ final class Configuration implements ConfigurationInterface
$node
->addDefaultsIfNotSet()
->children()
->scalarNode('choice_pattern')
->defaultValue(ProjectHelper::PATTERN_NAME)
->end()
->booleanNode('copy_teams_on_create')
->defaultValue(false)
->end()
->scalarNode('number_format')
->defaultValue('{pc,4}')
->end()
->booleanNode('allow_duplicate_number')
->defaultFalse()
->end()
->end()
;
@@ -132,9 +144,18 @@ final class Configuration implements ConfigurationInterface
$node
->addDefaultsIfNotSet()
->children()
->scalarNode('choice_pattern')
->defaultValue(ActivityHelper::PATTERN_NAME)
->end()
->booleanNode('allow_inline_create')
->defaultValue(false)
->end()
->scalarNode('number_format')
->defaultValue('{ac,4}')
->end()
->booleanNode('allow_duplicate_number')
->defaultFalse()
->end()
->end()
;
@@ -551,6 +572,9 @@ final class Configuration implements ConfigurationInterface
$node
->addDefaultsIfNotSet()
->children()
->scalarNode('choice_pattern')
->defaultValue(CustomerHelper::PATTERN_NAME)
->end()
->scalarNode('number_format')
->defaultValue('{cc,4}')
->end()

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -26,8 +27,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ProjectName', exp: 'object.getProject() === null ? null : object.getProject().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Activity'])])]
#[Serializer\VirtualProperty('ProjectAsId', exp: 'object.getProject() === null ? null : object.getProject().getId()', options: [new Serializer\SerializedName('project'), new Serializer\Type(name: 'integer'), new Serializer\Groups(['Activity', 'Team', 'Not_Expanded'])])]
#[Exporter\Order(['id', 'name', 'project', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Exporter\Order(['id', 'name', 'project', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'project', label: 'project', exp: 'object.getProject() === null ? null : object.getProject().getName()')]
#[Constraints\Activity]
class Activity implements EntityWithMetaFields, EntityWithBudget
{
use BudgetTrait;
@@ -109,6 +111,12 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
private Collection $teams;
#[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)]
private ?string $invoiceText = null;
#[ORM\Column(name: 'number', type: 'string', length: 10, nullable: true)]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'activity_number')]
private ?string $number = null;
public function __construct()
{
@@ -269,6 +277,16 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$this->invoiceText = $invoiceText;
}
public function setNumber(?string $number): void
{
$this->number = $number;
}
public function getNumber(): ?string
{
return $this->number;
}
public function __toString(): string
{
return $this->getName();
@@ -276,7 +294,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
public function __clone()
{
if ($this->id) {
if ($this->id !== null) {
$this->id = null;
}

View File

@@ -475,7 +475,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
public function __clone()
{
if ($this->id) {
if ($this->id !== null) {
$this->id = null;
}

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]
#[Serializer\VirtualProperty('CustomerAsId', exp: 'object.getCustomer() === null ? null : object.getCustomer().getId()', options: [new Serializer\SerializedName('customer'), new Serializer\Type(name: 'integer'), new Serializer\Groups(['Project', 'Team', 'Not_Expanded'])])]
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable'])]
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
#[Constraints\Project]
class Project implements EntityWithMetaFields, EntityWithBudget
@@ -167,6 +167,12 @@ class Project implements EntityWithMetaFields, EntityWithBudget
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private bool $globalActivities = true;
#[ORM\Column(name: 'number', type: 'string', length: 10, nullable: true)]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'project_number')]
private ?string $number = null;
public function __construct()
{
@@ -447,6 +453,16 @@ class Project implements EntityWithMetaFields, EntityWithBudget
$this->invoiceText = $invoiceText;
}
public function setNumber(?string $number): void
{
$this->number = $number;
}
public function getNumber(): ?string
{
return $this->number;
}
public function __toString(): string
{
return $this->getName();
@@ -454,7 +470,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
public function __clone()
{
if ($this->id) {
if ($this->id !== null) {
$this->id = null;
}

View File

@@ -55,6 +55,13 @@ class ActivityEditForm extends AbstractType
'autofocus' => 'autofocus'
],
])
->add('number', TextType::class, [
'label' => 'activity_number',
'required' => false,
'attr' => [
'maxlength' => 10,
],
])
->add('comment', TextareaType::class, [
'label' => 'description',
'required' => false,

View File

@@ -40,7 +40,7 @@ final class StringToArrayTransformer implements DataTransformerInterface
}
/**
* Transforms a string to an array of tags.
* Transforms a string to an array of strings.
*
* @param string|null $value
* @return array<string>

View File

@@ -14,6 +14,7 @@ use App\Entity\Activity;
final class ActivityHelper
{
public const PATTERN_NUMBER = '{number}';
public const PATTERN_NAME = '{name}';
public const PATTERN_COMMENT = '{comment}';
public const PATTERN_SPACER = '{spacer}';
@@ -21,7 +22,7 @@ final class ActivityHelper
private ?string $pattern = null;
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}
@@ -44,6 +45,7 @@ final class ActivityHelper
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $activity->getName(), $name);
$name = str_replace(self::PATTERN_NUMBER, $activity->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $activity->getComment() ?? '', $name);
while (str_starts_with($name, self::SPACER)) {

View File

@@ -23,7 +23,7 @@ final class CustomerHelper
private ?string $pattern = null;
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}

View File

@@ -17,6 +17,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class ProjectHelper
{
public const PATTERN_NAME = '{name}';
public const PATTERN_NUMBER = '{number}';
public const PATTERN_COMMENT = '{comment}';
public const PATTERN_ORDERNUMBER = '{ordernumber}';
public const PATTERN_DATERANGE = '{daterange}';
@@ -70,6 +71,7 @@ final class ProjectHelper
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $project->getName(), $name);
$name = str_replace(self::PATTERN_NUMBER, $project->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name);
$name = str_replace(self::PATTERN_ORDERNUMBER, $project->getOrderNumber() ?? '', $name);

View File

@@ -65,6 +65,13 @@ class ProjectEditForm extends AbstractType
'autofocus' => 'autofocus'
],
])
->add('number', TextType::class, [
'label' => 'project_number',
'required' => false,
'attr' => [
'maxlength' => 10,
],
])
->add('comment', TextareaType::class, [
'label' => 'description',
'required' => false,

View File

@@ -9,9 +9,12 @@
namespace App\Form\Type;
use App\Form\DataTransformer\StringToArrayTransformer;
use App\Form\Helper\ActivityHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\ReversedTransformer;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -20,22 +23,28 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
final class ActivityTypePatternType extends AbstractType
{
public function __construct(private TranslatorInterface $translator)
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new ReversedTransformer(new StringToArrayTransformer(ActivityHelper::PATTERN_SPACER)), true);
}
public function configureOptions(OptionsResolver $resolver): void
{
$name = $this->translator->trans('name');
$number = $this->translator->trans('activity_number');
$comment = $this->translator->trans('description');
$spacer = ActivityHelper::SPACER;
$resolver->setDefaults([
'label' => 'choice_pattern',
'multiple' => true,
'choices' => [
$number => ActivityHelper::PATTERN_NUMBER,
$name => ActivityHelper::PATTERN_NAME,
$comment => ActivityHelper::PATTERN_COMMENT,
$name . $spacer . $comment => ActivityHelper::PATTERN_NAME . ActivityHelper::PATTERN_SPACER . ActivityHelper::PATTERN_COMMENT,
]
]);
}

View File

@@ -9,9 +9,12 @@
namespace App\Form\Type;
use App\Form\DataTransformer\StringToArrayTransformer;
use App\Form\Helper\CustomerHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\ReversedTransformer;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -20,32 +23,30 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
final class CustomerTypePatternType extends AbstractType
{
public function __construct(private TranslatorInterface $translator)
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new ReversedTransformer(new StringToArrayTransformer(CustomerHelper::PATTERN_SPACER)), true);
}
public function configureOptions(OptionsResolver $resolver): void
{
$name = $this->translator->trans('name');
$company = $this->translator->trans('company');
$number = $this->translator->trans('number');
$comment = $this->translator->trans('description');
$spacer = CustomerHelper::SPACER;
$resolver->setDefaults([
'label' => 'choice_pattern',
'multiple' => true,
'choices' => [
$number => CustomerHelper::PATTERN_NUMBER,
$name => CustomerHelper::PATTERN_NAME,
$company => CustomerHelper::PATTERN_COMPANY,
$number => CustomerHelper::PATTERN_NUMBER,
$comment => CustomerHelper::PATTERN_COMMENT,
$name . $spacer . $company => CustomerHelper::PATTERN_NAME . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_COMPANY,
$name . $spacer . $number => CustomerHelper::PATTERN_NAME . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_NUMBER,
$name . $spacer . $comment => CustomerHelper::PATTERN_NAME . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_COMMENT,
$number . $spacer . $name => CustomerHelper::PATTERN_NUMBER . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_NAME,
$number . $spacer . $company => CustomerHelper::PATTERN_NUMBER . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_COMPANY,
$number . $spacer . $comment => CustomerHelper::PATTERN_NUMBER . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_COMMENT,
$company . $spacer . $comment => CustomerHelper::PATTERN_COMPANY . CustomerHelper::PATTERN_SPACER . CustomerHelper::PATTERN_COMMENT,
]
]);
}

View File

@@ -9,9 +9,12 @@
namespace App\Form\Type;
use App\Form\DataTransformer\StringToArrayTransformer;
use App\Form\Helper\ProjectHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\ReversedTransformer;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -20,10 +23,15 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
final class ProjectTypePatternType extends AbstractType
{
public function __construct(private TranslatorInterface $translator)
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new ReversedTransformer(new StringToArrayTransformer(ProjectHelper::PATTERN_SPACER)), true);
}
public function configureOptions(OptionsResolver $resolver): void
{
$name = $this->translator->trans('name');
@@ -32,18 +40,18 @@ final class ProjectTypePatternType extends AbstractType
$projectStart = $this->translator->trans('project_start');
$projectEnd = $this->translator->trans('project_end');
$customer = $this->translator->trans('customer');
$spacer = ProjectHelper::SPACER;
$number = $this->translator->trans('project_number');
$resolver->setDefaults([
'label' => 'choice_pattern',
'multiple' => true,
'choices' => [
$number => ProjectHelper::PATTERN_NUMBER,
$orderNumber => ProjectHelper::PATTERN_ORDERNUMBER,
$name => ProjectHelper::PATTERN_NAME,
$comment => ProjectHelper::PATTERN_COMMENT,
$name . $spacer . $customer => ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER . ProjectHelper::PATTERN_CUSTOMER,
$name . $spacer . $orderNumber => ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER . ProjectHelper::PATTERN_ORDERNUMBER,
$name . $spacer . $comment => ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER . ProjectHelper::PATTERN_COMMENT,
$name . $spacer . $projectStart . '-' . $projectEnd => ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER . ProjectHelper::PATTERN_DATERANGE,
$customer => ProjectHelper::PATTERN_CUSTOMER,
$projectStart . '-' . $projectEnd => ProjectHelper::PATTERN_DATERANGE,
]
]);
}

View File

@@ -20,6 +20,7 @@ use App\Event\ProjectUpdatePostEvent;
use App\Event\ProjectUpdatePreEvent;
use App\Repository\ProjectRepository;
use App\Utils\Context;
use App\Utils\NumberGenerator;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -30,13 +31,19 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
*/
final class ProjectService
{
public function __construct(private SystemConfiguration $configuration, private ProjectRepository $repository, private EventDispatcherInterface $dispatcher, private ValidatorInterface $validator)
public function __construct(
private readonly ProjectRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ValidatorInterface $validator
)
{
}
public function createNewProject(?Customer $customer = null): Project
{
$project = new Project();
$project->setNumber($this->calculateNextProjectNumber());
if ($customer !== null) {
$project->setCustomer($customer);
@@ -99,4 +106,41 @@ final class ProjectService
{
return $this->repository->findOneBy(['name' => $name]);
}
public function findProjectByNumber(string $number): ?Project
{
return $this->repository->findOneBy(['number' => $number]);
}
private function calculateNextProjectNumber(): ?string
{
$format = $this->configuration->find('project.number_format');
if (empty($format) || !\is_string($format)) {
return null;
}
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countProject();
$i = 0;
do {
$start++;
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
return match ($format) {
'pc' => $start + $increaseBy,
default => $originalFormat,
};
});
$number = $numberGenerator->getNumber();
$project = $this->findProjectByNumber($number);
} while ($project !== null && $i++ < 100);
if ($project !== null) {
return null;
}
return $number;
}
}

View File

@@ -353,7 +353,7 @@ class ActivityRepository extends EntityRepository
*/
private function getSearchableFields(): array
{
return ['a.name', 'a.comment'];
return ['a.name', 'a.comment', 'a.number'];
}
public function countActivitiesForQuery(ActivityQuery $query): int

View File

@@ -290,7 +290,7 @@ class ProjectRepository extends EntityRepository
*/
private function getSearchableFields(): array
{
return ['p.name', 'p.comment', 'p.orderNumber'];
return ['p.name', 'p.comment', 'p.orderNumber', 'p.number'];
}
private function addProjectStartAndEndDate(QueryBuilder $qb, ?DateTime $begin, ?DateTime $end): Andx

View File

@@ -0,0 +1,29 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class Activity extends Constraint
{
public const ACTIVITY_NUMBER_EXISTING = 'kimai-activity-00';
protected const ERROR_NAMES = [
self::ACTIVITY_NUMBER_EXISTING => 'The number %number% is already used.',
];
public string $message = 'This activity has invalid settings.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,55 @@
<?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\Validator\Constraints;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity as ActivityEntity;
use App\Repository\ActivityRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class ActivityValidator extends ConstraintValidator
{
public function __construct(
private readonly SystemConfiguration $systemConfiguration,
private readonly ActivityRepository $activityRepository
)
{
}
/**
* @param ActivityEntity|mixed $value
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof Activity)) {
throw new UnexpectedTypeException($constraint, Activity::class);
}
if (!($value instanceof ActivityEntity)) {
throw new UnexpectedTypeException($value, ActivityEntity::class);
}
if ((bool) $this->systemConfiguration->find('activity.allow_duplicate_number') === false && (($number = $value->getNumber()) !== null)) {
foreach ($this->activityRepository->findBy(['number' => $number]) as $tmp) {
if ($tmp->getId() !== $value->getId()) {
$this->context->buildViolation(Activity::getErrorName(Activity::ACTIVITY_NUMBER_EXISTING))
->setParameter('%number%', $number)
->atPath('number')
->setTranslationDomain('validators')
->setCode(Activity::ACTIVITY_NUMBER_EXISTING)
->addViolation();
break;
}
}
}
}
}

View File

@@ -17,7 +17,7 @@ final class Customer extends Constraint
public const CUSTOMER_NUMBER_EXISTING = 'kimai-customer-00';
protected const ERROR_NAMES = [
self::CUSTOMER_NUMBER_EXISTING => 'The account number %number% is already used.',
self::CUSTOMER_NUMBER_EXISTING => 'The number %number% is already used.',
];
public string $message = 'This customer has invalid settings.';

View File

@@ -18,13 +18,15 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class CustomerValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $systemConfiguration, private CustomerRepository $customerRepository)
public function __construct(
private readonly SystemConfiguration $systemConfiguration,
private readonly CustomerRepository $customerRepository
)
{
}
/**
* @param CustomerEntity|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
@@ -37,14 +39,16 @@ final class CustomerValidator extends ConstraintValidator
}
if ((bool) $this->systemConfiguration->find('customer.rules.allow_duplicate_number') === false && (($number = $value->getNumber()) !== null)) {
$tmp = $this->customerRepository->findOneBy(['number' => $number]);
if ($tmp !== null && $tmp->getId() !== $value->getId()) {
$this->context->buildViolation(Customer::getErrorName(Customer::CUSTOMER_NUMBER_EXISTING))
->setParameter('%number%', $number)
->atPath('number')
->setTranslationDomain('validators')
->setCode(Customer::CUSTOMER_NUMBER_EXISTING)
->addViolation();
foreach ($this->customerRepository->findBy(['number' => $number]) as $tmp) {
if ($tmp->getId() !== $value->getId()) {
$this->context->buildViolation(Customer::getErrorName(Customer::CUSTOMER_NUMBER_EXISTING))
->setParameter('%number%', $number)
->atPath('number')
->setTranslationDomain('validators')
->setCode(Customer::CUSTOMER_NUMBER_EXISTING)
->addViolation();
break;
}
}
}
}

View File

@@ -15,9 +15,11 @@ use Symfony\Component\Validator\Constraint;
final class Project extends Constraint
{
public const END_BEFORE_BEGIN_ERROR = 'kimai-project-00';
public const PROJECT_NUMBER_EXISTING = 'kimai-project-01';
protected const ERROR_NAMES = [
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
self::PROJECT_NUMBER_EXISTING => 'The number %number% is already used.',
];
public string $message = 'This project has invalid settings.';

View File

@@ -9,12 +9,13 @@
namespace App\Validator\Constraints;
use App\Entity\Project;
use App\Configuration\SystemConfiguration;
use App\Entity\Project as ProjectEntity;
use App\Repository\ProjectRepository;
use App\Validator\Constraints\Project as ProjectEntityConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class ProjectValidator extends ConstraintValidator
@@ -23,6 +24,8 @@ final class ProjectValidator extends ConstraintValidator
* @param ProjectConstraint[] $constraints
*/
public function __construct(
private readonly SystemConfiguration $systemConfiguration,
private readonly ProjectRepository $projectRepository,
#[TaggedIterator(ProjectConstraint::class)]
private iterable $constraints = []
)
@@ -31,7 +34,6 @@ final class ProjectValidator extends ConstraintValidator
/**
* @param Project|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
@@ -39,11 +41,31 @@ final class ProjectValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, ProjectEntityConstraint::class);
}
if (!\is_object($value) || !($value instanceof Project)) {
if (!\is_object($value) || !($value instanceof ProjectEntity)) {
return;
}
$this->validateProject($value, $this->context);
if (null !== $value->getStart() && null !== $value->getEnd() && $value->getStart()->getTimestamp() > $value->getEnd()->getTimestamp()) {
$this->context->buildViolation(ProjectEntityConstraint::getErrorName(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR))
->atPath('end')
->setTranslationDomain('validators')
->setCode(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
if ((bool) $this->systemConfiguration->find('project.allow_duplicate_number') === false && (($number = $value->getNumber()) !== null)) {
foreach ($this->projectRepository->findBy(['number' => $number]) as $tmp) {
if ($tmp->getId() !== $value->getId()) {
$this->context->buildViolation(Project::getErrorName(Project::PROJECT_NUMBER_EXISTING))
->setParameter('%number%', $number)
->atPath('number')
->setTranslationDomain('validators')
->setCode(Project::PROJECT_NUMBER_EXISTING)
->addViolation();
break;
}
}
}
foreach ($this->constraints as $innerConstraint) {
$this->context
@@ -52,15 +74,4 @@ final class ProjectValidator extends ConstraintValidator
->validate($value, $innerConstraint, [Constraint::DEFAULT_GROUP]);
}
}
protected function validateProject(Project $project, ExecutionContextInterface $context): void
{
if (null !== $project->getStart() && null !== $project->getEnd() && $project->getStart()->getTimestamp() > $project->getEnd()->getTimestamp()) {
$context->buildViolation(ProjectEntityConstraint::getErrorName(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR))
->atPath('end')
->setTranslationDomain('validators')
->setCode(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}
}

View File

@@ -71,6 +71,14 @@
</td>
</tr>
{% endif %}
{% if activity.number is not empty %}
<tr>
<th>{{ 'activity_number'|trans }}</th>
<td colspan="3">
{{ activity.number }}
</td>
</tr>
{% endif %}
{% for metaField in activity.visibleMetaFields|filter(field => field.defined)|sort((a, b) => a.order <=> b.order) %}
<tr>
<th>{{ metaField.label|trans }}</th>

View File

@@ -24,6 +24,7 @@
{% if form.project is defined %}
{{ form_row(form.project) }}
{% endif %}
{{ form_row(form.number) }}
{% if form.budgetType is defined %}
<div class="row">
{% if form.budget is defined %}

View File

@@ -20,6 +20,8 @@
{% endif %}
{% elseif column == 'comment' %}
{{ entry.comment|comment1line }}
{% elseif column == 'number' %}
{{ entry.number }}
{% elseif column == 'budget' %}
{% if entry.hasBudget() and is_granted('budget', entry) %}
{{ entry.budget|money((entry.project is null ? defaultCurrency : entry.project.customer.currency)) }}

View File

@@ -85,6 +85,14 @@
</td>
</tr>
{% endif %}
{% if project.number is not empty %}
<tr>
<th>{{ 'project_number'|trans }}</th>
<td colspan="3">
{{ project.number }}
</td>
</tr>
{% endif %}
{% endif %}
{% if project.hasBudget() and is_granted('budget', project) %}
<tr>

View File

@@ -18,7 +18,14 @@
</div>
</div>
{{ form_row(form.comment) }}
{{ form_row(form.customer) }}
<div class="row">
<div class="col-md-6">
{{ form_row(form.customer) }}
</div>
<div class="col-md-6">
{{ form_row(form.number) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.orderNumber) }}

View File

@@ -13,6 +13,8 @@
{{ entry.comment|comment1line }}
{% elseif column == 'orderNumber' %}
{{ entry.orderNumber }}
{% elseif column == 'number' %}
{{ entry.number }}
{% elseif column == 'orderDate' %}
{% if entry.orderDate is not null %}{{ entry.orderDate|date_short }}{% endif %}
{% elseif column == 'project_start' %}

View File

@@ -472,6 +472,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'billable' => 'bool',
'color' => '@string',
'customer' => 'int',
'number' => '@int',
'globalActivities' => 'bool',
'comment' => '@string',
];
@@ -485,6 +486,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'billable' => 'bool',
'color' => '@string',
'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@int',
'globalActivities' => 'bool',
'comment' => '@string',
];
@@ -497,6 +499,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'visible' => 'bool',
'billable' => 'bool',
'customer' => 'int',
'number' => '@int',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => 'string',
@@ -515,6 +518,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'visible' => 'bool',
'billable' => 'bool',
'customer' => 'int',
'number' => '@int',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => 'string',
@@ -538,6 +542,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'visible' => 'bool',
'billable' => 'bool',
'project' => '@int',
'number' => '@int',
'color' => '@string',
'comment' => '@string',
];
@@ -549,6 +554,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'visible' => 'bool',
'billable' => 'bool',
'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
'number' => '@int',
'color' => '@string',
'comment' => '@string',
];
@@ -561,6 +567,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'visible' => 'bool',
'billable' => 'bool',
'project' => '@int',
'number' => '@int',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => '@string',
@@ -576,6 +583,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'visible' => 'bool',
'billable' => 'bool',
'project' => '@int',
'number' => '@int',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => '@string',

View File

@@ -19,6 +19,7 @@ use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\ActivityUpdatePreEvent;
use App\Repository\ActivityRepository;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Validator\ValidationFailedException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -49,7 +50,9 @@ class ActivityServiceTest extends TestCase
$validator->method('validate')->willReturn(new ConstraintViolationList());
}
$service = new ActivityService($repository, $dispatcher, $validator);
$configuration = SystemConfigurationFactory::createStub(['activity' => []]);
$service = new ActivityService($repository, $configuration, $dispatcher, $validator);
return $service;
}

View File

@@ -425,15 +425,22 @@ class ConfigurationTest extends TestCase
],
'project' => [
'copy_teams_on_create' => false,
'number_format' => '{pc,4}',
'allow_duplicate_number' => false,
'choice_pattern' => '{name}',
],
'activity' => [
'allow_inline_create' => false,
'number_format' => '{ac,4}',
'allow_duplicate_number' => false,
'choice_pattern' => '{name}',
],
'customer' => [
'number_format' => '{cc,4}',
'rules' => [
'allow_duplicate_number' => false,
],
'choice_pattern' => '{name}',
],
'features' => [
'user_registration' => false,

View File

@@ -152,6 +152,7 @@ class ActivityTest extends AbstractEntityTest
['visible', 'boolean'],
['comment', 'string'],
['billable', 'boolean'],
['activity_number', 'string'],
];
self::assertCount(\count($expected), $columns);

View File

@@ -184,6 +184,7 @@ class ProjectTest extends AbstractEntityTest
['visible', 'boolean'],
['comment', 'string'],
['billable', 'boolean'],
['project_number', 'string'],
];
self::assertCount(\count($expected), $columns);

View File

@@ -0,0 +1,75 @@
<?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\Export\Spreadsheet;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Event\ActivityMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Extractor\AnnotationExtractor;
use App\Export\Spreadsheet\Extractor\MetaFieldExtractor;
use App\Export\Spreadsheet\SpreadsheetExporter;
use App\Repository\Query\ActivityQuery;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Spreadsheet\EntityWithMetaFieldsExporter
*/
class ActivityExporterTest extends TestCase
{
public function testExport(): void
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects(self::once())->method('dispatch')->willReturnCallback(function (ActivityMetaDisplayEvent $event) {
$event->addField((new ActivityMeta())->setName('foo meta')->setIsVisible(true));
$event->addField((new ActivityMeta())->setName('hidden meta')->setIsVisible(false));
$event->addField((new ActivityMeta())->setName('bar meta')->setIsVisible(true));
return $event;
});
$spreadsheetExporter = new SpreadsheetExporter($this->createMock(TranslatorInterface::class));
$annotationExtractor = new AnnotationExtractor();
$metaFieldExtractor = new MetaFieldExtractor($dispatcher);
$activity = new Activity();
$activity->setName('test activity');
$activity->setComment('Lorem Ipsum');
$activity->setBudget(123456.7890);
$activity->setTimeBudget(1234567890);
$activity->setColor('#ababab');
$activity->setVisible(false);
$activity->setNumber('AC-0815');
$activity->setMetaField((new ActivityMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
$activity->setMetaField((new ActivityMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
$activity->setMetaField((new ActivityMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
$sut = new EntityWithMetaFieldsExporter($spreadsheetExporter, $annotationExtractor, $metaFieldExtractor);
$spreadsheet = $sut->export(Activity::class, [$activity], new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT));
$worksheet = $spreadsheet->getActiveSheet();
$i = 0;
self::assertNull($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('test activity', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(123456.7890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('=1234567890/86400', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('#ababab', $worksheet->getCell([++$i, 2])->getValue());
self::assertFalse($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue());
self::assertTrue($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('AC-0815', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
}
}

View File

@@ -44,18 +44,18 @@ class AnnotatedObjectExporterTest extends TestCase
$worksheet = $spreadsheet->getActiveSheet();
$i = 0;
self::assertNull($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('test project', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('A customer', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals(1234567890, $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals(123456.7890, $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('=1234567890/86400', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('month', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('#ababab', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertFalse($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('Lorem Ipsum', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertNull($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('test project', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('A customer', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(1234567890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(123456.7890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('=1234567890/86400', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('month', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('#ababab', $worksheet->getCell([++$i, 2])->getValue());
self::assertFalse($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue());
}
}

View File

@@ -40,7 +40,7 @@ abstract class AbstractFormatterTest extends TestCase
$worksheet = $spreadsheet->getActiveSheet();
$sut->setFormattedValue($worksheet, 1, 1, $this->getActualValue());
$cell = $worksheet->getCellByColumnAndRow(1, 1);
$cell = $worksheet->getCell([1, 1]);
$this->assertCellValue($cell);
$this->assertCellStyle($worksheet->getStyleByColumnAndRow(1, 1));
}
@@ -53,7 +53,7 @@ abstract class AbstractFormatterTest extends TestCase
$worksheet = $spreadsheet->getActiveSheet();
$sut->setFormattedValue($worksheet, 1, 1, null);
$cell = $worksheet->getCellByColumnAndRow(1, 1);
$cell = $worksheet->getCell([1, 1]);
$this->assertNullValue($cell);
}

View File

@@ -0,0 +1,88 @@
<?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\Export\Spreadsheet;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Event\CustomerMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Extractor\AnnotationExtractor;
use App\Export\Spreadsheet\Extractor\MetaFieldExtractor;
use App\Export\Spreadsheet\SpreadsheetExporter;
use App\Repository\Query\CustomerQuery;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Spreadsheet\EntityWithMetaFieldsExporter
*/
class CustomerExporterTest extends TestCase
{
public function testExport(): void
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects(self::once())->method('dispatch')->willReturnCallback(function (CustomerMetaDisplayEvent $event) {
$event->addField((new CustomerMeta())->setName('foo meta')->setIsVisible(true));
$event->addField((new CustomerMeta())->setName('hidden meta')->setIsVisible(false));
$event->addField((new CustomerMeta())->setName('bar meta')->setIsVisible(true));
return $event;
});
$spreadsheetExporter = new SpreadsheetExporter($this->createMock(TranslatorInterface::class));
$annotationExtractor = new AnnotationExtractor();
$metaFieldExtractor = new MetaFieldExtractor($dispatcher);
$customer = new Customer('test customer');
$customer->setCompany('Acme Foo');
$customer->setVatId('DE0123456789');
$customer->setComment('Lorem Ipsum');
$customer->setBudget(123456.7890);
$customer->setTimeBudget(1234567890);
$customer->setColor('#ababab');
$customer->setVisible(false);
$customer->setNumber('CU-0815');
$customer->setMetaField((new CustomerMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
$customer->setMetaField((new CustomerMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
$customer->setMetaField((new CustomerMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
$sut = new EntityWithMetaFieldsExporter($spreadsheetExporter, $annotationExtractor, $metaFieldExtractor);
$spreadsheet = $sut->export(Customer::class, [$customer], new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT));
$worksheet = $spreadsheet->getActiveSheet();
$i = 0;
self::assertNull($worksheet->getCell([++$i, 2])->getValue()); // id
self::assertEquals('test customer', $worksheet->getCell([++$i, 2])->getValue()); // name
self::assertEquals('Acme Foo', $worksheet->getCell([++$i, 2])->getValue()); // company
self::assertEquals('CU-0815', $worksheet->getCell([++$i, 2])->getValue()); // number
self::assertEquals('DE0123456789', $worksheet->getCell([++$i, 2])->getValue()); // vatId
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // address
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // contact
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // email
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // phone
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // mobile
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // fax
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // homepage
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // country
self::assertEquals('EUR', $worksheet->getCell([++$i, 2])->getValue()); // currency
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // timezone
self::assertEquals('123456.789', $worksheet->getCell([++$i, 2])->getValue()); // budget
self::assertEquals('=1234567890/86400', $worksheet->getCell([++$i, 2])->getValue()); // timeBudget
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // budgetType
self::assertEquals('#ababab', $worksheet->getCell([++$i, 2])->getValue()); // color
self::assertFalse($worksheet->getCell([++$i, 2])->getValue()); // visible
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue()); // comment
self::assertTrue($worksheet->getCell([++$i, 2])->getValue()); // billable
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
}
}

View File

@@ -51,6 +51,7 @@ class EntityWithMetaFieldsExporterTest extends TestCase
$project->setTimeBudget(1234567890);
$project->setColor('#ababab');
$project->setVisible(false);
$project->setNumber('PRJ-0815');
$project->setMetaField((new ProjectMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
$project->setMetaField((new ProjectMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
$project->setMetaField((new ProjectMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
@@ -60,21 +61,22 @@ class EntityWithMetaFieldsExporterTest extends TestCase
$worksheet = $spreadsheet->getActiveSheet();
$i = 0;
self::assertNull($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('test project', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('A customer', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals(1234567890, $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals(123456.7890, $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('=1234567890/86400', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('#ababab', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertFalse($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('Lorem Ipsum', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertTrue($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('some magic', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertEquals('is happening', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
self::assertNull($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('test project', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('A customer', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(1234567890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(123456.7890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('=1234567890/86400', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('#ababab', $worksheet->getCell([++$i, 2])->getValue());
self::assertFalse($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue());
self::assertTrue($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('PRJ-0815', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
}
}

View File

@@ -0,0 +1,82 @@
<?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\Export\Spreadsheet;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Event\ProjectMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Extractor\AnnotationExtractor;
use App\Export\Spreadsheet\Extractor\MetaFieldExtractor;
use App\Export\Spreadsheet\SpreadsheetExporter;
use App\Repository\Query\ProjectQuery;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Spreadsheet\EntityWithMetaFieldsExporter
*/
class ProjectExporterTest extends TestCase
{
public function testExport(): void
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects(self::once())->method('dispatch')->willReturnCallback(function (ProjectMetaDisplayEvent $event) {
$event->addField((new ProjectMeta())->setName('foo meta')->setIsVisible(true));
$event->addField((new ProjectMeta())->setName('hidden meta')->setIsVisible(false));
$event->addField((new ProjectMeta())->setName('bar meta')->setIsVisible(true));
return $event;
});
$spreadsheetExporter = new SpreadsheetExporter($this->createMock(TranslatorInterface::class));
$annotationExtractor = new AnnotationExtractor();
$metaFieldExtractor = new MetaFieldExtractor($dispatcher);
$project = new Project();
$project->setName('test project');
$project->setCustomer(new Customer('A customer'));
$project->setComment('Lorem Ipsum');
$project->setOrderNumber('1234567890');
$project->setBudget(123456.7890);
$project->setTimeBudget(1234567890);
$project->setColor('#ababab');
$project->setVisible(false);
$project->setNumber('PRJ-0815');
$project->setMetaField((new ProjectMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
$project->setMetaField((new ProjectMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
$project->setMetaField((new ProjectMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
$sut = new EntityWithMetaFieldsExporter($spreadsheetExporter, $annotationExtractor, $metaFieldExtractor);
$spreadsheet = $sut->export(Project::class, [$project], new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT));
$worksheet = $spreadsheet->getActiveSheet();
$i = 0;
self::assertNull($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('test project', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('A customer', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(1234567890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals(123456.7890, $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('=1234567890/86400', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('#ababab', $worksheet->getCell([++$i, 2])->getValue());
self::assertFalse($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue());
self::assertTrue($worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('PRJ-0815', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
}
}

View File

@@ -28,13 +28,13 @@ class SpreadsheetExporterTest extends TestCase
$sut->registerCellFormatter('foo', new class() implements CellFormatterInterface {
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
$sheet->setCellValueByColumnAndRow($column, $row, '##' . $value . '##');
$sheet->setCellValue([$column, $row], '##' . $value . '##');
}
});
$sut->registerCellFormatter('bar', new class() implements CellFormatterInterface {
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
$sheet->setCellValueByColumnAndRow($column, $row, '~' . $value . '~');
$sheet->setCellValue([$column, $row], '~' . $value . '~');
}
});
@@ -62,8 +62,8 @@ class SpreadsheetExporterTest extends TestCase
$worksheet = $spreadsheet->getActiveSheet();
self::assertEquals('##test project##', $worksheet->getCellByColumnAndRow(1, 2)->getValue());
self::assertEquals('~test project~', $worksheet->getCellByColumnAndRow(2, 2)->getValue());
self::assertFalse($worksheet->getCellByColumnAndRow(3, 2)->getValue());
self::assertEquals('##test project##', $worksheet->getCell([1, 2])->getValue());
self::assertEquals('~test project~', $worksheet->getCell([2, 2])->getValue());
self::assertFalse($worksheet->getCell([3, 2])->getValue());
}
}

View File

@@ -48,18 +48,18 @@ class UserExporterTest extends TestCase
$spreadsheet = $sut->export([$user], new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT));
$worksheet = $spreadsheet->getActiveSheet();
self::assertNull($worksheet->getCellByColumnAndRow(1, 2)->getValue());
self::assertEquals('test user', $worksheet->getCellByColumnAndRow(2, 2)->getValue());
self::assertEquals('Another name', $worksheet->getCellByColumnAndRow(3, 2)->getValue());
self::assertEquals('Mr. Title', $worksheet->getCellByColumnAndRow(4, 2)->getValue());
self::assertEquals('test@example.com', $worksheet->getCellByColumnAndRow(5, 2)->getValue());
self::assertEquals('', $worksheet->getCellByColumnAndRow(6, 2)->getValue());
self::assertEquals('de', $worksheet->getCellByColumnAndRow(7, 2)->getValue());
self::assertEquals('Europe/Berlin', $worksheet->getCellByColumnAndRow(8, 2)->getValue());
self::assertFalse($worksheet->getCellByColumnAndRow(9, 2)->getValue());
self::assertEquals($date->format('Y-m-d H:i'), $worksheet->getCellByColumnAndRow(10, 2)->getFormattedValue());
self::assertEquals('ROLE_TEAMLEAD;ROLE_USER', $worksheet->getCellByColumnAndRow(11, 2)->getValue());
self::assertEquals('#ececec', $worksheet->getCellByColumnAndRow(12, 2)->getValue());
self::assertEquals('F-747864', $worksheet->getCellByColumnAndRow(13, 2)->getValue());
self::assertNull($worksheet->getCell([1, 2])->getValue());
self::assertEquals('test user', $worksheet->getCell([2, 2])->getValue());
self::assertEquals('Another name', $worksheet->getCell([3, 2])->getValue());
self::assertEquals('Mr. Title', $worksheet->getCell([4, 2])->getValue());
self::assertEquals('test@example.com', $worksheet->getCell([5, 2])->getValue());
self::assertEquals('', $worksheet->getCell([6, 2])->getValue());
self::assertEquals('de', $worksheet->getCell([7, 2])->getValue());
self::assertEquals('Europe/Berlin', $worksheet->getCell([8, 2])->getValue());
self::assertFalse($worksheet->getCell([9, 2])->getValue());
self::assertEquals($date->format('Y-m-d H:i'), $worksheet->getCell([10, 2])->getFormattedValue());
self::assertEquals('ROLE_TEAMLEAD;ROLE_USER', $worksheet->getCell([11, 2])->getValue());
self::assertEquals('#ececec', $worksheet->getCell([12, 2])->getValue());
self::assertEquals('F-747864', $worksheet->getCell([13, 2])->getValue());
}
}

View File

@@ -56,7 +56,7 @@ class ProjectServiceTest extends TestCase
$configuration = SystemConfigurationFactory::createStub(['project' => ['copy_teams_on_create' => $copyTeamsOnCreate]]);
return new ProjectService($configuration, $repository, $dispatcher, $validator);
return new ProjectService($repository, $configuration, $dispatcher, $validator);
}
public function testCannotSavePersistedProjectAsNew(): void

View File

@@ -0,0 +1,49 @@
<?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\Validator\Constraints;
use App\Configuration\ConfigLoaderInterface;
use App\Repository\ActivityRepository;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Validator\Constraints\Activity as ActivityConstraint;
use App\Validator\Constraints\ActivityValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\Activity
* @covers \App\Validator\Constraints\ActivityValidator
* @extends ConstraintValidatorTestCase<ActivityValidator>
*/
class ActivityValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ActivityValidator
{
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = SystemConfigurationFactory::create($loader, []);
$repository = $this->createMock(ActivityRepository::class);
return new ActivityValidator($config, $repository);
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('foo', new NotBlank());
}
public function testGetTargets(): void
{
$constraint = new ActivityConstraint();
self::assertEquals('class', $constraint->getTargets());
}
}

View File

@@ -0,0 +1,49 @@
<?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\Validator\Constraints;
use App\Configuration\ConfigLoaderInterface;
use App\Repository\CustomerRepository;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Validator\Constraints\Customer as CustomerConstraint;
use App\Validator\Constraints\CustomerValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\Customer
* @covers \App\Validator\Constraints\CustomerValidator
* @extends ConstraintValidatorTestCase<CustomerValidator>
*/
class CustomerValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): CustomerValidator
{
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = SystemConfigurationFactory::create($loader, []);
$repository = $this->createMock(CustomerRepository::class);
return new CustomerValidator($config, $repository);
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('foo', new NotBlank());
}
public function testGetTargets(): void
{
$constraint = new CustomerConstraint();
self::assertEquals('class', $constraint->getTargets());
}
}

View File

@@ -9,7 +9,10 @@
namespace App\Tests\Validator\Constraints;
use App\Configuration\ConfigLoaderInterface;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Validator\Constraints\Project as ProjectConstraint;
use App\Validator\Constraints\ProjectValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -25,7 +28,11 @@ class ProjectValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ProjectValidator
{
return new ProjectValidator();
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = SystemConfigurationFactory::create($loader, []);
$repository = $this->createMock(ProjectRepository::class);
return new ProjectValidator($config, $repository);
}
public function testConstraintIsInvalid(): void

View File

@@ -766,6 +766,14 @@
<source>project_end</source>
<target>Projekt Ende</target>
</trans-unit>
<trans-unit id="tfOjLNp" resname="project_number">
<source>Project number</source>
<target>Projektnummer</target>
</trans-unit>
<trans-unit id="8JEDuyx" resname="activity_number">
<source>Activity number</source>
<target>Tätigkeitsnummer</target>
</trans-unit>
<!-- Customer -->
<trans-unit id="EohvnQA" resname="number">
<source>number</source>

View File

@@ -766,6 +766,14 @@
<source>project_end</source>
<target>Project end</target>
</trans-unit>
<trans-unit id="tfOjLNp" resname="project_number">
<source>Project number</source>
<target>Project number</target>
</trans-unit>
<trans-unit id="8JEDuyx" resname="activity_number">
<source>Activity number</source>
<target>Activity number</target>
</trans-unit>
<!-- Customer -->
<trans-unit id="EohvnQA" resname="number">
<source>number</source>

View File

@@ -214,6 +214,18 @@
<source>customer.allow_duplicate_number</source>
<target>Erlaube Kundennummer mehrfach zu verwenden</target>
</trans-unit>
<trans-unit id="3xUU9V." resname="project.number_format">
<source>project.number_format</source>
<target>Projektnummer Format</target>
</trans-unit>
<trans-unit id="ifgrIHh" resname="activity.number_format">
<source>activity.number_format</source>
<target>Tätigkeitsnummer Format</target>
</trans-unit>
<trans-unit id="jj1NmKW" resname="allow_duplicate_number">
<source>allow_duplicate_number</source>
<target>Erlaube Mehrfach-Nutzung derselben Nummer</target>
</trans-unit>
<trans-unit id="IZY5kqL" resname="allowed_replacer">
<source>allowed_replacer</source>
<target>Erlaubte Ersetzer: %replacer%</target>

View File

@@ -208,12 +208,24 @@
</trans-unit>
<trans-unit id="LmOfuE5" resname="customer.number_format">
<source>customer.number_format</source>
<target>Customer number Format</target>
<target>Customer number format</target>
</trans-unit>
<trans-unit id="1cAQPNS" resname="customer.allow_duplicate_number">
<source>customer.allow_duplicate_number</source>
<target>Allow duplicate account number</target>
</trans-unit>
<trans-unit id="3xUU9V." resname="project.number_format">
<source>project.number_format</source>
<target>Project number format</target>
</trans-unit>
<trans-unit id="ifgrIHh" resname="activity.number_format">
<source>activity.number_format</source>
<target>Activity number format</target>
</trans-unit>
<trans-unit id="jj1NmKW" resname="allow_duplicate_number">
<source>allow_duplicate_number</source>
<target>Allow multiple usages of the same number</target>
</trans-unit>
<trans-unit id="IZY5kqL" resname="allowed_replacer">
<source>allowed_replacer</source>
<target>Allowed replacer: %replacer%</target>