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