From 030553a4de2a32d0ef484748c37499735b3ce49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 23 Feb 2026 20:05:04 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Resolve=20"Lors=20de=20l'import=20de=20code?= =?UTF-8?q?=20postaux,=20les=20codes=20absents=20de=20l'import=20depuis=20?= =?UTF-8?q?la=20m=C3=AAme=20source=20ne=20sont=20pas=20supprim=C3=A9s"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unreleased/Fixed-20260223-182212.yaml | 7 ++ .../ChillMainBundle/Entity/PostalCode.php | 10 ++ .../Service/Import/PostalCodeBaseImporter.php | 104 ++++++++++++++++-- .../Import/PostalCodeBaseImporterTest.php | 76 +++++++++++++ 4 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 .changes/unreleased/Fixed-20260223-182212.yaml diff --git a/.changes/unreleased/Fixed-20260223-182212.yaml b/.changes/unreleased/Fixed-20260223-182212.yaml new file mode 100644 index 000000000..ec6a4423c --- /dev/null +++ b/.changes/unreleased/Fixed-20260223-182212.yaml @@ -0,0 +1,7 @@ +kind: Fixed +body: 'Fix import of postal code: mark postal code as deleted if they are not present in the import any more' +time: 2026-02-23T18:22:12.92214987+01:00 +custom: + Issue: "502" + MR: "968" + SchemaChange: No schema change diff --git a/src/Bundle/ChillMainBundle/Entity/PostalCode.php b/src/Bundle/ChillMainBundle/Entity/PostalCode.php index ecab19a5f..d6ffe763f 100644 --- a/src/Bundle/ChillMainBundle/Entity/PostalCode.php +++ b/src/Bundle/ChillMainBundle/Entity/PostalCode.php @@ -215,4 +215,14 @@ class PostalCode implements TrackUpdateInterface, TrackCreationInterface return $this; } + + public function isDeleted(): bool + { + return null !== $this->deletedAt; + } + + public function getDeletedAt(): ?\DateTimeImmutable + { + return $this->deletedAt; + } } diff --git a/src/Bundle/ChillMainBundle/Service/Import/PostalCodeBaseImporter.php b/src/Bundle/ChillMainBundle/Service/Import/PostalCodeBaseImporter.php index b1ace3875..a10579b23 100644 --- a/src/Bundle/ChillMainBundle/Service/Import/PostalCodeBaseImporter.php +++ b/src/Bundle/ChillMainBundle/Service/Import/PostalCodeBaseImporter.php @@ -19,31 +19,66 @@ use Doctrine\DBAL\Statement; */ class PostalCodeBaseImporter { - private const QUERY = <<<'SQL' + private const CREATE_TEMP_TABLE = <<<'SQL' + CREATE TEMPORARY TABLE chill_main_postal_code_temp ( + countrycode VARCHAR(10), + label VARCHAR(255), + code VARCHAR(100), + refpostalcodeid VARCHAR(255), + postalcodeSource VARCHAR(255), + lon FLOAT, + lat FLOAT, + srid INT + ) + SQL; + + private const INSERT_TEMP = <<<'SQL' + INSERT INTO chill_main_postal_code_temp + (countrycode, label, code, refpostalcodeid, postalcodeSource, lon, lat, srid) + VALUES + {{ values }} + SQL; + + private const UPSERT = <<<'SQL' WITH g AS ( SELECT DISTINCT country.id AS country_id, - g.* - FROM (VALUES - {{ values }} - ) AS g (countrycode, label, code, refpostalcodeid, postalcodeSource, lon, lat, srid) - JOIN country ON country.countrycode = g.countrycode + temp.* + FROM chill_main_postal_code_temp temp + JOIN country ON country.countrycode = temp.countrycode ) - INSERT INTO chill_main_postal_code (id, country_id, label, code, origin, refpostalcodeid, postalcodeSource, center, createdAt, updatedAt) + INSERT INTO chill_main_postal_code (id, country_id, label, code, origin, refpostalcodeid, postalcodeSource, center, createdAt, updatedAt, deletedAt) SELECT nextval('chill_main_postal_code_id_seq'), g.country_id, - g.label AS glabel, + g.label, g.code, 0, g.refpostalcodeid, g.postalcodeSource, - CASE WHEN (g.lon::float != 0.0 AND g.lat::float != 0.0) THEN ST_Transform(ST_setSrid(ST_point(g.lon::float, g.lat::float), g.srid::int), 4326) ELSE NULL END, + CASE WHEN (g.lon != 0.0 AND g.lat != 0.0) THEN ST_Transform(ST_setSrid(ST_point(g.lon, g.lat), g.srid), 4326) ELSE NULL END, NOW(), - NOW() + NOW(), + NULL FROM g ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE - SET label = excluded.label, center = excluded.center, updatedAt = CASE WHEN NOT st_equals(excluded.center, chill_main_postal_code.center) OR excluded.label != chill_main_postal_code.label THEN NOW() ELSE chill_main_postal_code.updatedAt END + SET label = excluded.label, + center = excluded.center, + deletedAt = NULL, + updatedAt = CASE WHEN NOT st_equals(excluded.center, chill_main_postal_code.center) OR excluded.label != chill_main_postal_code.label OR chill_main_postal_code.deletedAt IS NOT NULL THEN NOW() ELSE chill_main_postal_code.updatedAt END + SQL; + + private const DELETE_MISSING = <<<'SQL' + UPDATE chill_main_postal_code + SET deletedAt = NOW(), updatedAt = NOW() + WHERE postalcodeSource = ? + AND deletedAt IS NULL + AND NOT EXISTS ( + SELECT 1 FROM chill_main_postal_code_temp temp + WHERE temp.code = chill_main_postal_code.code + AND temp.refpostalcodeid = chill_main_postal_code.refpostalcodeid + AND temp.postalcodeSource = chill_main_postal_code.postalcodeSource + ) SQL; private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)'; @@ -55,11 +90,26 @@ class PostalCodeBaseImporter private array $waitingForInsert = []; + private bool $isInitialized = false; + + private ?string $currentSource = null; + public function __construct(private readonly Connection $defaultConnection) {} public function finalize(): void { $this->doInsertPending(); + + if ($this->isInitialized && null !== $this->currentSource) { + $this->defaultConnection->transactional(function (Connection $connection): void { + $connection->executeStatement(self::UPSERT); + $connection->executeStatement(self::DELETE_MISSING, [$this->currentSource]); + }); + $this->deleteTemporaryTable(); + } + + $this->isInitialized = false; + $this->currentSource = null; } public function importCode( @@ -72,6 +122,14 @@ class PostalCodeBaseImporter float $centerLon, int $centerSRID, ): void { + if (!$this->isInitialized) { + $this->initialize($refPostalCodeSource); + } + + if ($this->currentSource !== $refPostalCodeSource) { + throw new \LogicException('Cannot store postal codes from different sources during same import. Execute finalize to commit inserts before changing the source'); + } + $this->waitingForInsert[] = [ $countryCode, $label, @@ -88,10 +146,32 @@ class PostalCodeBaseImporter } } + private function initialize(string $source): void + { + $this->currentSource = $source; + $this->deleteTemporaryTable(); + $this->createTemporaryTable(); + $this->isInitialized = true; + } + + private function createTemporaryTable(): void + { + $this->defaultConnection->executeStatement(self::CREATE_TEMP_TABLE); + } + + private function deleteTemporaryTable(): void + { + $this->defaultConnection->executeStatement('DROP TABLE IF EXISTS chill_main_postal_code_temp'); + } + private function doInsertPending(): void { + if ([] == $this->waitingForInsert) { + return; + } + if (!\array_key_exists($forNumber = \count($this->waitingForInsert), $this->cachingStatements)) { - $sql = strtr(self::QUERY, [ + $sql = strtr(self::INSERT_TEMP, [ '{{ values }}' => implode( ', ', array_fill(0, $forNumber, self::VALUE) diff --git a/src/Bundle/ChillMainBundle/Tests/Services/Import/PostalCodeBaseImporterTest.php b/src/Bundle/ChillMainBundle/Tests/Services/Import/PostalCodeBaseImporterTest.php index d7447e7a0..eb313ba0d 100644 --- a/src/Bundle/ChillMainBundle/Tests/Services/Import/PostalCodeBaseImporterTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Services/Import/PostalCodeBaseImporterTest.php @@ -93,4 +93,80 @@ final class PostalCodeBaseImporterTest extends KernelTestCase $this->assertStringStartsWith('tested with adapted pattern', $postalCodes[0]->getName()); $this->assertEquals($previousId, $postalCodes[0]->getId()); } + + public function testPostalCodeRemoval(): void + { + $source = 'removal_test_'.uniqid(); + $refId1 = 'ref1_'.uniqid(); + $refId2 = 'ref2_'.uniqid(); + + // 1. Import two postal codes + $this->importer->importCode('BE', 'Label 1', '1000', $refId1, $source, 50.0, 5.0, 4326); + $this->importer->importCode('BE', 'Label 2', '2000', $refId2, $source, 50.0, 5.0, 4326); + $this->importer->finalize(); + + $pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source]); + $pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source]); + + $this->assertNotNull($pc1); + $this->assertNotNull($pc2); + + // 2. Import only the first one + $this->importer->importCode('BE', 'Label 1 updated', '1000', $refId1, $source, 50.0, 5.0, 4326); + $this->importer->finalize(); + + $this->entityManager->clear(); + + $pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source]); + $pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source]); + + $this->assertNotNull($pc1); + $this->assertEquals('Label 1 updated', $pc1->getName()); + + $this->assertFalse($pc1->isDeleted(), 'pc1 should NOT be marked as deleted'); + + // pc2 should be marked as deleted. Note: findOneBy might still find it if it doesn't filter by deletedAt + $this->assertNotNull($pc2); + + $this->assertTrue($pc2->isDeleted(), 'Postal code should be marked as deleted (deletedAt is not null)'); + + // 3. Reactivate pc2 by re-importing it + $this->importer->importCode('BE', 'Label 2 restored', '2000', $refId2, $source, 50.0, 5.0, 4326); + $this->importer->finalize(); + + $this->entityManager->clear(); + $pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source]); + $this->assertFalse($pc2->isDeleted(), 'Postal code should NOT be marked as deleted after restoration'); + $this->assertEquals('Label 2 restored', $pc2->getName()); + } + + public function testNoInterferenceBetweenSources(): void + { + $source1 = 'source1_'.uniqid(); + $source2 = 'source2_'.uniqid(); + $refId1 = 'ref1_'.uniqid(); + $refId2 = 'ref2_'.uniqid(); + + // 1. Import from source1 + $this->importer->importCode('BE', 'Label 1', '1000', $refId1, $source1, 50.0, 5.0, 4326); + $this->importer->finalize(); + + $pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source1]); + $this->assertNotNull($pc1); + $this->assertFalse($pc1->isDeleted()); + + // 2. Import from source2 + $this->importer->importCode('BE', 'Label 2', '2000', $refId2, $source2, 50.0, 5.0, 4326); + $this->importer->finalize(); + + $this->entityManager->clear(); + + $pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source1]); + $pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source2]); + + $this->assertNotNull($pc1); + $this->assertNotNull($pc2); + $this->assertFalse($pc1->isDeleted(), 'pc1 from source1 should NOT be deleted after import from source2'); + $this->assertFalse($pc2->isDeleted(), 'pc2 from source2 should NOT be deleted'); + } } From 5de3862ec262af10ce3098120adae0174827cb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 9 Mar 2026 09:25:08 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Resolve=20"Lors=20de=20la=20r=C3=A9-assigna?= =?UTF-8?q?tion=20des=20parcours,=20l'UI=20ne=20mentionne=20pas=20qu'une?= =?UTF-8?q?=20op=C3=A9ration=20a=20=C3=A9t=C3=A9=20r=C3=A9alis=C3=A9e"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unreleased/Fixed-20260309-101721.yaml | 7 +++++++ .../ReassignAccompanyingPeriodController.php | 19 +++++++++++++++---- .../translations/messages+intl-icu.fr.yaml | 8 ++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 .changes/unreleased/Fixed-20260309-101721.yaml diff --git a/.changes/unreleased/Fixed-20260309-101721.yaml b/.changes/unreleased/Fixed-20260309-101721.yaml new file mode 100644 index 000000000..5e369b184 --- /dev/null +++ b/.changes/unreleased/Fixed-20260309-101721.yaml @@ -0,0 +1,7 @@ +kind: Fixed +body: Add a flash message when reassigning accompanying course (reassign list) +time: 2026-03-09T10:17:21.923487588+01:00 +custom: + Issue: "503" + MR: "969" + SchemaChange: No schema change diff --git a/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php b/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php index 6e3fa5117..f44470f45 100644 --- a/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php +++ b/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php @@ -17,7 +17,6 @@ use Chill\MainBundle\Form\Type\PickPostalCodeType; use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\UserRepository; -use Chill\MainBundle\Templating\Entity\UserRender; use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepositoryInterface; use Chill\PersonBundle\Repository\AccompanyingPeriodRepository; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; @@ -31,18 +30,29 @@ use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; +use Symfony\Component\Translation\TranslatableMessage; use Symfony\Component\Validator\Constraints\NotIdenticalTo; use Symfony\Component\Validator\Constraints\NotNull; class ReassignAccompanyingPeriodController extends AbstractController { - public function __construct(private readonly AccompanyingPeriodACLAwareRepositoryInterface $accompanyingPeriodACLAwareRepository, private readonly UserRepository $userRepository, private readonly AccompanyingPeriodRepository $courseRepository, private readonly \Twig\Environment $engine, private readonly FormFactoryInterface $formFactory, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly UserRender $userRender, private readonly EntityManagerInterface $em) {} + public function __construct( + private readonly AccompanyingPeriodACLAwareRepositoryInterface $accompanyingPeriodACLAwareRepository, + private readonly UserRepository $userRepository, + private readonly AccompanyingPeriodRepository $courseRepository, + private readonly \Twig\Environment $engine, + private readonly FormFactoryInterface $formFactory, + private readonly PaginatorFactory $paginatorFactory, + private readonly Security $security, + private readonly EntityManagerInterface $entityManager, + ) {} #[Route(path: '/{_locale}/person/accompanying-periods/reassign', name: 'chill_course_list_reassign')] - public function listAction(Request $request): Response + public function listAction(Request $request, Session $session): Response { if (!$this->security->isGranted(AccompanyingPeriodVoter::REASSIGN_BULK)) { throw new AccessDeniedHttpException('no right to reassign bulk'); @@ -96,7 +106,8 @@ class ReassignAccompanyingPeriodController extends AbstractController } } - $this->em->flush(); + $this->entityManager->flush(); + $this->addFlash('success', new TranslatableMessage('period_by_user_list.successfully_re_assigned', ['count' => count($assignPeriodIds)])); // redirect to the first page return $this->redirectToRoute('chill_course_list_reassign', $request->query->all()); diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index 201157214..dc5f2e992 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -24,6 +24,14 @@ accompanying_period: number: >- n° {id} +period_by_user_list: + successfully_re_assigned: >- + {count, plural, + =0 {Aucune assignation de référent effectuée} + =1 {Assignation d'un nouveau référent pour un parcours} + other {Assignation d'un nouveau référent pour # parcours} + } + person: from_the: depuis le And himself: >- From 562fecb4aa728b0317f902b4e895220ef37e1468 Mon Sep 17 00:00:00 2001 From: LenaertsJ Date: Mon, 9 Mar 2026 11:19:38 +0000 Subject: [PATCH 3/4] Resolve "Create a filter/aggregator by user center for the exports" --- .../unreleased/Feature-20251230-164303.yaml | 6 + .junie/guidelines.md | 6 +- .../Test/Export/AbstractFilterTest.php | 5 +- .../translations/messages.fr.yml | 4 + .../ReferrerMainCenterAggregator.php | 143 ++++++++++++++++ .../PersonAggregators/CenterAggregator.php | 2 +- .../ReferrerMainCenterFilter.php | 155 ++++++++++++++++++ .../ReferrerMainCenterAggregatorTest.php | 123 ++++++++++++++ .../ReferrerMainCenterFilterTest.php | 69 ++++++++ .../services/exports_accompanying_course.yaml | 8 + .../translations/messages+intl-icu.fr.yaml | 7 + .../translations/messages.fr.yml | 15 +- 12 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 .changes/unreleased/Feature-20251230-164303.yaml create mode 100644 src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregator.php create mode 100644 src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilter.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregatorTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilterTest.php diff --git a/.changes/unreleased/Feature-20251230-164303.yaml b/.changes/unreleased/Feature-20251230-164303.yaml new file mode 100644 index 000000000..f402e60bf --- /dev/null +++ b/.changes/unreleased/Feature-20251230-164303.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Add filter and aggregator based on referrer's main center for exports of accompanying period +time: 2025-12-30T16:43:03.898677616+01:00 +custom: + Issue: "486" + SchemaChange: No schema change diff --git a/.junie/guidelines.md b/.junie/guidelines.md index ff84fdffa..df0efdfbb 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -238,13 +238,13 @@ The tests are run from the project's root (not from the bundle's root). ```bash # Run all tests -vendor/bin/phpunit +symfony composer exec phpunit # Run a specific test file -vendor/bin/phpunit path/to/TestFile.php +symfony composer exec phpunit -- path/to/TestFile.php # Run a specific test method -vendor/bin/phpunit --filter methodName path/to/TestFile.php +symfony composer exec phpunit --filter methodName path/to/TestFile.php ``` When writing tests, only test specific files. Do not run all tests or the full diff --git a/src/Bundle/ChillMainBundle/Test/Export/AbstractFilterTest.php b/src/Bundle/ChillMainBundle/Test/Export/AbstractFilterTest.php index e76d7d504..21a8e78c5 100644 --- a/src/Bundle/ChillMainBundle/Test/Export/AbstractFilterTest.php +++ b/src/Bundle/ChillMainBundle/Test/Export/AbstractFilterTest.php @@ -17,6 +17,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Contracts\Translation\TranslatableInterface; /** * Helper to test filters. @@ -255,8 +256,8 @@ abstract class AbstractFilterTest extends KernelTestCase $description = $this->getFilter()->describeAction($data, $context); $this->assertTrue( - \is_string($description) || \is_array($description), - 'test that the description is a string or an array' + \is_string($description) || \is_array($description) || $description instanceof TranslatableInterface, + 'test that the description is a string or an array, or a TranslatableInterface' ); if (\is_string($description)) { diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 387c16662..9a3f56afd 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -1,3 +1,7 @@ +common: + after: Après + until: Jusqu'à + centers: Territoires "This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License": "Ce programme est un logiciel libre: vous pouvez le redistribuer et/ou le modifier selon les termes de la licence GNU Affero GPL" User manual: Manuel d'utilisation Search: Rechercher diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregator.php new file mode 100644 index 000000000..6079b0a1b --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregator.php @@ -0,0 +1,143 @@ +leftJoin('acp.userHistories', "{$p}_uh", Join::WITH, $qb->expr()->andX( + $qb->expr()->eq("{$p}_uh.accompanyingPeriod", 'acp.id'), + "OVERLAPSI (acp.openingDate, acp.closingDate), ({$p}_uh.startDate, {$p}_uh.endDate) = TRUE", + "OVERLAPSI (:{$p}_startDate, :{$p}_endDate), ({$p}_uh.startDate, {$p}_uh.endDate) = TRUE" + )) + ->leftJoin("{$p}_uh.user", "{$p}_user") + ->addSelect("IDENTITY({$p}_user.mainCenter) AS {$p}_select") + ->addGroupBy("{$p}_select") + ->setParameter("{$p}_startDate", $this->rollingDateConverter->convert($data['start_date'])) + ->setParameter("{$p}_endDate", $this->rollingDateConverter->convert($data['end_date'])); + } + + public function applyOn(): string + { + return Declarations::ACP_TYPE; + } + + public function buildForm(FormBuilderInterface $builder): void + { + $builder + ->add('start_date', PickRollingDateType::class, [ + 'label' => 'common.after', + 'required' => true, + ]) + ->add('end_date', PickRollingDateType::class, [ + 'label' => 'common.until', + 'required' => true, + ]); + } + + public function getNormalizationVersion(): int + { + return 1; + } + + public function normalizeFormData(array $formData): array + { + return [ + 'start_date' => $formData['start_date']->normalize(), + 'end_date' => $formData['end_date']->normalize(), + ]; + } + + public function denormalizeFormData(array $formData, int $fromVersion): array + { + $default = $this->getFormDefaultData(); + + return [ + 'start_date' => array_key_exists('start_date', $formData) ? RollingDate::fromNormalized($formData['start_date']) : $default['start_date'], + 'end_date' => array_key_exists('end_date', $formData) ? RollingDate::fromNormalized($formData['end_date']) : $default['end_date'], + ]; + } + + public function getFormDefaultData(): array + { + return [ + 'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), + 'end_date' => new RollingDate(RollingDate::T_TODAY), + ]; + } + + public function transformData(?array $before): array + { + $default = $this->getFormDefaultData(); + + if (null === $before) { + return $default; + } + + return [ + 'start_date' => $before['start_date'] ?? $before['date_calc'] ?? $default['start_date'], + 'end_date' => $before['end_date'] ?? $before['date_calc'] ?? $default['end_date'], + ]; + } + + public function getLabels($key, array $values, $data): callable + { + return function ($value): string { + if ('_header' === $value) { + return 'person.export.period.aggregator.by_referrer_main_center.column_header'; + } + + if (null === $value || '' === $value) { + return ''; + } + + return (string) $this->centerRepository->find((int) $value)?->getName(); + }; + } + + public function getQueryKeys($data): array + { + return [self::P.'_select']; + } + + public function getTitle(): string + { + return 'person.export.period.aggregator.by_referrer_main_center.title'; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php index bf9901207..a8c4d090e 100644 --- a/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php @@ -64,7 +64,7 @@ final readonly class CenterAggregator implements AggregatorInterface { return function (int|string|null $value) { if (null === $value || '' === $value) { - return $this->translator->trans('person.export.aggregator.by_center.no_center'); + return $this->translator->trans('person.export.period.aggregator.by_center.no_center'); } if ('_header' === $value) { diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilter.php new file mode 100644 index 000000000..d534cfa2e --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilter.php @@ -0,0 +1,155 @@ += :'.self::DATE_PARAM_SINCE.' + ) + ) + AND '.self::UH.'_user.mainCenter IN (:'.self::CENTER_PARAM.')'; + + $qb->andWhere( + $qb->expr()->exists($dql) + ); + $qb + ->setParameter(self::DATE_PARAM_SINCE, $this->rollingDateConverter->convert($data['date_calc_since'])) + ->setParameter(self::DATE_PARAM_UNTIL, $this->rollingDateConverter->convert($data['date_calc_until'])) + ->setParameter(self::CENTER_PARAM, $data['centers']); + } + + public function applyOn(): string + { + return Declarations::ACP_TYPE; + } + + public function buildForm(FormBuilderInterface $builder): void + { + $builder + ->add('centers', EntityType::class, [ + 'class' => Center::class, + 'choices' => $this->centerRepository->findActive(), + 'multiple' => true, + 'expanded' => false, + 'choice_label' => static fn (Center $c) => $c->getName(), + 'required' => true, + 'label' => 'common.centers', + 'attr' => [ + 'class' => 'select2', + ], + ]) + ->add('date_calc_since', PickRollingDateType::class, [ + 'label' => 'person.export.period.filter.by_referrer_main_center.referrer_since', + 'required' => true, + ]) + ->add('date_calc_until', PickRollingDateType::class, [ + 'label' => 'person.export.period.filter.by_referrer_main_center.referrer_until', + 'required' => true, + ]); + } + + public function getNormalizationVersion(): int + { + return 1; + } + + public function normalizeFormData(array $formData): array + { + return [ + 'centers' => array_values(array_map(static fn (Center $c) => $c->getId(), $formData['centers'])), + 'date_calc_since' => $formData['date_calc_since']->normalize(), + 'date_calc_until' => $formData['date_calc_until']->normalize(), + ]; + } + + public function denormalizeFormData(array $formData, int $fromVersion): array + { + return [ + 'centers' => array_values(array_filter(array_map( + fn (int $id) => $this->centerRepository->find($id), + $formData['centers'] ?? [] + ))), + 'date_calc_since' => RollingDate::fromNormalized($formData['date_calc_since']), + 'date_calc_until' => RollingDate::fromNormalized($formData['date_calc_until']), + ]; + } + + public function getFormDefaultData(): array + { + return [ + 'centers' => [], + 'date_calc_since' => new RollingDate(RollingDate::T_TODAY), + 'date_calc_until' => new RollingDate(RollingDate::T_TODAY), + ]; + } + + public function describeAction($data, ExportGenerationContext $context): TranslatableInterface + { + $names = array_map(static fn (Center $c) => $c->getName(), $data['centers']); + + return new TranslatableMessage( + 'person.export.period.filter.by_referrer_main_center.description', + [ + 'centers' => implode(', ', $names), + 'date_since' => $this->rollingDateConverter->convert($data['date_calc_since']), + 'date_until' => $this->rollingDateConverter->convert($data['date_calc_until']), + ] + ); + } + + public function getTitle(): TranslatableInterface + { + return new TranslatableMessage('person.export.period.filter.by_referrer_main_center.title'); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregatorTest.php b/src/Bundle/ChillPersonBundle/Tests/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregatorTest.php new file mode 100644 index 000000000..5141f1899 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Export/Aggregator/AccompanyingCourseAggregators/ReferrerMainCenterAggregatorTest.php @@ -0,0 +1,123 @@ +aggregator = self::getContainer()->get('Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ReferrerMainCenterAggregator'); + } + + /** + * @dataProvider provideBeforeData + */ + public function testDataTransformer(?array $before, array $expected): void + { + $actual = $this->getAggregator()->transformData($before); + + self::assertEqualsCanonicalizing(array_keys($expected), array_keys($actual)); + foreach (['start_date', 'end_date'] as $key) { + self::assertInstanceOf(RollingDate::class, $actual[$key]); + self::assertEquals($expected[$key]->getRoll(), $actual[$key]->getRoll(), "Check that the roll is the same for {$key}"); + } + } + + public static function provideBeforeData(): iterable + { + yield [ + ['date_calc' => new RollingDate(RollingDate::T_TODAY)], + ['start_date' => new RollingDate(RollingDate::T_TODAY), 'end_date' => new RollingDate(RollingDate::T_TODAY)], + ]; + + yield [ + ['start_date' => new RollingDate(RollingDate::T_WEEK_CURRENT_START), 'end_date' => new RollingDate(RollingDate::T_TODAY)], + ['start_date' => new RollingDate(RollingDate::T_WEEK_CURRENT_START), 'end_date' => new RollingDate(RollingDate::T_TODAY)], + ]; + + yield [ + null, + // this is the default configuration + ['start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), 'end_date' => new RollingDate(RollingDate::T_TODAY)], + ]; + } + + public function getAggregator(): ReferrerMainCenterAggregator + { + return $this->aggregator; + } + + public static function getFormData(): array + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $centers = $em->getRepository(Center::class)->findBy([], null, 1); + + return [ + [ + 'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), + 'end_date' => new RollingDate(RollingDate::T_TODAY), + ], + ]; + } + + public static function provideGetResultsAndLabels(): iterable + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $centers = $em->getRepository(Center::class)->findAll(); + + $qb = $em->createQueryBuilder() + ->select('count(acp.id)') + ->from(AccompanyingPeriod::class, 'acp'); + + $data = [ + 'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), + 'end_date' => new RollingDate(RollingDate::T_TODAY), + ]; + + // Yield result with each center ID and null + foreach ($centers as $center) { + yield [$qb, $data, [(string) $center->getId() => 0]]; + } + + yield [$qb, $data, ['' => 0]]; + } + + public static function getQueryBuilders(): iterable + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(acp.id)') + ->from(AccompanyingPeriod::class, 'acp'), + ]; + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilterTest.php b/src/Bundle/ChillPersonBundle/Tests/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilterTest.php new file mode 100644 index 000000000..b1bc27888 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Export/Filter/AccompanyingCourseFilters/ReferrerMainCenterFilterTest.php @@ -0,0 +1,69 @@ +filter = self::getContainer()->get(ReferrerMainCenterFilter::class); + } + + public function getFilter(): ReferrerMainCenterFilter + { + return $this->filter; + } + + public static function getFormData(): array + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $centers = $em->getRepository(Center::class)->findAll(); + + if ([] === $centers) { + throw new \RuntimeException('No centers found in database'); + } + + return [ + [ + 'centers' => [$centers[0]], + 'date_calc_since' => new RollingDate(RollingDate::T_TODAY), + 'date_calc_until' => new RollingDate(RollingDate::T_TODAY), + ], + ]; + } + + public static function getQueryBuilders(): iterable + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + yield $em->createQueryBuilder() + ->from(AccompanyingPeriod::class, 'acp') + ->select('acp.id'); + } +} diff --git a/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml b/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml index d488df8d6..b7ab71d40 100644 --- a/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml @@ -104,6 +104,10 @@ services: tags: - { name: chill.export_filter, alias: accompanyingcourse_referrer_filter_between_dates } + Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\ReferrerMainCenterFilter: + tags: + - { name: chill.export_filter, alias: accompanyingcourse_referrer_main_center_filter } + chill.person.export.filter_openbetweendates: class: Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\OpenBetweenDatesFilter tags: @@ -270,3 +274,7 @@ services: Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\PersonParticipatingAggregator: tags: - { name: chill.export_aggregator, alias: accompanyingcourse_person_part_aggregator } + + Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ReferrerMainCenterAggregator: + tags: + - { name: chill.export_aggregator, alias: accompanyingcourse_referrer_main_center_aggregator } diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index dc5f2e992..5a3d3d5a3 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -41,6 +41,13 @@ person: neutral {et lui·elle-même} other {et lui·elle-même} } + export: + period: + filter: + by_referrer_main_center: + description: >- + Filtre les parcours par territoire du référent, entre le {date_since, date, medium} et le {date_until, date, medium}, uniquement {centers} + household: Household: Ménage diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index ad97767d2..a2a2fb048 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -105,9 +105,18 @@ Administrative status: Situation administrative person: # trans key according to new conventions export: - aggregator: - by_center: - no_center: Sans territoire + period: + aggregator: + by_center: + no_center: Sans territoire + by_referrer_main_center: + title: Grouper les parcours par territoire du référent + column_header: Territoire du référent + filter: + by_referrer_main_center: + title: Filtrer les parcours par territoire du référent + referrer_since: Référent depuis le + referrer_until: Référent avant le Identifiers: Identifiants From e2dec285770532b1ae504cdb9513b75e41042d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 9 Mar 2026 12:27:00 +0100 Subject: [PATCH 4/4] Release v4.14.0 - Implemented `ReferrerMainCenterAggregatorTest` to validate data transformation and query logic. - Added data providers and query builders to ensure comprehensive test coverage. - Verified correct handling of rolling dates and aggregator logic. --- .changes/unreleased/Feature-20251230-164303.yaml | 6 ------ .changes/unreleased/Fixed-20260223-182212.yaml | 7 ------- .changes/unreleased/Fixed-20260309-101721.yaml | 7 ------- .changes/v4.14.0.md | 6 ++++++ CHANGELOG.md | 7 +++++++ 5 files changed, 13 insertions(+), 20 deletions(-) delete mode 100644 .changes/unreleased/Feature-20251230-164303.yaml delete mode 100644 .changes/unreleased/Fixed-20260223-182212.yaml delete mode 100644 .changes/unreleased/Fixed-20260309-101721.yaml create mode 100644 .changes/v4.14.0.md diff --git a/.changes/unreleased/Feature-20251230-164303.yaml b/.changes/unreleased/Feature-20251230-164303.yaml deleted file mode 100644 index f402e60bf..000000000 --- a/.changes/unreleased/Feature-20251230-164303.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: Feature -body: Add filter and aggregator based on referrer's main center for exports of accompanying period -time: 2025-12-30T16:43:03.898677616+01:00 -custom: - Issue: "486" - SchemaChange: No schema change diff --git a/.changes/unreleased/Fixed-20260223-182212.yaml b/.changes/unreleased/Fixed-20260223-182212.yaml deleted file mode 100644 index ec6a4423c..000000000 --- a/.changes/unreleased/Fixed-20260223-182212.yaml +++ /dev/null @@ -1,7 +0,0 @@ -kind: Fixed -body: 'Fix import of postal code: mark postal code as deleted if they are not present in the import any more' -time: 2026-02-23T18:22:12.92214987+01:00 -custom: - Issue: "502" - MR: "968" - SchemaChange: No schema change diff --git a/.changes/unreleased/Fixed-20260309-101721.yaml b/.changes/unreleased/Fixed-20260309-101721.yaml deleted file mode 100644 index 5e369b184..000000000 --- a/.changes/unreleased/Fixed-20260309-101721.yaml +++ /dev/null @@ -1,7 +0,0 @@ -kind: Fixed -body: Add a flash message when reassigning accompanying course (reassign list) -time: 2026-03-09T10:17:21.923487588+01:00 -custom: - Issue: "503" - MR: "969" - SchemaChange: No schema change diff --git a/.changes/v4.14.0.md b/.changes/v4.14.0.md new file mode 100644 index 000000000..9ad3b65c9 --- /dev/null +++ b/.changes/v4.14.0.md @@ -0,0 +1,6 @@ +## v4.14.0 - 2026-03-09 +### Feature +* ([#486](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/486)) ([!](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/)) Add filter and aggregator based on referrer's main center for exports of accompanying period +### Fixed +* ([#502](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/502)) ([!968](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/968)) Fix import of postal code: mark postal code as deleted if they are not present in the import any more +* ([#503](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/503)) ([!969](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/969)) Add a flash message when reassigning accompanying course (reassign list) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd8931fc..842ddce69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## v4.14.0 - 2026-03-09 +### Feature +* ([#486](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/486)) ([!](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/)) Add filter and aggregator based on referrer's main center for exports of accompanying period +### Fixed +* ([#502](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/502)) ([!968](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/968)) Fix import of postal code: mark postal code as deleted if they are not present in the import any more +* ([#503](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/503)) ([!969](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/969)) Add a flash message when reassigning accompanying course (reassign list) + ## v4.13.0 - 2026-02-23 ### Feature * ([#500](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/500)) ([!964](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/964)) Limit the number of public download of stored object to 30 downloads