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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -475,7 +475,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
if ($this->id !== null) {
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -23,7 +23,7 @@ final class CustomerHelper
|
||||
|
||||
private ?string $pattern = null;
|
||||
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
src/Validator/Constraints/Activity.php
Normal file
29
src/Validator/Constraints/Activity.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/Validator/Constraints/ActivityValidator.php
Normal file
55
src/Validator/Constraints/ActivityValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user