diff --git a/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php b/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php
new file mode 100644
index 000000000..02808960e
--- /dev/null
+++ b/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php
@@ -0,0 +1,50 @@
+setDescription('Import ticket motives from a directory containing motives.yaml and referenced files')
+ ->addArgument('directory', InputArgument::REQUIRED, 'The directory path containing motives.yaml')
+ ->addArgument('lang', InputArgument::REQUIRED, 'The language key to use for matching labels (e.g., fr)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $directory = (string) $input->getArgument('directory');
+ $lang = (string) $input->getArgument('lang');
+
+ $this->importMotivesFromDirectory->import($directory, $lang);
+
+ $output->writeln('Ticket motives import completed successfully.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php
index c2d8cc6c9..380ea3d02 100644
--- a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php
+++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php
@@ -132,4 +132,14 @@ class Motive
{
$this->supplementaryComments[] = $supplementaryComments;
}
+
+ public function setSupplementaryComment(array $supplementaryComments): void
+ {
+ foreach ($this->supplementaryComments as $key => $supplementaryComment) {
+ unset($this->supplementaryComments[$key]);
+ }
+ foreach (array_values($supplementaryComments) as $key => $supplementaryComment) {
+ $this->supplementaryComments[$key] = $supplementaryComment;
+ }
+ }
}
diff --git a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php
index 7863305fe..234a7fd70 100644
--- a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php
+++ b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php
@@ -38,4 +38,18 @@ class MotiveRepository extends ServiceEntityRepository implements AssociatedEnti
return $query->getQuery()->getOneOrNullResult();
}
+
+ /**
+ * @return list
+ */
+ public function findByLabel(string $label, string $lang): array
+ {
+ $query = $this->createQueryBuilder('m');
+ $query->select('m')
+ ->where($query->expr()->like('JSON_EXTRACT(m.label, :lang)', ':label'))
+ ->setParameter('label', $label)
+ ->setParameter('lang', $lang);
+
+ return $query->getQuery()->getResult();
+ }
}
diff --git a/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php
new file mode 100644
index 000000000..946a25922
--- /dev/null
+++ b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php
@@ -0,0 +1,136 @@
+ $item) {
+ if (!\is_array($item)) {
+ throw new \RuntimeException(sprintf('Item at index %d is not an object/array.', $index));
+ }
+
+ // label: set as-is (expects an array)
+ if (!array_key_exists('label', $item) || !\is_array($item['label'])) {
+ throw new \RuntimeException(sprintf('Item %d: missing or invalid "label" (expected array).', $index));
+ }
+ $labelArray = $item['label'];
+ $labelForSearch = $labelArray[$lang] ?? null;
+ if (!\is_string($labelForSearch) || '' === trim($labelForSearch)) {
+ throw new \RuntimeException(sprintf('Item %d: missing label for language "%s".', $index, $lang));
+ }
+
+ $existing = $this->motiveRepository->findByLabel($labelForSearch, $lang);
+ if (\count($existing) > 1) {
+ throw new \RuntimeException(sprintf('Item %d: multiple motives found with label "%s" for lang "%s".', $index, $labelForSearch, $lang));
+ }
+ $motive = $existing[0] ?? new Motive();
+
+ $motive->setLabel($labelArray);
+
+ // urgent: if true => YES, if explicitly false => NO, if absent => leave null
+ if (array_key_exists('urgent', $item)) {
+ $urgent = (bool) $item['urgent'];
+ $motive->setMakeTicketEmergency($urgent ? EmergencyStatusEnum::YES : EmergencyStatusEnum::NO);
+ }
+
+ // supplementary informations (support both keys with/without underscore)
+ $suppKey = array_key_exists('supplementary_informations', $item) ? 'supplementary_informations' : (array_key_exists('supplementary informations', $item) ? 'supplementary informations' : null);
+ if ($suppKey && \is_array($item[$suppKey])) {
+ $motive->setSupplementaryComment(array_map(fn (array $supplementaryDefinition) => ['label' => $supplementaryDefinition['label'][$lang]], $item[$suppKey]));
+ }
+
+ // stored objects
+ $storedKey = array_key_exists('stored_objects', $item) ? 'stored_objects' : (array_key_exists('stored object', $item) ? 'stored object' : null);
+ if ($storedKey && \is_array($item[$storedKey])) {
+ foreach ($item[$storedKey] as $docIndex => $doc) {
+ if (!\is_array($doc)) {
+ throw new \RuntimeException(sprintf('Item %d, stored object %d: invalid entry.', $index, $docIndex));
+ }
+ $label = $doc['label'][$lang] ?? ($doc['label'] ?? null);
+ $filename = $doc['filename'] ?? null;
+ if (null === $filename || !\is_string($filename)) {
+ throw new \RuntimeException(sprintf('Item %d, stored object %d: missing filename.', $index, $docIndex));
+ }
+
+ $fullPath = $directory.DIRECTORY_SEPARATOR.$filename;
+ if (!is_file($fullPath)) {
+ throw new \RuntimeException(sprintf('Referenced file not found: %s', $fullPath));
+ }
+
+ $storedObject = new StoredObject();
+ if (\is_string($label)) {
+ $storedObject->setTitle($label);
+ } elseif (\is_array($label) && isset($label['fr']) && \is_string($label['fr'])) {
+ $storedObject->setTitle($label['fr']);
+ } else {
+ $storedObject->setTitle(pathinfo($filename, PATHINFO_FILENAME));
+ }
+
+ $content = file_get_contents($fullPath);
+ if (false === $content) {
+ throw new \RuntimeException(sprintf('Unable to read file: %s', $fullPath));
+ }
+
+ $ext = strtolower((string) pathinfo($fullPath, PATHINFO_EXTENSION));
+ $contentType = match ($ext) {
+ 'pdf' => 'application/pdf',
+ 'png' => 'image/png',
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'webp' => 'image/webp',
+ 'txt' => 'text/plain',
+ default => 'application/octet-stream',
+ };
+
+ $this->storedObjectManager->write($storedObject, $content, $contentType);
+
+ $motive->addStoredObject($storedObject);
+ $this->entityManager->persist($storedObject);
+ }
+ }
+
+ $this->entityManager->persist($motive);
+ }
+
+ $this->entityManager->flush();
+ }
+}
diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml
index fb0945fe6..1089b574e 100644
--- a/src/Bundle/ChillTicketBundle/src/config/services.yaml
+++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml
@@ -9,6 +9,9 @@ services:
Chill\TicketBundle\Action\Comment\Handler\:
resource: '../Action/Comment/Handler/'
+ Chill\TicketBundle\Command\:
+ resource: '../Command/'
+
Chill\TicketBundle\Controller\:
resource: '../Controller/'
tags:
diff --git a/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php
new file mode 100644
index 000000000..0553901b0
--- /dev/null
+++ b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php
@@ -0,0 +1,95 @@
+assertTrue(mkdir($tmpBase, 0777, true));
+
+ $filePath = $tmpBase.DIRECTORY_SEPARATOR.'file.txt';
+ file_put_contents($filePath, 'hello world');
+
+ $yaml = <<<'YAML'
+- label:
+ fr: "Test Motive"
+ urgent: true
+ supplementary_informations:
+ - label:
+ fr: "Some note"
+ stored_objects:
+ - label:
+ fr: "Doc title"
+ filename: "file.txt"
+YAML;
+ file_put_contents($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml', $yaml);
+
+ // 2) Create mocks (Prophecy) for dependencies
+ $emProphecy = $this->prophesize(EntityManagerInterface::class);
+ // persist should be called for StoredObject and Motive at least once
+ $emProphecy->persist(Argument::type(StoredObject::class))->shouldBeCalled();
+ $emProphecy->persist(Argument::that(fn($arg) =>
+ // Motive class is in another namespace; just check it's an object with setLabel method
+ \is_object($arg) && method_exists($arg, 'setLabel')))->shouldBeCalled();
+ $emProphecy->flush()->shouldBeCalled();
+
+ $somMock = $this->createMock(StoredObjectManagerInterface::class);
+ $somMock
+ ->expects($this->once())
+ ->method('write')
+ ->willReturnCallback(function (StoredObject $so, string $content, ?string $contentType) {
+ $this->assertSame('text/plain', $contentType);
+
+ return new StoredObjectVersion($so, 1, [], [], $contentType ?? 'application/octet-stream', 'file.txt');
+ });
+
+ $repoProphecy = $this->prophesize(MotiveRepository::class);
+ $repoProphecy->findByLabel('Test Motive', 'fr')->willReturn([])->shouldBeCalled();
+
+ // 3) Run the importer
+ $importer = new ImportMotivesFromDirectory(
+ $emProphecy->reveal(),
+ $somMock,
+ $repoProphecy->reveal(),
+ );
+
+ $importer->import($tmpBase, 'fr');
+
+ // 4) Cleanup
+ @unlink($filePath);
+ @unlink($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml');
+ @rmdir($tmpBase);
+
+ // If we reached here, it's a successful smoke test
+ $this->assertTrue(true);
+ }
+}