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