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/.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/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 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/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/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'); + } } diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 429f36aab..fece175c6 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/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/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 25f8c9f2a..57097eac8 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: >- @@ -33,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 95a63e95b..b83b9ccbb 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