Skip to content

Commit

Permalink
imp: Allow to assign tickets and unaccounted times on contract (re)newal
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Jun 6, 2024
2 parents 39ff01e + 0f1b1fe commit e7cc7a4
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 3 deletions.
40 changes: 40 additions & 0 deletions src/Controller/Organizations/ContractsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
]);
Expand Down
10 changes: 10 additions & 0 deletions src/Form/Type/ContractType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/Repository/TicketRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(<<<SQL
SELECT t
FROM App\Entity\Ticket t
WHERE t.organization = :organization
AND :startAt <= t.createdAt
AND t.createdAt < :endAt
SQL);

$query->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;
});
}
}
31 changes: 31 additions & 0 deletions templates/contracts/_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@
>{{ field_value(form.notes) }}</textarea>
</div>

{% if display_associate_checkboxes %}
<div>
<input
type="checkbox"
id="{{ field_id(form.associateTickets) }}"
name="{{ field_name(form.associateTickets) }}"
{{ form.associateTickets.vars.checked ? 'checked' }}
/>

<label for="{{ field_id(form.associateTickets) }}">
{{ 'contracts.form.associate_tickets' | trans }}
</label>
</div>

<div>
<input
type="checkbox"
id="{{ field_id(form.associateUnaccountedTimes) }}"
name="{{ field_name(form.associateUnaccountedTimes) }}"
{{ form.associateUnaccountedTimes.vars.checked ? 'checked' }}
/>

<label for="{{ field_id(form.associateUnaccountedTimes) }}">
{{ 'contracts.form.associate_unaccounted_times' | trans }}
</label>
</div>
{% else %}
<!-- {{ field_name(form.associateTickets) }} -->
<!-- {{ field_name(form.associateUnaccountedTimes) }} -->
{% endif %}

<div class="form__actions">
<button class="button--primary" type="submit">
{{ submit_label }}
Expand Down
1 change: 1 addition & 0 deletions templates/contracts/edit.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
<div class="panel">
{{ include('contracts/_form.html.twig', {
'form': form,
'display_associate_checkboxes': false,
'submit_label': 'forms.save_changes' | trans,
}) }}
</div>
Expand Down
1 change: 1 addition & 0 deletions templates/organizations/contracts/new.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<div class="panel">
{{ include('contracts/_form.html.twig', {
'form': form,
'display_associate_checkboxes': true,
'submit_label': 'contracts.form.new' | trans,
}) }}
</div>
Expand Down
2 changes: 1 addition & 1 deletion templates/tickets/contracts/edit.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
/>

<label for="include-unaccounted-time">
{{ 'tickets.contracts.edit.include_unaccounted_time' | trans }}
{{ 'tickets.contracts.edit.associate_unaccounted_times' | trans }}
</label>
</div>

Expand Down
139 changes: 139 additions & 0 deletions tests/Controller/Organizations/ContractsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion translations/messages+intl-icu.en_GB.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ contracts.alerts.edit.hours_alert.before: 'Alert when'
contracts.alerts.edit.title: 'Set up contract alerts'
contracts.days_consumed: "<strong>{days}\_d consumed</strong>"
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'
Expand Down Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion translations/messages+intl-icu.fr_FR.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ contracts.alerts.edit.hours_alert.before: 'Alerter lorsque'
contracts.alerts.edit.title: 'Configurer les alertes du contrat'
contracts.days_consumed: "<strong>{days, plural, =0 {0\_j consommé} one {1\_j consommé} other {#\_j consommés}}</strong>"
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'
Expand Down Expand Up @@ -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'
Expand Down

0 comments on commit e7cc7a4

Please sign in to comment.