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

@@ -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();
}
}
}