From a636683dee0d462d9a1c70a69e287b80c431e4cd Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Thu, 4 Apr 2024 17:43:22 +0200 Subject: [PATCH] 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 --- migrations/Version20240326125247.php | 36 ++++++++ src/Activity/ActivityService.php | 47 +++++++++- src/Controller/ActivityController.php | 1 + src/Controller/ProjectController.php | 1 + .../SystemConfigurationController.php | 22 ++++- src/Customer/CustomerService.php | 46 +++++----- src/DependencyInjection/Configuration.php | 24 +++++ src/Entity/Activity.php | 22 ++++- src/Entity/Customer.php | 2 +- src/Entity/Project.php | 20 ++++- src/Form/ActivityEditForm.php | 7 ++ .../StringToArrayTransformer.php | 2 +- src/Form/Helper/ActivityHelper.php | 4 +- src/Form/Helper/CustomerHelper.php | 2 +- src/Form/Helper/ProjectHelper.php | 2 + src/Form/ProjectEditForm.php | 7 ++ src/Form/Type/ActivityTypePatternType.php | 15 +++- src/Form/Type/CustomerTypePatternType.php | 21 ++--- src/Form/Type/ProjectTypePatternType.php | 22 +++-- src/Project/ProjectService.php | 46 +++++++++- src/Repository/ActivityRepository.php | 2 +- src/Repository/ProjectRepository.php | 2 +- src/Validator/Constraints/Activity.php | 29 ++++++ .../Constraints/ActivityValidator.php | 55 ++++++++++++ src/Validator/Constraints/Customer.php | 2 +- .../Constraints/CustomerValidator.php | 24 ++--- src/Validator/Constraints/Project.php | 2 + .../Constraints/ProjectValidator.php | 43 +++++---- templates/activity/details.html.twig | 8 ++ templates/activity/edit.html.twig | 1 + templates/activity/index.html.twig | 2 + templates/project/details.html.twig | 8 ++ templates/project/edit.html.twig | 9 +- templates/project/index.html.twig | 2 + tests/API/APIControllerBaseTest.php | 8 ++ tests/Activity/ActivityServiceTest.php | 5 +- .../DependencyInjection/ConfigurationTest.php | 7 ++ tests/Entity/ActivityTest.php | 1 + tests/Entity/ProjectTest.php | 1 + .../Spreadsheet/ActivityExporterTest.php | 75 ++++++++++++++++ .../AnnotatedObjectExporterTest.php | 26 +++--- .../CellFormatter/AbstractFormatterTest.php | 4 +- .../Spreadsheet/CustomerExporterTest.php | 88 +++++++++++++++++++ .../EntityWithMetaFieldsExporterTest.php | 34 +++---- .../Spreadsheet/ProjectExporterTest.php | 82 +++++++++++++++++ .../Spreadsheet/SpreadsheetExporterTest.php | 10 +-- tests/Export/Spreadsheet/UserExporterTest.php | 26 +++--- tests/Project/ProjectServiceTest.php | 2 +- .../Constraints/ActivityValidatorTest.php | 49 +++++++++++ .../Constraints/CustomerValidatorTest.php | 49 +++++++++++ .../Constraints/ProjectValidatorTest.php | 9 +- translations/messages.de.xlf | 8 ++ translations/messages.en.xlf | 8 ++ translations/system-configuration.de.xlf | 12 +++ translations/system-configuration.en.xlf | 14 ++- 55 files changed, 920 insertions(+), 136 deletions(-) create mode 100644 migrations/Version20240326125247.php create mode 100644 src/Validator/Constraints/Activity.php create mode 100644 src/Validator/Constraints/ActivityValidator.php create mode 100644 tests/Export/Spreadsheet/ActivityExporterTest.php create mode 100644 tests/Export/Spreadsheet/CustomerExporterTest.php create mode 100644 tests/Export/Spreadsheet/ProjectExporterTest.php create mode 100644 tests/Validator/Constraints/ActivityValidatorTest.php create mode 100644 tests/Validator/Constraints/CustomerValidatorTest.php diff --git a/migrations/Version20240326125247.php b/migrations/Version20240326125247.php new file mode 100644 index 00000000..68ae1f4c --- /dev/null +++ b/migrations/Version20240326125247.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/src/Activity/ActivityService.php b/src/Activity/ActivityService.php index c82c41d1..37924a1f 100644 --- a/src/Activity/ActivityService.php +++ b/src/Activity/ActivityService.php @@ -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; + } } diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 25372b81..4029194b 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -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]); diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index c27a81ad..28173a56 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -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']); diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php index 8e262b89..95f64fa8 100644 --- a/src/Controller/SystemConfigurationController.php +++ b/src/Controller/SystemConfigurationController.php @@ -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')) diff --git a/src/Customer/CustomerService.php b/src/Customer/CustomerService.php index 1ed86613..1bbf1c03 100644 --- a/src/Customer/CustomerService.php +++ b/src/Customer/CustomerService.php @@ -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; } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a87d7770..b03a82d2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -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() diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index af1ab656..b1a4e90e 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -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; } diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index bebd1835..ed455e01 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -475,7 +475,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget public function __clone() { - if ($this->id) { + if ($this->id !== null) { $this->id = null; } diff --git a/src/Entity/Project.php b/src/Entity/Project.php index f254206f..b9d5b20f 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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; } diff --git a/src/Form/ActivityEditForm.php b/src/Form/ActivityEditForm.php index 5befc17e..11337fd3 100644 --- a/src/Form/ActivityEditForm.php +++ b/src/Form/ActivityEditForm.php @@ -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, diff --git a/src/Form/DataTransformer/StringToArrayTransformer.php b/src/Form/DataTransformer/StringToArrayTransformer.php index 0ba853e8..5f9a001f 100644 --- a/src/Form/DataTransformer/StringToArrayTransformer.php +++ b/src/Form/DataTransformer/StringToArrayTransformer.php @@ -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 diff --git a/src/Form/Helper/ActivityHelper.php b/src/Form/Helper/ActivityHelper.php index 9643982b..34e37481 100644 --- a/src/Form/Helper/ActivityHelper.php +++ b/src/Form/Helper/ActivityHelper.php @@ -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)) { diff --git a/src/Form/Helper/CustomerHelper.php b/src/Form/Helper/CustomerHelper.php index 72d76af0..1c3429b2 100644 --- a/src/Form/Helper/CustomerHelper.php +++ b/src/Form/Helper/CustomerHelper.php @@ -23,7 +23,7 @@ final class CustomerHelper private ?string $pattern = null; - public function __construct(private SystemConfiguration $configuration) + public function __construct(private readonly SystemConfiguration $configuration) { } diff --git a/src/Form/Helper/ProjectHelper.php b/src/Form/Helper/ProjectHelper.php index 233d4250..eb89232a 100644 --- a/src/Form/Helper/ProjectHelper.php +++ b/src/Form/Helper/ProjectHelper.php @@ -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); diff --git a/src/Form/ProjectEditForm.php b/src/Form/ProjectEditForm.php index de7f680b..6d9ade3e 100644 --- a/src/Form/ProjectEditForm.php +++ b/src/Form/ProjectEditForm.php @@ -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, diff --git a/src/Form/Type/ActivityTypePatternType.php b/src/Form/Type/ActivityTypePatternType.php index 299e3d0e..11e77cc5 100644 --- a/src/Form/Type/ActivityTypePatternType.php +++ b/src/Form/Type/ActivityTypePatternType.php @@ -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, ] ]); } diff --git a/src/Form/Type/CustomerTypePatternType.php b/src/Form/Type/CustomerTypePatternType.php index 335dc0b1..bd56af88 100644 --- a/src/Form/Type/CustomerTypePatternType.php +++ b/src/Form/Type/CustomerTypePatternType.php @@ -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, ] ]); } diff --git a/src/Form/Type/ProjectTypePatternType.php b/src/Form/Type/ProjectTypePatternType.php index 02e16c95..a208e1f8 100644 --- a/src/Form/Type/ProjectTypePatternType.php +++ b/src/Form/Type/ProjectTypePatternType.php @@ -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, ] ]); } diff --git a/src/Project/ProjectService.php b/src/Project/ProjectService.php index ecceaa7f..68bc691c 100644 --- a/src/Project/ProjectService.php +++ b/src/Project/ProjectService.php @@ -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; + } } diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index fb7f0253..57b615a5 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -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 diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 824ce584..23f1729e 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -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 diff --git a/src/Validator/Constraints/Activity.php b/src/Validator/Constraints/Activity.php new file mode 100644 index 00000000..693d8a03 --- /dev/null +++ b/src/Validator/Constraints/Activity.php @@ -0,0 +1,29 @@ + 'The number %number% is already used.', + ]; + + public string $message = 'This activity has invalid settings.'; + + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/Validator/Constraints/ActivityValidator.php b/src/Validator/Constraints/ActivityValidator.php new file mode 100644 index 00000000..6577fa0c --- /dev/null +++ b/src/Validator/Constraints/ActivityValidator.php @@ -0,0 +1,55 @@ +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; + } + } + } + } +} diff --git a/src/Validator/Constraints/Customer.php b/src/Validator/Constraints/Customer.php index 2a532234..b27e997c 100644 --- a/src/Validator/Constraints/Customer.php +++ b/src/Validator/Constraints/Customer.php @@ -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.'; diff --git a/src/Validator/Constraints/CustomerValidator.php b/src/Validator/Constraints/CustomerValidator.php index 599e1bb4..8480a465 100644 --- a/src/Validator/Constraints/CustomerValidator.php +++ b/src/Validator/Constraints/CustomerValidator.php @@ -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; + } } } } diff --git a/src/Validator/Constraints/Project.php b/src/Validator/Constraints/Project.php index c5e52c78..03dfc432 100644 --- a/src/Validator/Constraints/Project.php +++ b/src/Validator/Constraints/Project.php @@ -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.'; diff --git a/src/Validator/Constraints/ProjectValidator.php b/src/Validator/Constraints/ProjectValidator.php index 40d5be28..72223c07 100644 --- a/src/Validator/Constraints/ProjectValidator.php +++ b/src/Validator/Constraints/ProjectValidator.php @@ -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(); - } - } } diff --git a/templates/activity/details.html.twig b/templates/activity/details.html.twig index 6e7daf58..a38f75ba 100644 --- a/templates/activity/details.html.twig +++ b/templates/activity/details.html.twig @@ -71,6 +71,14 @@ {% endif %} + {% if activity.number is not empty %} + + {{ 'activity_number'|trans }} + + {{ activity.number }} + + + {% endif %} {% for metaField in activity.visibleMetaFields|filter(field => field.defined)|sort((a, b) => a.order <=> b.order) %} {{ metaField.label|trans }} diff --git a/templates/activity/edit.html.twig b/templates/activity/edit.html.twig index 10f38cbb..8d17409a 100644 --- a/templates/activity/edit.html.twig +++ b/templates/activity/edit.html.twig @@ -24,6 +24,7 @@ {% if form.project is defined %} {{ form_row(form.project) }} {% endif %} + {{ form_row(form.number) }} {% if form.budgetType is defined %}
{% if form.budget is defined %} diff --git a/templates/activity/index.html.twig b/templates/activity/index.html.twig index 0b72795f..ffa9ce92 100644 --- a/templates/activity/index.html.twig +++ b/templates/activity/index.html.twig @@ -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)) }} diff --git a/templates/project/details.html.twig b/templates/project/details.html.twig index 6066b43a..81d2affb 100644 --- a/templates/project/details.html.twig +++ b/templates/project/details.html.twig @@ -85,6 +85,14 @@ {% endif %} + {% if project.number is not empty %} + + {{ 'project_number'|trans }} + + {{ project.number }} + + + {% endif %} {% endif %} {% if project.hasBudget() and is_granted('budget', project) %} diff --git a/templates/project/edit.html.twig b/templates/project/edit.html.twig index 8d5f49cd..7c0c14b8 100644 --- a/templates/project/edit.html.twig +++ b/templates/project/edit.html.twig @@ -18,7 +18,14 @@
{{ form_row(form.comment) }} - {{ form_row(form.customer) }} +
+
+ {{ form_row(form.customer) }} +
+
+ {{ form_row(form.number) }} +
+
{{ form_row(form.orderNumber) }} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig index e47a5fbb..f1b68649 100644 --- a/templates/project/index.html.twig +++ b/templates/project/index.html.twig @@ -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' %} diff --git a/tests/API/APIControllerBaseTest.php b/tests/API/APIControllerBaseTest.php index d8a1f0ff..84fd70df 100644 --- a/tests/API/APIControllerBaseTest.php +++ b/tests/API/APIControllerBaseTest.php @@ -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', diff --git a/tests/Activity/ActivityServiceTest.php b/tests/Activity/ActivityServiceTest.php index 68a6fd47..685cadce 100644 --- a/tests/Activity/ActivityServiceTest.php +++ b/tests/Activity/ActivityServiceTest.php @@ -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; } diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 19093a89..91db4170 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -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, diff --git a/tests/Entity/ActivityTest.php b/tests/Entity/ActivityTest.php index 3eb628fc..ea1c3113 100644 --- a/tests/Entity/ActivityTest.php +++ b/tests/Entity/ActivityTest.php @@ -152,6 +152,7 @@ class ActivityTest extends AbstractEntityTest ['visible', 'boolean'], ['comment', 'string'], ['billable', 'boolean'], + ['activity_number', 'string'], ]; self::assertCount(\count($expected), $columns); diff --git a/tests/Entity/ProjectTest.php b/tests/Entity/ProjectTest.php index 91fe275e..17d66532 100644 --- a/tests/Entity/ProjectTest.php +++ b/tests/Entity/ProjectTest.php @@ -184,6 +184,7 @@ class ProjectTest extends AbstractEntityTest ['visible', 'boolean'], ['comment', 'string'], ['billable', 'boolean'], + ['project_number', 'string'], ]; self::assertCount(\count($expected), $columns); diff --git a/tests/Export/Spreadsheet/ActivityExporterTest.php b/tests/Export/Spreadsheet/ActivityExporterTest.php new file mode 100644 index 00000000..be0cfc01 --- /dev/null +++ b/tests/Export/Spreadsheet/ActivityExporterTest.php @@ -0,0 +1,75 @@ +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()); + } +} diff --git a/tests/Export/Spreadsheet/AnnotatedObjectExporterTest.php b/tests/Export/Spreadsheet/AnnotatedObjectExporterTest.php index 1c41d1ec..75a012fb 100644 --- a/tests/Export/Spreadsheet/AnnotatedObjectExporterTest.php +++ b/tests/Export/Spreadsheet/AnnotatedObjectExporterTest.php @@ -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()); } } diff --git a/tests/Export/Spreadsheet/CellFormatter/AbstractFormatterTest.php b/tests/Export/Spreadsheet/CellFormatter/AbstractFormatterTest.php index 974b425f..f331d8cc 100644 --- a/tests/Export/Spreadsheet/CellFormatter/AbstractFormatterTest.php +++ b/tests/Export/Spreadsheet/CellFormatter/AbstractFormatterTest.php @@ -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); } diff --git a/tests/Export/Spreadsheet/CustomerExporterTest.php b/tests/Export/Spreadsheet/CustomerExporterTest.php new file mode 100644 index 00000000..16da9607 --- /dev/null +++ b/tests/Export/Spreadsheet/CustomerExporterTest.php @@ -0,0 +1,88 @@ +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()); + } +} diff --git a/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php b/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php index c67564e4..69668888 100644 --- a/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php +++ b/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php @@ -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()); } } diff --git a/tests/Export/Spreadsheet/ProjectExporterTest.php b/tests/Export/Spreadsheet/ProjectExporterTest.php new file mode 100644 index 00000000..42a46634 --- /dev/null +++ b/tests/Export/Spreadsheet/ProjectExporterTest.php @@ -0,0 +1,82 @@ +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()); + } +} diff --git a/tests/Export/Spreadsheet/SpreadsheetExporterTest.php b/tests/Export/Spreadsheet/SpreadsheetExporterTest.php index 1418c5d6..654a4067 100644 --- a/tests/Export/Spreadsheet/SpreadsheetExporterTest.php +++ b/tests/Export/Spreadsheet/SpreadsheetExporterTest.php @@ -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()); } } diff --git a/tests/Export/Spreadsheet/UserExporterTest.php b/tests/Export/Spreadsheet/UserExporterTest.php index 586cb60c..92294d97 100644 --- a/tests/Export/Spreadsheet/UserExporterTest.php +++ b/tests/Export/Spreadsheet/UserExporterTest.php @@ -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()); } } diff --git a/tests/Project/ProjectServiceTest.php b/tests/Project/ProjectServiceTest.php index aba1b53f..d0e9710b 100644 --- a/tests/Project/ProjectServiceTest.php +++ b/tests/Project/ProjectServiceTest.php @@ -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 diff --git a/tests/Validator/Constraints/ActivityValidatorTest.php b/tests/Validator/Constraints/ActivityValidatorTest.php new file mode 100644 index 00000000..8d0bb136 --- /dev/null +++ b/tests/Validator/Constraints/ActivityValidatorTest.php @@ -0,0 +1,49 @@ + + */ +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()); + } +} diff --git a/tests/Validator/Constraints/CustomerValidatorTest.php b/tests/Validator/Constraints/CustomerValidatorTest.php new file mode 100644 index 00000000..2f9a8b69 --- /dev/null +++ b/tests/Validator/Constraints/CustomerValidatorTest.php @@ -0,0 +1,49 @@ + + */ +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()); + } +} diff --git a/tests/Validator/Constraints/ProjectValidatorTest.php b/tests/Validator/Constraints/ProjectValidatorTest.php index 51b4bf1d..77590514 100644 --- a/tests/Validator/Constraints/ProjectValidatorTest.php +++ b/tests/Validator/Constraints/ProjectValidatorTest.php @@ -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 diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 8f130c94..28bf36b4 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -766,6 +766,14 @@ project_end Projekt Ende + + Project number + Projektnummer + + + Activity number + Tätigkeitsnummer + number diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ab6607b4..8e6d14c8 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -766,6 +766,14 @@ project_end Project end + + Project number + Project number + + + Activity number + Activity number + number diff --git a/translations/system-configuration.de.xlf b/translations/system-configuration.de.xlf index 53472007..fa6cb5fd 100644 --- a/translations/system-configuration.de.xlf +++ b/translations/system-configuration.de.xlf @@ -214,6 +214,18 @@ customer.allow_duplicate_number Erlaube Kundennummer mehrfach zu verwenden + + project.number_format + Projektnummer Format + + + activity.number_format + Tätigkeitsnummer Format + + + allow_duplicate_number + Erlaube Mehrfach-Nutzung derselben Nummer + allowed_replacer Erlaubte Ersetzer: %replacer% diff --git a/translations/system-configuration.en.xlf b/translations/system-configuration.en.xlf index 9731d661..2ffe6b9e 100644 --- a/translations/system-configuration.en.xlf +++ b/translations/system-configuration.en.xlf @@ -208,12 +208,24 @@ customer.number_format - Customer number Format + Customer number format customer.allow_duplicate_number Allow duplicate account number + + project.number_format + Project number format + + + activity.number_format + Activity number format + + + allow_duplicate_number + Allow multiple usages of the same number + allowed_replacer Allowed replacer: %replacer%