From 2602817b02191e5bc1fa1e54c43ca9803f3a1468 Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Thu, 6 Jun 2024 10:16:10 +0200 Subject: [PATCH 1/4] Rename the "include unaccounted time" checkbox --- templates/tickets/contracts/edit.html.twig | 2 +- translations/messages+intl-icu.en_GB.yaml | 2 +- translations/messages+intl-icu.fr_FR.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/tickets/contracts/edit.html.twig b/templates/tickets/contracts/edit.html.twig index ff178146..098bf23c 100644 --- a/templates/tickets/contracts/edit.html.twig +++ b/templates/tickets/contracts/edit.html.twig @@ -48,7 +48,7 @@ /> diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index 2563656e..18ba59fa 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -289,7 +289,7 @@ teams.show.edit: 'Edit the team' tickets.actors: Actors tickets.actors.edit.title: 'Edit the actors' tickets.assignee: Assignee -tickets.contracts.edit.include_unaccounted_time: 'Include unaccounted time in the contract' +tickets.contracts.edit.associate_unaccounted_times: 'Associate existing unaccounted spent times' tickets.contracts.edit.title: 'Edit the contract' tickets.contracts.none: 'No contract' tickets.contracts.ongoing: 'Ongoing contract' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index f2493fd0..836e0661 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -289,7 +289,7 @@ teams.show.edit: 'Modifier l’équipe' tickets.actors: Acteurs tickets.actors.edit.title: 'Modifier les acteurs' tickets.assignee: 'Attribué à' -tickets.contracts.edit.include_unaccounted_time: 'Inclure le temps non comptabilisé dans le contrat' +tickets.contracts.edit.associate_unaccounted_times: 'Associer les temps passés existants non comptabilisés' tickets.contracts.edit.title: 'Modifier le contrat' tickets.contracts.none: 'Aucun contrat' tickets.contracts.ongoing: 'Contrat en cours' From 9f562fb88809f63d8d406b08e082f8075dc8ccde Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Thu, 6 Jun 2024 12:03:50 +0200 Subject: [PATCH 2/4] Allow to associate tickets to a contract --- .../Organizations/ContractsController.php | 12 +++ src/Form/Type/ContractType.php | 5 ++ src/Repository/TicketRepository.php | 43 +++++++++ templates/contracts/_form.html.twig | 17 ++++ templates/contracts/edit.html.twig | 1 + .../organizations/contracts/new.html.twig | 1 + .../Organizations/ContractsControllerTest.php | 89 +++++++++++++++++++ translations/messages+intl-icu.en_GB.yaml | 1 + translations/messages+intl-icu.fr_FR.yaml | 1 + 9 files changed, 170 insertions(+) diff --git a/src/Controller/Organizations/ContractsController.php b/src/Controller/Organizations/ContractsController.php index 808039eb..53a19c28 100644 --- a/src/Controller/Organizations/ContractsController.php +++ b/src/Controller/Organizations/ContractsController.php @@ -12,6 +12,7 @@ use App\Form\Type\ContractType; use App\Repository\ContractRepository; use App\Repository\OrganizationRepository; +use App\Repository\TicketRepository; use App\Security\Authorizer; use App\Utils; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -86,6 +87,7 @@ public function create( Organization $organization, Request $request, ContractRepository $contractRepository, + TicketRepository $ticketRepository, ValidatorInterface $validator, TranslatorInterface $translator, ): Response { @@ -106,6 +108,16 @@ public function create( $contract->initDefaultAlerts(); $contractRepository->save($contract, true); + if ($form->get('associateTickets')->getData()) { + $tickets = $ticketRepository->findAssociableTickets($contract); + + foreach ($tickets as $ticket) { + $ticket->addContract($contract); + } + + $ticketRepository->save($tickets, true); + } + return $this->redirectToRoute('contract', [ 'uid' => $contract->getUid(), ]); diff --git a/src/Form/Type/ContractType.php b/src/Form/Type/ContractType.php index 0f1c2493..118aceab 100644 --- a/src/Form/Type/ContractType.php +++ b/src/Form/Type/ContractType.php @@ -43,6 +43,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'empty_data' => '', 'trim' => true, ]); + $builder->add('associateTickets', Type\CheckboxType::class, [ + 'required' => false, + 'mapped' => false, + 'data' => true, + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Repository/TicketRepository.php b/src/Repository/TicketRepository.php index 5e85d500..b9ccff55 100644 --- a/src/Repository/TicketRepository.php +++ b/src/Repository/TicketRepository.php @@ -6,9 +6,11 @@ namespace App\Repository; +use App\Entity\Contract; use App\Entity\Ticket; use App\Uid\UidGeneratorInterface; use App\Uid\UidGeneratorTrait; +use App\Utils; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -34,4 +36,45 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Ticket::class); } + + /** + * @return Ticket[] + */ + public function findAssociableTickets(Contract $contract): array + { + $entityManager = $this->getEntityManager(); + + $query = $entityManager->createQuery(<<setParameter('organization', $contract->getOrganization()); + $query->setParameter('startAt', $contract->getStartAt()); + $query->setParameter('endAt', $contract->getEndAt()); + + $tickets = $query->getResult(); + + // Filter tickets that have no ongoing contract on the period of the + // given contract. + return array_filter($tickets, function ($ticket) use ($contract): bool { + $ticketContracts = $ticket->getContracts()->toArray(); + + $hasOngoingContract = Utils\ArrayHelper::any( + $ticketContracts, + function ($ticketContract) use ($contract): bool { + return ( + $ticketContract->getEndAt() >= $contract->getStartAt() && + $ticketContract->getStartAt() < $contract->getEndAt() + ); + } + ); + + return !$hasOngoingContract; + }); + } } diff --git a/templates/contracts/_form.html.twig b/templates/contracts/_form.html.twig index 29566022..2c9b5db1 100644 --- a/templates/contracts/_form.html.twig +++ b/templates/contracts/_form.html.twig @@ -156,6 +156,23 @@ >{{ field_value(form.notes) }} + {% if display_associate_checkboxes %} +
+ + + +
+ {% else %} + + {% endif %} +
+ +
+ + + +
{% else %} + {% endif %}
diff --git a/tests/Controller/Organizations/ContractsControllerTest.php b/tests/Controller/Organizations/ContractsControllerTest.php index 417cce9c..1dd7ad55 100644 --- a/tests/Controller/Organizations/ContractsControllerTest.php +++ b/tests/Controller/Organizations/ContractsControllerTest.php @@ -10,6 +10,7 @@ use App\Tests\Factory\ContractFactory; use App\Tests\Factory\OrganizationFactory; use App\Tests\Factory\TicketFactory; +use App\Tests\Factory\TimeSpentFactory; use App\Tests\Factory\UserFactory; use App\Tests\SessionHelper; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -238,6 +239,55 @@ public function testPostCreateDoesNotAttachContractIfTicketsHaveAlreadyOneOngoin $this->assertSame(0, count($tickets)); } + public function testPostCreateCanAttachUnaccountedTimeSpentsToContract(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $organization = OrganizationFactory::createOne(); + $this->grantOrga($user->object(), [ + 'orga:see:contracts', + 'orga:manage:contracts', + ]); + $startAt = new \DateTimeImmutable('2023-01-01'); + $endAt = new \DateTimeImmutable('2023-12-31'); + $timeAccountingUnit = 30; + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdAt' => new \DateTimeImmutable('2023-06-06'), + ]); + $timeSpent = TimeSpentFactory::createOne([ + 'ticket' => $ticket, + 'contract' => null, + 'time' => 10, + 'realTime' => 10, + ]); + + $this->assertSame(0, ContractFactory::count()); + + $client->request('POST', "/organizations/{$organization->getUid()}/contracts/new", [ + 'contract' => [ + '_token' => $this->generateCsrfToken($client, 'contract'), + 'name' => 'My contract', + 'maxHours' => 10, + 'startAt' => $startAt->format('Y-m-d'), + 'endAt' => $endAt->format('Y-m-d'), + 'timeAccountingUnit' => $timeAccountingUnit, + 'associateTickets' => true, + 'associateUnaccountedTimes' => true, + ], + ]); + + $this->assertSame(1, ContractFactory::count()); + $contract = ContractFactory::last(); + $this->assertResponseRedirects("/contracts/{$contract->getUid()}", 302); + $timeSpent->refresh(); + $timeSpentContract = $timeSpent->getContract(); + $this->assertNotNull($timeSpentContract); + $this->assertSame($contract->getUid(), $timeSpentContract->getUid()); + $this->assertSame(30, $timeSpent->getTime()); + } + public function testPostCreateFailsIfNameIsInvalid(): void { $client = static::createClient(); diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index d5ce884d..a2d3adf1 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -69,6 +69,7 @@ contracts.alerts.edit.title: 'Set up contract alerts' contracts.days_consumed: "{days}\_d consumed" contracts.edit.title: 'Edit a contract' contracts.form.associate_tickets: 'Associate existing tickets created during the contract period' +contracts.form.associate_unaccounted_times: 'Associate existing unaccounted spent times' contracts.form.time_accounting_unit: 'Time accounting unit (minutes)' contracts.form.time_accounting_unit.caption: 'The time spent on tickets will be rounded up for contractual purposes. For example, if the time accounting unit is 30 minutes, and a technician spends 15 minutes on a ticket, 30 minutes will be accounted for in the contract.' contracts.form.end_at: 'End on' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index bb46366d..733abf17 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -69,6 +69,7 @@ contracts.alerts.edit.title: 'Configurer les alertes du contrat' contracts.days_consumed: "{days, plural, =0 {0\_j consommé} one {1\_j consommé} other {#\_j consommés}}" contracts.edit.title: 'Modifier un contrat' contracts.form.associate_tickets: 'Associer les tickets existants créés durant la période du contrat' +contracts.form.associate_unaccounted_times: 'Associer les temps passés existants non comptabilisés' contracts.form.time_accounting_unit: 'Unité de comptabilisation du temps (minutes)' contracts.form.time_accounting_unit.caption: 'Le temps passé sur les tickets sera arrondi à l’unité supérieure à des fins contractuelles. Par exemple, si l’unité de comptabilisation du temps est de 30 minutes et qu’un technicien passe 15 minutes sur un ticket, 30 minutes seront comptabilisées dans le contrat.' contracts.form.end_at: 'Termine le' From 0f1b1fe78700886cd963c857b208e0ed09f3ac6e Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Thu, 6 Jun 2024 15:49:30 +0200 Subject: [PATCH 4/4] Save the event that sets the ticket's contract --- src/Controller/Organizations/ContractsController.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Controller/Organizations/ContractsController.php b/src/Controller/Organizations/ContractsController.php index 07674a81..91ef8050 100644 --- a/src/Controller/Organizations/ContractsController.php +++ b/src/Controller/Organizations/ContractsController.php @@ -8,9 +8,11 @@ use App\Controller\BaseController; use App\Entity\Contract; +use App\Entity\EntityEvent; use App\Entity\Organization; use App\Form\Type\ContractType; use App\Repository\ContractRepository; +use App\Repository\EntityEventRepository; use App\Repository\OrganizationRepository; use App\Repository\TicketRepository; use App\Repository\TimeSpentRepository; @@ -89,6 +91,7 @@ public function create( Organization $organization, Request $request, ContractRepository $contractRepository, + EntityEventRepository $entityEventRepository, TicketRepository $ticketRepository, TimeSpentRepository $timeSpentRepository, ContractTimeAccounting $contractTimeAccounting, @@ -116,12 +119,18 @@ public function create( if ($form->get('associateTickets')->getData()) { $contractTickets = $ticketRepository->findAssociableTickets($contract); + $entityEvents = []; foreach ($contractTickets as $ticket) { $ticket->addContract($contract); + + $entityEvents[] = EntityEvent::initUpdate($ticket, [ + 'ongoingContract' => [null, $contract->getId()], + ]); } $ticketRepository->save($contractTickets, true); + $entityEventRepository->save($entityEvents, true); } if ($form->get('associateUnaccountedTimes')->getData()) {