From e57d1ac696e162c9ca00d0eb5fcebec1da56c146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 30 Sep 2025 13:12:06 +0000 Subject: [PATCH 1/2] Support for parent/children motives --- .../ChillTicketBundle/chill.api.specs.yaml | 2 +- .../src/Controller/MotiveApiController.php | 5 +- .../src/DataFixtures/ORM/LoadMotives.php | 83 ++++++---- .../ChillTicketBundle/src/Entity/Motive.php | 88 +++++++++- .../src/Entity/MotiveHistory.php | 2 +- .../Repository/TicketACLAwareRepository.php | 5 +- .../src/Resources/public/types.ts | 26 ++- .../components/ActionToolbarComponent.vue | 4 +- .../Addressee/AddresseeComponent.vue | 5 +- .../TicketApp/components/BannerComponent.vue | 14 +- .../Comment/CommentEditorComponent.vue | 23 ++- .../components/Motive/MotiveComponent.vue | 18 +- .../Motive/MotiveSelectorComponent.vue | 141 ++++++++++++---- .../components/TicketInitFormComponent.vue | 7 +- .../vuejs/TicketApp/store/modules/motive.ts | 13 ++ .../vuejs/TicketApp/store/modules/persons.ts | 2 +- .../TicketApp/store/modules/ticket_list.ts | 3 +- .../public/vuejs/TicketApp/utils/utils.ts | 32 ++-- .../components/TicketFilterListComponent.vue | 10 +- .../TicketList/components/ToggleComponent.vue | 8 +- .../Normalizer/MotiveNormalizer.php | 103 ++++++++++++ .../Normalizer/TicketNormalizer.php | 12 +- .../src/migrations/Version20250924124214.php | 37 +++++ .../tests/Entity/MotiveTest.php | 63 +++++++ .../Normalizer/MotiveNormalizerTest.php | 154 ++++++++++++++++++ 25 files changed, 724 insertions(+), 136 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php create mode 100644 src/Bundle/ChillTicketBundle/src/migrations/Version20250924124214.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Entity/MotiveTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index e2eea460b..f0255fb19 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -112,7 +112,7 @@ paths: - no - name: byMotives in: query - description: the motives of the ticket + description: the motives of the ticket. All the descendants of the motive are taken into account. required: false style: form explode: false diff --git a/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php index d57b43e7b..1b9ce263c 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php @@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Controller; use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer; use Doctrine\ORM\QueryBuilder; use Symfony\Component\HttpFoundation\Request; @@ -21,13 +22,13 @@ final class MotiveApiController extends ApiController protected function customizeQuery(string $action, Request $request, $query): void { /* @var $query QueryBuilder */ - $query->andWhere('e.active = TRUE'); + $query->andWhere('e.active = TRUE')->andWhere('e.parent IS NULL'); } protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array { return match ($request->getMethod()) { - Request::METHOD_GET => ['groups' => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]], + Request::METHOD_GET => ['groups' => ['read', 'read:extended', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY, MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]], default => parent::getContextForSerialization($action, $request, $_format, $entity), }; } diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php index ff65ea016..044949d1e 100644 --- a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php +++ b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php @@ -13,7 +13,6 @@ namespace Chill\TicketBundle\DataFixtures\ORM; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\Motive; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; @@ -35,6 +34,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface ['label' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'], ['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'], ]; + $motivesByLabel = []; foreach (explode("\n", self::MOTIVES) as $row) { if ('' === trim($row)) { @@ -46,50 +46,65 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface continue; } - $motive = new Motive(); - $motive->setLabel(['fr' => trim((string) $data[0])]); - $motive->setMakeTicketEmergency(match ($data[1]) { - 'true' => EmergencyStatusEnum::YES, - 'false' => EmergencyStatusEnum::NO, - default => throw new \UnexpectedValueException('Unexpected value'), - }); + $labels = explode(' > ', $data[0]); + $parent = null; - $numberOfDocs = (int) $data[2]; - for ($i = 1; $i <= $numberOfDocs; ++$i) { - $doc = $docs[$i - 1]; - $storedObject = new StoredObject(); - $storedObject->setTitle($doc['label']); + while (count($labels) > 0) { + $label = array_shift($labels); + dump($labels); + if (isset($motivesByLabel[$label])) { + $motive = $motivesByLabel[$label]; + } else { + $motive = new Motive(); + $motive->setLabel(['fr' => $label]); + $motivesByLabel[$label] = $motive; + } - $content = file_get_contents($doc['path']); - $contentType = match (substr($doc['path'], -3, 3)) { - 'pdf' => 'application/pdf', - 'png' => 'image/png', - default => throw new \UnexpectedValueException('Not supported content type here'), - }; - $this->storedObjectManager->write($storedObject, $content, $contentType); + if (null !== $parent) { + $motive->setParent($parent); + } - $motive->addStoredObject($storedObject); - $manager->persist($storedObject); - } + $manager->persist($motive); + $parent = $motive; + + if (0 === count($labels)) { + // this is the last one, we add data + $numberOfDocs = (int) $data[2]; + for ($i = 1; $i <= $numberOfDocs; ++$i) { + $doc = $docs[$i - 1]; + $storedObject = new StoredObject(); + $storedObject->setTitle($doc['label']); + + $content = file_get_contents($doc['path']); + $contentType = match (substr($doc['path'], -3, 3)) { + 'pdf' => 'application/pdf', + 'png' => 'image/png', + default => throw new \UnexpectedValueException('Not supported content type here'), + }; + $this->storedObjectManager->write($storedObject, $content, $contentType); + + $motive->addStoredObject($storedObject); + $manager->persist($storedObject); + } - foreach (array_slice($data, 3) as $supplementaryComment) { - if ('' !== trim((string) $supplementaryComment)) { - $motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]); + foreach (array_slice($data, 3) as $supplementaryComment) { + if ('' !== trim((string) $supplementaryComment)) { + $motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]); + } + } } } - - $manager->persist($motive); } $manager->flush(); } private const MOTIVES = <<<'CSV' - "Coordonnées",false,"3","Nouvelles coordonnées", - "Horaire de passage",false,"0", - "Retard de livraison",false,"0", - "Erreur de livraison",false,"0", - "Colis incomplet",false,"0", + "Motif administratif > Coordonnées",false,"3","Nouvelles coordonnées", + "Organisation > Horaire de passage",false,"0", + "Organisation > Livraison > Retard de livraison",false,"0", + "Organisation > Livraison > Erreur de livraison",false,"0", + "Organisation > Livraison > Colis incomplet",false,"0", "MATLOC",false,"0", "Retard DASRI",false,"1", "Planning d'astreintes",false,"0", @@ -116,7 +131,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface "Mauvaise adresse",false,"0", "Patient absent",false,"0", "Annulation",false,"0", - "Colis perdu",false,"0", + "Organisation > Livraison > Colis perdu",false,"0", "Changement de rendez-vous",false,"0", "Coordination interservices",false,"0", "Problème de substitution produits",true,"0", diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php index 789d6b562..1be0fe47f 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php @@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ReadableCollection; +use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation as Serializer; @@ -26,19 +27,15 @@ class Motive #[ORM\Id] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\GeneratedValue(strategy: 'AUTO')] - #[Serializer\Groups(['read'])] private ?int $id = null; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] - #[Serializer\Groups(['read'])] private array $label = []; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] - #[Serializer\Groups(['read'])] private bool $active = true; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)] - #[Serializer\Groups(['read'])] private ?EmergencyStatusEnum $makeTicketEmergency = null; /** @@ -49,12 +46,22 @@ class Motive private Collection $storedObjects; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])] - #[Serializer\Groups(['read'])] private array $supplementaryComments = []; + #[ORM\ManyToOne(targetEntity: Motive::class, inversedBy: 'children')] + private ?Motive $parent = null; + + + /** + * @var Collection&Selectable + */ + #[ORM\OneToMany(targetEntity: Motive::class, mappedBy: 'parent')] + private Collection&Selectable $children; + public function __construct() { $this->storedObjects = new ArrayCollection(); + $this->children = new ArrayCollection(); } public function addStoredObject(StoredObject $storedObject): void @@ -69,7 +76,6 @@ class Motive $this->storedObjects->removeElement($storedObject); } - #[Serializer\Groups(['read'])] public function getStoredObjects(): ReadableCollection { return $this->storedObjects; @@ -142,4 +148,74 @@ class Motive $this->supplementaryComments[$key] = $supplementaryComment; } } + + public function isParent(): bool + { + return $this->children->count() > 0; + } + + public function isChild(): bool + { + return null !== $this->parent; + } + + public function setParent(?Motive $parent): void + { + if (null !== $parent) { + $parent->addChild($this); + } else { + $this->parent->removeChild($this); + } + + $this->parent = $parent; + } + + /** + * @internal use @see{setParent} instead + */ + public function addChild(Motive $child): void + { + if (!$this->children->contains($child)) { + $this->children->add($child); + } + } + + /** + * @internal use @see{setParent} with null as argument instead + */ + public function removeChild(Motive $child): void + { + $this->children->removeElement($child); + } + + public function getChildren(): ReadableCollection&Selectable + { + return $this->children; + } + + public function getParent(): ?Motive + { + return $this->parent; + } + + /** + * Get the descendants of the current entity. + * + * This method collects all descendant entities recursively, starting from the current entity + * and including all of its children and their descendants. + * + * @return ReadableCollection&Selectable A collection containing the current entity and all its descendants + */ + public function getDescendants(): ReadableCollection&Selectable + { + $collection = new ArrayCollection([$this]); + + foreach ($this->getChildren() as $child) { + foreach ($child->getDescendants() as $descendant) { + $collection->add($descendant); + } + } + + return $collection; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php index e2902aae3..e3de710be 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php @@ -42,7 +42,7 @@ class MotiveHistory implements TrackCreationInterface public function __construct( #[ORM\ManyToOne(targetEntity: Motive::class)] #[ORM\JoinColumn(nullable: false)] - #[Serializer\Groups(['read'])] + #[Serializer\Groups(['read', 'read:children-to-parent'])] private Motive $motive, #[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\JoinColumn(nullable: false)] diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php index 222199666..ad8fa1387 100644 --- a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php @@ -113,10 +113,11 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor if (array_key_exists('byMotives', $params)) { $byMotives = $qb->expr()->orX(); foreach ($params['byMotives'] as $motive) { + $motivesWithDescendants = $motive->getDescendants()->toArray(); $byMotives->add( $qb->expr()->exists(sprintf( 'SELECT 1 FROM %s tp_motive_%d WHERE tp_motive_%d.ticket = t - AND tp_motive_%d.motive = :motive_%d AND tp_motive_%d.endDate IS NULL + AND tp_motive_%d.motive IN (:motives_%d) AND tp_motive_%d.endDate IS NULL ', MotiveHistory::class, ++$i, @@ -126,7 +127,7 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor $i, )) ); - $qb->setParameter(sprintf('motive_%d', $i), $motive); + $qb->setParameter(sprintf('motives_%d', $i), $motivesWithDescendants); } $qb->andWhere($byMotives); } diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index 4c31f2341..1c914570e 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -8,14 +8,32 @@ import { Person } from "ChillPersonAssets/types"; import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types"; import { StoredObject } from "ChillDocStoreAssets/types"; -export interface Motive { +interface MotiveBase { type: "ticket_motive"; id: number; active: boolean; label: TranslatableString; +} + +/** + * Represent a motive with basic information and parent motive. + * + * Match the "read" and "read:children-to-parent" serializer groups. + */ +export interface MotiveWithParent extends MotiveBase { + parent: MotiveWithParent | null; +} + +/** + * Represents a motive for a ticket, including details like emergency status, stored objects, and supplementary comments. + * + * Match the "read:extended" serializer group in MotiveNormalizer. + */ +export interface Motive extends MotiveBase { makeTicketEmergency: TicketEmergencyState; storedObjects: StoredObject[]; supplementaryComments: { label: string }[]; + children: Motive[]; } export type TicketState = "open" | "closed" | "close"; @@ -45,7 +63,7 @@ export interface MotiveHistory { id: number; startDate: null; endDate: null | DateTime; - motive: Motive; + motive: MotiveWithParent; createdBy: User | null; createdAt: DateTime | null; } @@ -140,7 +158,7 @@ interface BaseTicket< createdAt: DateTime | null; currentAddressees: UserGroupOrUser[]; currentPersons: Person[]; - currentMotive: null | Motive; + currentMotive: MotiveWithParent | null; currentState: TicketState | null; emergency: TicketEmergencyState | null; caller: Person | Thirdparty | null; @@ -185,7 +203,7 @@ export interface TicketFilterParams { export interface TicketInitForm { content: string; - motive?: Motive; + motive?: MotiveWithParent | null; addressees: UserGroupOrUser[]; persons: Person[]; caller: Person | null; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue index 6c437f0b7..69569f304 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue @@ -179,7 +179,7 @@ import { UserGroup, UserGroupOrUser, } from "../../../../../../../ChillMainBundle/Resources/public/types"; -import { Comment, Motive, Ticket } from "../../../types"; +import { Comment, Motive, MotiveWithParent, Ticket } from "../../../types"; import { Person } from "ChillPersonAssets/types"; const store = useStore(); @@ -252,7 +252,7 @@ const returnPath = computed((): string => { return returnPath; }); -const motive = ref(ticket.value.currentMotive as Motive); +const motive = ref(ticket.value.currentMotive as MotiveWithParent | null); const content = ref("" as Comment["content"]); const addressees = ref(ticket.value.currentAddressees as UserGroupOrUser[]); const persons = ref(ticket.value.currentPersons as Person[]); diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue index 4b7447132..edd9503c9 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue @@ -7,7 +7,7 @@ class="badge-user-group" :style="`background-color: ${addressee.backgroundColor}; color: ${addressee.foregroundColor};`" > - {{ addressee.label.fr }} + {{ localizeString(addressee.label) }} (); diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue index c874142b2..0b0c5131a 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue @@ -6,7 +6,9 @@

{{ getTicketTitle(ticket) }}

@@ -91,7 +93,7 @@ diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue index 58edb7c19..6ea2c67b2 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue @@ -1,24 +1,32 @@ diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue index b1f6315d1..de094743a 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue @@ -5,22 +5,50 @@ + @remove="(value: Motive) => $emit('remove', value)" + > + +
- +
@@ -28,11 +56,11 @@