diff --git a/src/Controller/Organizations/ContractsController.php b/src/Controller/Organizations/ContractsController.php index 808039eb..91ef8050 100644 --- a/src/Controller/Organizations/ContractsController.php +++ b/src/Controller/Organizations/ContractsController.php @@ -8,11 +8,16 @@ 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; use App\Security\Authorizer; +use App\Service\ContractTimeAccounting; use App\Utils; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\Response; @@ -86,6 +91,10 @@ public function create( Organization $organization, Request $request, ContractRepository $contractRepository, + EntityEventRepository $entityEventRepository, + TicketRepository $ticketRepository, + TimeSpentRepository $timeSpentRepository, + ContractTimeAccounting $contractTimeAccounting, ValidatorInterface $validator, TranslatorInterface $translator, ): Response { @@ -106,6 +115,37 @@ public function create( $contract->initDefaultAlerts(); $contractRepository->save($contract, true); + $contractTickets = []; + + 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()) { + foreach ($contractTickets as $ticket) { + $timeSpents = $ticket->getUnaccountedTimeSpents()->getValues(); + + if (!$timeSpents) { + continue; + } + + $contractTimeAccounting->accountTimeSpents($contract, $timeSpents); + $timeSpentRepository->save($timeSpents, true); + } + } + return $this->redirectToRoute('contract', [ 'uid' => $contract->getUid(), ]); diff --git a/src/Form/Type/ContractType.php b/src/Form/Type/ContractType.php index 0f1c2493..f5468c07 100644 --- a/src/Form/Type/ContractType.php +++ b/src/Form/Type/ContractType.php @@ -43,6 +43,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'empty_data' => '', 'trim' => true, ]); + $builder->add('associateTickets', Type\CheckboxType::class, [ + 'required' => false, + 'mapped' => false, + 'data' => true, + ]); + $builder->add('associateUnaccountedTimes', 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..71cb0019 100644 --- a/templates/contracts/_form.html.twig +++ b/templates/contracts/_form.html.twig @@ -156,6 +156,37 @@ >{{ field_value(form.notes) }} + {% if display_associate_checkboxes %} +
+ + + +
+ +
+ + + +
+ {% else %} + + + {% endif %} +
diff --git a/tests/Controller/Organizations/ContractsControllerTest.php b/tests/Controller/Organizations/ContractsControllerTest.php index a1f92153..1dd7ad55 100644 --- a/tests/Controller/Organizations/ContractsControllerTest.php +++ b/tests/Controller/Organizations/ContractsControllerTest.php @@ -9,6 +9,8 @@ use App\Tests\AuthorizationHelper; 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; @@ -149,6 +151,143 @@ public function testPostCreateCreatesAContractAndRedirects(): void $this->assertSame(24, $contract->getDateAlert()); // 20% of the days duration } + public function testPostCreateCanAttachTicketsToContract(): 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'); + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdAt' => new \DateTimeImmutable('2023-06-06'), + ]); + + $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'), + 'associateTickets' => true, + ], + ]); + + $this->assertSame(1, ContractFactory::count()); + $contract = ContractFactory::last(); + $contract->refresh(); + $this->assertResponseRedirects("/contracts/{$contract->getUid()}", 302); + $tickets = $contract->getTickets(); + $this->assertSame(1, count($tickets)); + $this->assertSame($ticket->getUid(), $tickets[0]->getUid()); + } + + public function testPostCreateDoesNotAttachContractIfTicketsHaveAlreadyOneOngoing(): 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'); + // We use an overlapping contract. Note that the ticket has been + // created before the start of the existing contract. It allows to test + // an edge-case that we want to handle correctly. + $existingStartAt = new \DateTimeImmutable('2023-09-01'); + $existingEndAt = new \DateTimeImmutable('2023-08-31'); + $existingContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => $startAt, + 'endAt' => $endAt, + ]); + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdAt' => new \DateTimeImmutable('2023-06-06'), + 'contracts' => [$existingContract], + ]); + + $this->assertSame(1, 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'), + 'associateTickets' => true, + ], + ]); + + $this->assertSame(2, ContractFactory::count()); + $contract = ContractFactory::last(); + $contract->refresh(); + $this->assertResponseRedirects("/contracts/{$contract->getUid()}", 302); + $tickets = $contract->getTickets(); + $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 2563656e..a2d3adf1 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -68,6 +68,8 @@ contracts.alerts.edit.hours_alert.before: 'Alert when' 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' @@ -289,7 +291,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..733abf17 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -68,6 +68,8 @@ contracts.alerts.edit.hours_alert.before: 'Alerter lorsque' 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' @@ -289,7 +291,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'