diff --git a/migrations/Version20250915094404.php b/migrations/Version20250915094404.php
new file mode 100644
index 0000000..0dcce10
--- /dev/null
+++ b/migrations/Version20250915094404.php
@@ -0,0 +1,33 @@
+addSql('CREATE TABLE rpps_address (id CHAR(36) NOT NULL COMMENT \'(DC2Type:guid)\', rpps_id CHAR(36) NOT NULL COMMENT \'(DC2Type:guid)\', city_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:guid)\', md5_address VARCHAR(32) NOT NULL, address VARCHAR(255) DEFAULT NULL, address_extension VARCHAR(255) DEFAULT NULL, zipcode VARCHAR(255) DEFAULT NULL, original_address LONGTEXT DEFAULT NULL, latitude DOUBLE PRECISION DEFAULT NULL, longitude DOUBLE PRECISION DEFAULT NULL, coordinates POINT NOT NULL COMMENT \'(DC2Type:point)\', created_date DATETIME NOT NULL, import_id VARCHAR(20) NOT NULL, INDEX IDX_6EC5A0EA8BAC62AF (city_id), INDEX idx_rppsaddress_rpps (rpps_id), INDEX idx_rppsaddress_md5 (md5_address), UNIQUE INDEX uniq_rppsaddress_rpps_md5 (rpps_id, md5_address), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->addSql('ALTER TABLE rpps_address ADD CONSTRAINT FK_6EC5A0EAF4E1E022 FOREIGN KEY (rpps_id) REFERENCES rpps (id)');
+ $this->addSql('ALTER TABLE rpps_address ADD CONSTRAINT FK_6EC5A0EA8BAC62AF FOREIGN KEY (city_id) REFERENCES city (id)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('DROP TABLE rpps_address');
+ }
+}
diff --git a/src/ApiPlatform/Filter/RPPSFilter.php b/src/ApiPlatform/Filter/RPPSFilter.php
index 5ac3f30..fbfcc70 100755
--- a/src/ApiPlatform/Filter/RPPSFilter.php
+++ b/src/ApiPlatform/Filter/RPPSFilter.php
@@ -7,8 +7,8 @@
use ApiPlatform\Metadata\Operation;
use App\Entity\City;
use App\Entity\Specialty;
+use Doctrine\DBAL\Platforms\MySqlPlatform;
use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Exception;
@@ -94,21 +94,31 @@ protected function addCityFilter(QueryBuilder $queryBuilder, ?string $value): vo
/** @var City|null $city */
$city = $this->em->getRepository(City::class)->findOneBy(['canonical' => $value]);
+ // if city not found, force an empty result set
if (!$city) {
+ $queryBuilder->andWhere('1 = 2');
+
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
+ // Use RPPSAddress -> City relation instead of legacy RPPS.cityEntity
+ // Ensure we don't duplicate RPPS rows if multiple addresses match
+ $queryBuilder->distinct();
+ $queryBuilder
+ ->innerJoin("$rootAlias.addresses", 'addr')
+ ->innerJoin('addr.city', 'city');
+
if ($city->getSubCities()->toArray()) {
- $queryBuilder->innerJoin("$rootAlias.cityEntity", 'city', Join::WITH, 'city.canonical IN (:cityId)');
- $queryBuilder->setParameter('cityId', [
+ $queryBuilder->andWhere('city.canonical IN (:cityCanonicalList)');
+ $queryBuilder->setParameter('cityCanonicalList', [
$value,
- ...array_map(fn (City $city) => $city->getCanonical(), $city->getSubCities()->toArray()),
+ ...array_map(static fn (City $c) => $c->getCanonical(), $city->getSubCities()->toArray()),
]);
} else {
- $queryBuilder->innerJoin("$rootAlias.cityEntity", 'city', Join::WITH, 'city.canonical = :cityId');
- $queryBuilder->setParameter('cityId', $value);
+ $queryBuilder->andWhere('city.canonical = :cityCanonical');
+ $queryBuilder->setParameter('cityCanonical', $value);
}
}
@@ -161,14 +171,17 @@ protected function addSearchFilter(QueryBuilder $queryBuilder, ?string $value):
$value = $this->cleanValue($value);
if (str_contains($value, '%')) {
- $result = $this->em->getConnection()->fetchFirstColumn('(SELECT id FROM rpps WHERE full_name LIKE :search
+ $result = $this->em->getConnection()->fetchFirstColumn(
+ '(SELECT id FROM rpps WHERE full_name LIKE :search
LIMIT 500)
UNION
(SELECT id FROM rpps WHERE full_name_inversed LIKE :search
LIMIT 500)
-LIMIT 500;', [
- 'search' => "$value%",
- ]);
+LIMIT 500;',
+ [
+ 'search' => "$value%",
+ ]
+ );
$queryBuilder->andWhere("$alias.id IN (:result)");
$queryBuilder->setParameter('result', $result);
@@ -200,16 +213,19 @@ protected function addExcludedRppsFilter(QueryBuilder $queryBuilder, mixed $excl
return $queryBuilder;
}
- public function addLatitudeFilter(QueryBuilder $queryBuilder, ?string $latitude, ?Operation &$operation): QueryBuilder
- {
+ public function addLatitudeFilter(
+ QueryBuilder $queryBuilder,
+ ?string $latitude,
+ ?Operation &$operation,
+ ): QueryBuilder {
$request = $this->requestStack->getCurrentRequest();
$longitude = $request?->query->get('longitude');
if (!$latitude || !$longitude) {
return $queryBuilder;
}
- $operation = $operation->withPaginationClientEnabled(false);
- $operation = $operation->withPaginationClientPartial(true);
+ $operation = $operation?->withPaginationClientEnabled(false);
+ $operation = $operation?->withPaginationClientPartial(true);
$request->attributes->set('_api_operation', $operation);
@@ -229,32 +245,51 @@ public function addLatitudeFilter(QueryBuilder $queryBuilder, ?string $latitude,
$minLng = (float) $longitude - (float) $lngOffset;
$maxLng = (float) $longitude + (float) $lngOffset;
- // Add bounding box condition using the POINT function
- $queryBuilder->andWhere(
- 'MBRContains(ST_MakeEnvelope(POINT(:minLng, :minLat), POINT(:maxLng, :maxLat)), ' . $rootAlias . '.coordinates) = true'
- );
-
- // Apply the more accurate distance filter
- $queryBuilder->andWhere(
- "ST_Distance_Sphere(POINT(:longitude, :latitude), $rootAlias.coordinates) < :distance"
- )
- ->addSelect(
- "ST_Distance_Sphere(POINT(:longitude, :latitude), $rootAlias.coordinates) AS HIDDEN distance"
+ $queryBuilder->distinct();
+
+ // Join RPPS -> RPPSAddress for coordinates
+ $queryBuilder->innerJoin($rootAlias . '.addresses', 'addr');
+
+ $platform = $this->em->getConnection()->getDatabasePlatform();
+
+ if ($platform instanceof MySqlPlatform) {
+ // TODO NOT TESTED !
+ // MySQL path: use POINT/MBRContains/ST_Distance_Sphere on RPPSAddress.coordinates
+ $queryBuilder->andWhere(
+ 'MBRContains(ST_MakeEnvelope(POINT(:minLng, :minLat), POINT(:maxLng, :maxLat)), addr.coordinates) = 1'
);
- // Set parameters
- $queryBuilder->setParameter('latitude', (float) $latitude);
- $queryBuilder->setParameter('longitude', (float) $longitude);
- $queryBuilder->setParameter('distance', (float) $distance);
- $queryBuilder->setParameter('minLat', $minLat);
- $queryBuilder->setParameter('maxLat', $maxLat);
- $queryBuilder->setParameter('minLng', $minLng);
- $queryBuilder->setParameter('maxLng', $maxLng);
+ $queryBuilder
+ ->andWhere('ST_Distance_Sphere(POINT(:longitude, :latitude), addr.coordinates) < :distance')
+ ->addSelect('ST_Distance_Sphere(POINT(:longitude, :latitude), addr.coordinates) AS HIDDEN distance');
+
+ $queryBuilder->setParameter('latitude', (float) $latitude);
+ $queryBuilder->setParameter('longitude', (float) $longitude);
+ $queryBuilder->setParameter('distance', (float) $distance);
+ $queryBuilder->setParameter('minLat', $minLat);
+ $queryBuilder->setParameter('maxLat', $maxLat);
+ $queryBuilder->setParameter('minLng', $minLng);
+ $queryBuilder->setParameter('maxLng', $maxLng);
+ } else {
+ $queryBuilder
+ ->andWhere('addr.latitude IS NOT NULL')
+ ->andWhere('addr.longitude IS NOT NULL')
+ ->andWhere(
+ '(addr.latitude BETWEEN :minLat AND :maxLat AND addr.longitude BETWEEN :minLng AND :maxLng)
+ OR (ABS(addr.latitude - :latExact) < 1e-5 AND ABS(addr.longitude - :lngExact) < 1e-5)'
+ );
+
+ // Set only the parameters used by this branch
+ $queryBuilder->setParameter('minLat', $minLat);
+ $queryBuilder->setParameter('maxLat', $maxLat);
+ $queryBuilder->setParameter('minLng', $minLng);
+ $queryBuilder->setParameter('maxLng', $maxLng);
+ $queryBuilder->setParameter('latExact', (float) $latitude);
+ $queryBuilder->setParameter('lngExact', (float) $longitude);
+ }
- // $queryBuilder->orderBy(
- // 'distance',
- // 'ASC'
- // );
+ // Keep ordering optional
+ // $queryBuilder->addOrderBy('distance', 'ASC');
return $queryBuilder;
}
@@ -284,7 +319,7 @@ public static function parseBooleanValue(string $string): ?bool
// If true or 1, returns true
// if false or 0 returns false
- // Else, incorrect value : returns null
+ // Else, incorrect value: returns null
return in_array($string, ['1', 'true']) ? true : (in_array($string, ['0', 'false']) ? false : null);
}
@@ -326,7 +361,7 @@ public function getDescription(string $resourceClass): array
'type' => 'array',
'required' => false,
'swagger' => [
- 'description' => 'Exclude specific RPPS numbers from the result set. Provide one or more RPPS numbers.',
+ 'description' => 'Exclude given RPPS numbers from the result. Provide one or more RPPS numbers',
'type' => 'array',
'items' => [
'type' => 'string',
diff --git a/src/Command/RppsDetectDuplicates.php b/src/Command/RppsDetectDuplicates.php
new file mode 100644
index 0000000..48af246
--- /dev/null
+++ b/src/Command/RppsDetectDuplicates.php
@@ -0,0 +1,151 @@
+writeln("File not found: {$inputPath}");
+
+ return Command::FAILURE;
+ }
+
+ try {
+ $in = new SplFileObject($inputPath, 'r');
+ $in->setFlags(SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
+ } catch (Throwable $e) {
+ $output->writeln("Cannot open file: {$e->getMessage()}");
+
+ return Command::FAILURE;
+ }
+
+ // Open output CSV
+ $outHandle = @fopen($outputPath, 'w');
+ if (false === $outHandle) {
+ $output->writeln("Cannot open output file for writing: {$outputPath}");
+
+ return Command::FAILURE;
+ }
+
+ // Parse header
+ $header = $in->fgetcsv($delimiter);
+ if (false === $header) {
+ fclose($outHandle);
+ $output->writeln('Empty file or unreadable header.');
+
+ return Command::FAILURE;
+ }
+
+ // Write header to output
+ fputcsv($outHandle, $header, $delimiter);
+
+ // Find needed columns
+ $headerIndex = array_flip($header);
+ $colRaw = $headerIndex['Identifiant PP'] ?? null;
+ $colNational = $headerIndex['Identification nationale PP'] ?? null;
+
+ if (null === $colRaw && null === $colNational) {
+ fclose($outHandle);
+ $output->writeln('Required columns not found in header.');
+
+ return Command::FAILURE;
+ }
+
+ $counts = []; // key => count
+ $firstLines = []; // key => first line (array of columns)
+ $linesRead = 0; // data lines read (excluding header)
+ $linesWritten = 0; // lines written to output (including header already written)
+ $dupKeys = 0; // number of keys with count >= 2
+ $tripKeys = 0; // number of keys with count >= 3
+
+ while (!$in->eof()) {
+ /* @phpstan-ignore-next-line */
+ if ($hardLimit > 0 && $linesRead >= $hardLimit) {
+ break;
+ }
+
+ $row = $in->fgetcsv($delimiter);
+ if (false === $row || $row === [null]) {
+ continue;
+ }
+
+ ++$linesRead;
+
+ // Extract values
+ $raw = null !== $colRaw && array_key_exists($colRaw, $row) ? trim((string) $row[$colRaw]) : '';
+ $national = null !== $colNational && array_key_exists($colNational, $row) ? trim((string) $row[$colNational]) : '';
+
+ // Key: use national, fallback to padded raw
+ $key = '' !== $national ? $national : ('' !== $raw ? str_pad($raw, 10, '0', STR_PAD_LEFT) : '');
+
+ if ('' === $key) {
+ if (0 === $linesRead % $progressEvery) {
+ $output->writeln("Processed {$linesRead} lines... (empty key)");
+ }
+ continue;
+ }
+
+ $currentCount = $counts[$key] ?? 0;
+
+ if (0 === $currentCount) {
+ // First occurrence: remember the line, do not write yet
+ $firstLines[$key] = $row;
+ $counts[$key] = 1;
+ } elseif (1 === $currentCount) {
+ // Second occurrence: write the first occurrence and this one
+ if (isset($firstLines[$key])) {
+ fputcsv($outHandle, $firstLines[$key], $delimiter);
+ ++$linesWritten;
+ unset($firstLines[$key]); // free memory
+ }
+ fputcsv($outHandle, $row, $delimiter);
+ ++$linesWritten;
+ $counts[$key] = 2;
+ ++$dupKeys; // first time we cross to duplicate for this key
+ } else {
+ // Third or more: write only the current row
+ fputcsv($outHandle, $row, $delimiter);
+ ++$linesWritten;
+
+ // Count keys that reached triple+ exactly once
+ if (2 === $currentCount) {
+ ++$tripKeys;
+ }
+ $counts[$key] = $currentCount + 1;
+ }
+
+ if (0 === $linesRead % $progressEvery) {
+ $output->writeln("Processed {$linesRead} lines... written: {$linesWritten}");
+ }
+ }
+
+ fclose($outHandle);
+
+ $output->writeln("Done. Data lines read (limited): {$linesRead}. Lines written: {$linesWritten}. Keys with duplicates (>=2): {$dupKeys}. Keys with triple or more (>=3): {$tripKeys}. Output: {$outputPath}");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/RppsImport.php b/src/Command/RppsImport.php
index d570bb2..c0d15fd 100755
--- a/src/Command/RppsImport.php
+++ b/src/Command/RppsImport.php
@@ -84,8 +84,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln("Import id was {$this->rppsService->getImportId()}");
- $this->rppsService->loadTestData();
-
return Command::SUCCESS;
} catch (Exception $e) {
error_log($e->getMessage());
diff --git a/src/DataFixtures/LoadRPPS.php b/src/DataFixtures/LoadRPPS.php
index 34b2444..ef44fcc 100755
--- a/src/DataFixtures/LoadRPPS.php
+++ b/src/DataFixtures/LoadRPPS.php
@@ -2,18 +2,19 @@
namespace App\DataFixtures;
-use App\Entity\City;
+use AllowDynamicProperties;
use App\Entity\RPPS;
use App\Entity\Specialty;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
-use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Persistence\ObjectManager;
-use Faker\Factory;
-class LoadRPPS extends Fixture implements DependentFixtureInterface, FixtureInterface
+#[AllowDynamicProperties]
+class LoadRPPS extends Fixture implements DependentFixtureInterface
{
- public string $importId = 'import_1';
+ public const string IMPORT_ID = 'import_1';
+ public const string RPPS_USER_1 = '10101485653';
+ public const string RPPS_USER_2 = '19900000002';
protected ObjectManager $em;
@@ -21,10 +22,6 @@ public function load(ObjectManager $manager): void
{
$this->em = $manager;
- $faker = Factory::create('fr_FR');
-
- $faker->seed(666);
-
$specialtyRepo = $this->em->getRepository(Specialty::class);
$generalSpecialty = $specialtyRepo->findOneBy(['canonical' => 'medecine-generale']);
$pediatricsSpecialty = $specialtyRepo->findOneBy(['canonical' => 'pediatrie']);
@@ -32,79 +29,32 @@ public function load(ObjectManager $manager): void
$infirmierSpecialty = $specialtyRepo->findOneBy(['canonical' => 'infirmier']);
$sageFemmeSpecialty = $specialtyRepo->findOneBy(['canonical' => 'sage-femme']);
- foreach ($this->getUsers() as $i => $user) {
- $rpps = new RPPS();
- $rpps->setFirstName($user);
- $rpps->setLastName('Test');
- if (in_array($i, [0, 3, 4])) {
- $rpps->setTitle('Docteur');
- }
-
- $rppsId = $this->getRpps($i);
-
- $rpps->setIdRpps($rppsId);
-
- if (in_array($i, [0, 1, 5, 8])) {
- $rpps->setCpsNumber(substr($rppsId, 1, 10));
- }
-
- if (in_array($i, [0, 2, 3, 8])) {
- $rpps->setFinessNumber(substr($rppsId, 1, 9));
- }
- if (in_array($i, [0, 4, 5, 9])) {
- $rpps->setEmail(strtolower("$user@instamed.fr"));
- }
-
- if (in_array($i, [0, 1, 4, 8])) {
- $rpps->setAddress($faker->streetAddress());
- $rpps->setCity($faker->city());
- $rpps->setZipcode($faker->postcode());
- $rpps->setLatitude($faker->latitude());
- $rpps->setLongitude($faker->longitude());
- }
- $rpps->setSpecialty($this->getLegacySpecialties()[$i]);
-
- switch ($i) {
- case 0:
- case 1:
- $rpps->setSpecialtyEntity($generalSpecialty);
- break;
- case 2:
- case 3:
- $rpps->setSpecialtyEntity($sageFemmeSpecialty);
- break;
- case 4:
- case 5:
- case 6:
- case 7:
- $rpps->setSpecialtyEntity($pediatricsSpecialty);
- break;
- case 8:
- $rpps->setSpecialtyEntity($pharmacySpecialty);
- break;
- case 9:
- case 10:
- case 11:
- $rpps->setSpecialtyEntity($infirmierSpecialty);
- break;
- }
-
- if (in_array($i, [0, 3, 5, 9])) {
- $rpps->setPhoneNumber($faker->phoneNumber());
- }
+ $preloadedByCanonical = [
+ 'medecine-generale' => $generalSpecialty,
+ 'pediatrie' => $pediatricsSpecialty,
+ 'pharmacien' => $pharmacySpecialty,
+ 'infirmier' => $infirmierSpecialty,
+ 'sage-femme' => $sageFemmeSpecialty,
+ ];
- // Dynamically link cityEntity based on the INSEE code
- $cityInseeCode = $this->getCityInseeCode($i);
- $city = $this->em->getRepository(City::class)->findOneBy(['inseeCode' => $cityInseeCode]);
+ foreach ($this->getUsers() as $user) {
+ $rpps = new RPPS();
- if ($city) {
- $rpps->setCityEntity($city);
+ // Champs de base
+ $rpps->setIdRpps($user['idRpps']);
+ $rpps->setFirstName($user['firstName']);
+ $rpps->setLastName($user['lastName']);
+ $rpps->setTitle($user['title']);
+ $rpps->setEmail($user['email']);
+ $rpps->setCpsNumber($user['cpsNumber']);
+ $rpps->setFinessNumber($user['finessNumber']);
+ $rpps->setPhoneNumber($user['phoneNumber']);
+ $rpps->setCanonical($user['canonical']);
+ $specKey = $user['specialty'] ?? null;
+ if (is_string($specKey) && isset($preloadedByCanonical[$specKey])) {
+ $rpps->setSpecialtyEntity($preloadedByCanonical[$specKey]);
}
-
- $rpps->setCanonical('fixture-canonical-' . $i);
-
- $rpps->setImportId($this->importId);
-
+ $rpps->setImportId(self::IMPORT_ID);
$this->em->persist($rpps);
}
@@ -114,79 +64,61 @@ public function load(ObjectManager $manager): void
protected function getUsers(): array
{
return [
- 'Bastien',
- 'Jérémie',
- 'Luv',
- 'Julien',
- 'Lauriane',
- 'Maxime',
- 'Johann',
- 'Emilie',
- 'Blandine',
- 'Quentin',
- 'Achile',
- ];
- }
-
- private function getRpps(int $index): string
- {
- $j = $index + 1;
-
- $isDemo = $j > 6;
-
- $first = $isDemo ? 2 : 1;
-
- if ($j >= 10) {
- $ids = [
- 10 => "{$first}1234567890",
- 11 => "{$first}0987654321",
- 12 => "{$first}4444455555",
- ];
-
- return $ids[$j];
- }
-
- return "$first$j$j$j$j$j$j$j$j$j$j";
- }
-
- private function getCityInseeCode(int $index): string
- {
- $cityInseeCodes = [
- '75104',
- '75104',
- '75105',
- '75105',
- '75120',
- '01050',
- '01050',
- '01050',
- '01053',
- '01053',
- '01053',
- ];
-
- return $cityInseeCodes[$index % count($cityInseeCodes)];
- }
-
- private function getLegacySpecialties(): array
- {
- return [
- 'Qualifié en Médecine Générale',
- 'Sage-Femme',
- 'Masseur-Kinésithérapeute',
- null,
- 'Pédiatrie',
- 'Pharmacien',
- null,
- 'Biologie médicale',
- 'Radiologie',
- null,
- 'Infirmier',
+ [
+ 'idRpps' => self::RPPS_USER_1,
+ 'title' => 'Docteur',
+ 'lastName' => 'Ochrome',
+ 'firstName' => 'Mercure',
+ 'canonical' => 'fixture-canonical-0',
+ 'phoneNumber' => '+33123456789',
+ 'email' => 'mercure.ochrome@example.test',
+ 'finessNumber' => '750300667',
+ 'cpsNumber' => null,
+ 'specialty' => 'medecine-generale',
+ ],
+ [
+ 'idRpps' => self::RPPS_USER_2,
+ 'title' => 'Docteur',
+ 'lastName' => 'Bressan',
+ 'firstName' => 'Aurelien',
+ 'canonical' => 'fixture-canonical-1',
+ 'phoneNumber' => '+33400000000',
+ 'email' => 'aurelien.bressan@example.test',
+ 'finessNumber' => null,
+ 'cpsNumber' => null,
+ 'specialty' => 'medecine-generale',
+ ],
+
+ // Demo users (idRpps starts with "2" to match demo filter)
+ [
+ 'idRpps' => '21234567890',
+ 'title' => 'Docteur',
+ 'lastName' => 'Demo',
+ 'firstName' => 'Emilie',
+ 'canonical' => 'fixture-canonical-demo-1',
+ 'phoneNumber' => '+33111111111',
+ 'email' => 'emilie.demo@example.test',
+ 'finessNumber' => null,
+ 'cpsNumber' => null,
+ 'specialty' => 'medecine-generale',
+ ],
+ [
+ 'idRpps' => '20987654321',
+ 'title' => 'Docteur',
+ 'lastName' => 'Demo',
+ 'firstName' => 'Jeremie',
+ 'canonical' => 'fixture-canonical-demo-2',
+ 'phoneNumber' => '+33122222222',
+ 'email' => 'jeremie.demo@example.test',
+ 'finessNumber' => null,
+ 'cpsNumber' => null,
+ 'specialty' => 'medecine-generale',
+ ],
];
}
public function getDependencies(): array
{
- return [LoadSpecialty::class, LoadCity::class];
+ return [LoadSpecialty::class];
}
}
diff --git a/src/DataFixtures/LoadRPPSAddress.php b/src/DataFixtures/LoadRPPSAddress.php
new file mode 100644
index 0000000..675c157
--- /dev/null
+++ b/src/DataFixtures/LoadRPPSAddress.php
@@ -0,0 +1,106 @@
+ [
+ [
+ 'address' => '10 Rue de la Paix',
+ 'addressExtension' => 'Bât A',
+ 'zipcode' => '75002',
+ 'cityCanonical' => 'paris-2eme',
+ 'latitude' => 48.8686,
+ 'longitude' => 2.3314,
+ ],
+ [
+ 'address' => '25 Avenue des Champs',
+ 'addressExtension' => 'Bât B',
+ 'zipcode' => '75008',
+ 'cityCanonical' => 'paris-8eme',
+ 'latitude' => 48.870637,
+ 'longitude' => 2.318747,
+ ],
+ ],
+ LoadRPPS::RPPS_USER_2 => [
+ [
+ 'address' => '3 Place de la Préfecture',
+ 'addressExtension' => 'Etage 2',
+ 'zipcode' => '01000',
+ 'cityCanonical' => 'bourg-en-bresse',
+ 'latitude' => 46.2052,
+ 'longitude' => 5.2460,
+ ],
+ ],
+ ];
+ }
+
+ public function load(ObjectManager $manager): void
+ {
+ $rppsRepo = $manager->getRepository(RPPS::class);
+ $cityRepo = $manager->getRepository(City::class);
+
+ foreach ($this->getAddresses() as $idRpps => $addresses) {
+ /** @var RPPS|null $rpps */
+ $rpps = $rppsRepo->findOneBy(['idRpps' => $idRpps]);
+ if (!$rpps) {
+ // RPPS manquant: on saute silencieusement
+ continue;
+ }
+
+ foreach ($addresses as $a) {
+ $address = $a['address'] ?? null;
+ $addressExt = $a['addressExtension'] ?? null;
+ $zipcode = $a['zipcode'] ?? null;
+
+ $rppsAddress = new RPPSAddress();
+ $rpps->addAddress($rppsAddress);
+ $rppsAddress->setAddress($address);
+ $rppsAddress->setAddressExtension($addressExt);
+ $rppsAddress->setZipcode($zipcode);
+
+ if (array_key_exists('cityCanonical', $a)) {
+ $cityEntity = $cityRepo->findOneBy(['canonical' => $a['cityCanonical']]);
+ if ($cityEntity) {
+ $rppsAddress->setCity($cityEntity);
+ }
+ }
+
+ if (array_key_exists('latitude', $a)) {
+ $rppsAddress->setLatitude($a['latitude']);
+ }
+ if (array_key_exists('longitude', $a)) {
+ $rppsAddress->setLongitude($a['longitude']);
+ }
+
+ $rppsAddress->syncCoordinatesFromLatLong();
+
+ // Compute the originalAddress from current fields for consistency
+ $rppsAddress->refreshOriginalAddress();
+
+ $rppsAddress->setMd5AddressFromParts($address, $rppsAddress->getCity(), $zipcode);
+ $rppsAddress->setImportId(LoadRPPS::IMPORT_ID);
+
+ $manager->persist($rpps);
+ $manager->persist($rppsAddress);
+ }
+ }
+
+ $manager->flush();
+ }
+
+ public function getDependencies(): array
+ {
+ return [LoadCity::class, LoadRPPS::class];
+ }
+}
diff --git a/src/Entity/RPPS.php b/src/Entity/RPPS.php
index 1c74cac..a26a40c 100755
--- a/src/Entity/RPPS.php
+++ b/src/Entity/RPPS.php
@@ -13,6 +13,8 @@
use App\Entity\Traits\ImportIdTrait;
use App\Repository\RPPSRepository;
use App\StateProvider\DefaultItemDataProvider;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use libphonenumber\PhoneNumber;
@@ -22,11 +24,18 @@
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
-// TODO - remove this index when the migration to specialtyEntity is done. @Bastien
+// TODO: Keep until API/search use specialtyEntity only; remove when legacy specialty string is dropped.
#[ORM\Index(columns: ['specialty'], name: 'specialty_index')]
-#[ApiFilter(RPPSFilter::class, properties: ['search', 'first_letter', 'city', 'specialty', 'demo', 'latitude', 'longitude', 'excluded_rpps'])]
+#[ApiFilter(
+ RPPSFilter::class,
+ // NOTE: city/specialty/latitude/longitude rely on legacy fields that will be flattened by the normalizer.
+ // TODO: once API/search use RPPSAddress + city/coordinates directly, realign/remap filters and
+ // drop legacy reliance here.
+ properties: ['search', 'first_letter', 'city', 'specialty', 'demo', 'latitude', 'longitude', 'excluded_rpps']
+)]
#[ORM\Entity(repositoryClass: RPPSRepository::class)]
#[ORM\Table(name: 'rpps')]
+// TODO: Keep until API/search use RPPSAddress only; remove when legacy coordinates are dropped.
#[ORM\Index(columns: ['coordinates'], name: 'idx_coordinates')]
#[ORM\Index(columns: ['last_name'], name: 'last_name_index')]
#[ORM\Index(columns: ['full_name'], name: 'full_name_index')]
@@ -120,7 +129,6 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'deprecated' => true,
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected ?string $specialty = null;
@@ -133,6 +141,10 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
#[ORM\JoinColumn(nullable: true)]
private ?Specialty $specialtyEntity = null;
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ApiProperty(
description: 'The address of the doctor',
required: false,
@@ -141,10 +153,13 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'example' => '12 Rue de Paris',
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected ?string $address = null;
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ApiProperty(
description: 'The address extension of the doctor',
required: false,
@@ -153,10 +168,13 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'example' => 'BP 75',
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected ?string $addressExtension = null;
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ApiProperty(
description: 'The postal code of the doctor',
required: false,
@@ -165,12 +183,12 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'example' => '75019',
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected ?string $zipcode = null;
/**
- * @deprecated use $cityEntity instead
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
*/
#[ApiProperty(
description: 'Deprecated. The city of the doctor, use cityEntity instead.',
@@ -181,10 +199,13 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'deprecated' => true,
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected ?string $city = null;
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ApiProperty(
description: 'The latitude of the doctor',
required: false,
@@ -193,16 +214,27 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'example' => 48.8566,
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'float', nullable: true)]
protected ?float $latitude = null;
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ORM\Column(type: 'text', nullable: true)]
protected ?string $originalAddress = null;
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ORM\Column(type: PointType::POINT, nullable: false)]
private array $coordinates = [];
+ /**
+ * @deprecated Backward compatibility for the mobile app. This field is now stored in RPPSAddress.
+ * Can be removed once the mobile app is updated to consume addresses from RPPSAddress.
+ */
#[ApiProperty(
description: 'The latitude of the doctor',
required: false,
@@ -211,12 +243,11 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
'example' => 48.8566,
]
)]
- #[Groups(['read'])]
#[ORM\Column(type: 'float', nullable: true)]
protected ?float $longitude = null;
#[ApiProperty(
- description: 'The city entity of the doctor, with more detailed information such as population and coordinates.',
+ description: 'The city entity of the doctor, with more detailed information such as population and coordinates',
required: false,
)]
#[Groups(['read'])]
@@ -282,6 +313,21 @@ class RPPS extends BaseEntity implements ImportableEntityInterface
#[ORM\Column(type: 'string', length: 255, nullable: false)]
private ?string $fullNameInversed = null;
+ #[Groups(['read'])]
+ #[ORM\OneToMany(
+ mappedBy: 'rpps',
+ targetEntity: RPPSAddress::class,
+ cascade: ['persist', 'remove'],
+ orphanRemoval: true
+ )]
+ private Collection $addresses;
+
+ public function __construct()
+ {
+ parent::__construct();
+ $this->addresses = new ArrayCollection();
+ }
+
public function getCanonical(): ?string
{
return $this->canonical;
@@ -398,11 +444,9 @@ public function getPhoneNumber(): ?PhoneNumber
}
/**
- * @param string|PhoneNumber|null $number
- *
* @return $this
*/
- public function setPhoneNumber($number): self
+ public function setPhoneNumber(PhoneNumber|string|null $number): self
{
if (!$number) {
$this->phoneNumber = null;
@@ -481,7 +525,7 @@ public function setCpsNumber(?string $cpsNumber): self
#[SerializedName('fullName')]
public function getFullNameWithTitle(): string
{
- return trim((string) "{$this->shortTitle()} {$this->getFirstName()} {$this->getLastName()}");
+ return trim("{$this->shortTitle()} {$this->getFirstName()} {$this->getLastName()}");
}
public function getFullNameInversed(): ?string
@@ -609,8 +653,8 @@ public function setLatitude(?float $latitude): void
{
$this->latitude = $latitude;
$this->coordinates = [
- 'latitude' => $latitude ?? 0,
- 'longitude' => $this->longitude ?? 0,
+ 'latitude' => $latitude ?? 0.0,
+ 'longitude' => $this->longitude ?? 0.0,
];
}
@@ -627,8 +671,8 @@ public function setLongitude(?float $longitude): void
{
$this->longitude = $longitude;
$this->coordinates = [
- 'latitude' => $this->latitude ?? 0,
- 'longitude' => $longitude ?? 0,
+ 'latitude' => $this->latitude ?? 0.0,
+ 'longitude' => $longitude ?? 0.0,
];
}
@@ -651,4 +695,32 @@ public function setOriginalAddress(?string $originalAddress): void
{
$this->originalAddress = $originalAddress;
}
+
+ /**
+ * @return Collection
+ */
+ public function getAddresses(): Collection
+ {
+ return $this->addresses;
+ }
+
+ public function addAddress(RPPSAddress $rppsAddress): static
+ {
+ if (!$this->addresses->contains($rppsAddress)) {
+ $this->addresses->add($rppsAddress);
+ $rppsAddress->setRpps($this);
+ }
+
+ return $this;
+ }
+
+ public function removeAddress(RPPSAddress $rPPSAddress): static
+ {
+ // set the owning side to null (unless already changed)
+ if ($this->addresses->removeElement($rPPSAddress) && $rPPSAddress->getRpps() === $this) {
+ $rPPSAddress->setRpps(null);
+ }
+
+ return $this;
+ }
}
diff --git a/src/Entity/RPPSAddress.php b/src/Entity/RPPSAddress.php
new file mode 100644
index 0000000..f11a9de
--- /dev/null
+++ b/src/Entity/RPPSAddress.php
@@ -0,0 +1,300 @@
+rpps;
+ }
+
+ public function setRpps(?RPPS $rpps): static
+ {
+ $this->rpps = $rpps;
+
+ return $this;
+ }
+
+ public function getAddress(): ?string
+ {
+ $address = trim((string) $this->address);
+ $address = preg_replace('# {2,}#', ' ', $address);
+
+ return $address ?: null;
+ }
+
+ public function setAddress(?string $address): self
+ {
+ if (null === $address) {
+ $this->address = null;
+
+ return $this;
+ }
+
+ // Normalize spaces and trim
+ $normalized = trim(preg_replace('#\s+#', ' ', $address));
+
+ // Consider an empty string or literal "0" as null
+ $this->address = ('' === $normalized || '0' === $normalized) ? null : $normalized;
+
+ return $this;
+ }
+
+ public function getZipcode(): ?string
+ {
+ return $this->zipcode;
+ }
+
+ public function setZipcode(?string $zipcode): self
+ {
+ $this->zipcode = $zipcode;
+
+ return $this;
+ }
+
+ public function getCity(): ?City
+ {
+ return $this->city;
+ }
+
+ public function setCity(?City $city): self
+ {
+ $this->city = $city;
+
+ return $this;
+ }
+
+ #[Groups(['read'])]
+ #[SerializedName('cityName')]
+ public function getCityName(): ?string
+ {
+ if ($this->city) {
+ return $this->city->getName();
+ }
+
+ // Fallback to legacy code, remove when all addresses are updated and city field dropped.
+ if (!$this->getRpps()?->getCity()) {
+ return null;
+ }
+
+ return trim(preg_replace('#^\\d{5,6}#', '', $this->getRpps()->getCity()));
+ }
+
+ public function getAddressExtension(): ?string
+ {
+ return $this->addressExtension;
+ }
+
+ public function setAddressExtension(?string $addressExtension): void
+ {
+ $this->addressExtension = $addressExtension;
+ }
+
+ public function getOriginalAddress(): ?string
+ {
+ return $this->originalAddress;
+ }
+
+ public function setOriginalAddress(?string $originalAddress): void
+ {
+ $this->originalAddress = $originalAddress;
+ }
+
+ /**
+ * Helper to recompute and set the originalAddress from current entity fields.
+ *
+ * Warning: call this AFTER setting address, addressExtension, zipcode and city,
+ * so the computed value is consistent across the application (imports, fixtures, etc.).
+ */
+ public function refreshOriginalAddress(): void
+ {
+ $original = trim(implode(' ', array_filter([
+ $this->getAddress(),
+ $this->getAddressExtension(),
+ $this->getZipcode(),
+ $this->getCity(),
+ ], static fn ($v) => null !== $v && '' !== $v)));
+
+ $this->setOriginalAddress($original ?: null);
+ }
+
+ public function __toString(): string
+ {
+ $parts = array_filter([
+ $this->getAddress(),
+ $this->getZipcode(),
+ $this->getCity(),
+ ]);
+
+ return implode(' ', $parts) ?: $this->getId() ?? '';
+ }
+
+ /**
+ * Setter direct pour le MD5 hexadécimal (32 chars).
+ */
+ public function setMd5AddressHex(string $hex32): void
+ {
+ if (32 !== strlen($hex32)) {
+ throw new InvalidArgumentException('md5Address must be 32 chars hex.');
+ }
+ $this->md5Address = strtolower($hex32);
+ }
+
+ /**
+ * Getter du MD5 au format hexadécimal (32 chars).
+ */
+ public function getMd5AddressHex(): string
+ {
+ return $this->md5Address ?? '';
+ }
+
+ /**
+ * Calcule et affecte le MD5 (hex) à partir des champs normalisés.
+ */
+ public function setMd5AddressFromParts(?string $address, ?string $city, ?string $zipcode): void
+ {
+ $normAddr = self::normalizeText($address);
+ $normCity = self::normalizeText($city);
+ $normZip = self::normalizeText($zipcode);
+
+ $toHash = $normAddr . '|' . $normCity . '|' . $normZip;
+
+ $this->md5Address = md5($toHash);
+ }
+
+ private static function normalizeText(?string $value): string
+ {
+ if (null === $value) {
+ return '';
+ }
+ // trim and collapse whitespaces + lowercase ASCII
+ $v = trim(preg_replace('#\s+#', ' ', $value));
+ if ('' === $v || '0' === $v) {
+ return '';
+ }
+ // passage en ASCII basique (rapide), enlever accents si nécessaire selon vos besoins
+ $v = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v) ?: $v;
+
+ return strtolower($v);
+ }
+
+ public function getCoordinates(): array
+ {
+ return $this->coordinates;
+ }
+
+ public function setCoordinates(array $coordinates): void
+ {
+ $this->coordinates = $coordinates;
+ }
+
+ /**
+ * Helper to sync the "coordinates" point field from the current latitude/longitude properties.
+ * Use this after you've set latitude and/or longitude to ensure the DB point field is consistent.
+ */
+ public function syncCoordinatesFromLatLong(): void
+ {
+ $this->coordinates = [
+ 'latitude' => $this->latitude ?? 0.0,
+ 'longitude' => $this->longitude ?? 0.0,
+ ];
+ }
+
+ public function getLatitude(): ?float
+ {
+ if (isset($this->coordinates['latitude']) && 0 !== $this->coordinates['latitude']) {
+ return $this->coordinates['latitude'];
+ }
+
+ return $this->latitude;
+ }
+
+ public function setLatitude(?float $latitude): void
+ {
+ $this->latitude = $latitude;
+ $this->coordinates = [
+ 'latitude' => $latitude ?? 0.0,
+ 'longitude' => $this->longitude ?? 0.0,
+ ];
+ }
+
+ public function getLongitude(): ?float
+ {
+ if (isset($this->coordinates['longitude']) && 0 !== $this->coordinates['longitude']) {
+ return $this->coordinates['longitude'];
+ }
+
+ return $this->longitude;
+ }
+
+ public function setLongitude(?float $longitude): void
+ {
+ $this->longitude = $longitude;
+ $this->coordinates = [
+ 'latitude' => $this->latitude ?? 0.0,
+ 'longitude' => $longitude ?? 0.0,
+ ];
+ }
+}
diff --git a/src/Repository/RPPSAddressRepository.php b/src/Repository/RPPSAddressRepository.php
new file mode 100644
index 0000000..3b1a762
--- /dev/null
+++ b/src/Repository/RPPSAddressRepository.php
@@ -0,0 +1,23 @@
+
+ *
+ * @method RPPSAddress|null find($id, $lockMode = null, $lockVersion = null)
+ * @method RPPSAddress|null findOneBy(array $criteria, array $orderBy = null)
+ * @method RPPSAddress[] findAll()
+ * @method RPPSAddress[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class RPPSAddressRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, RPPSAddress::class);
+ }
+}
diff --git a/src/Serializer/Normalizer/RppsNormalizer.php b/src/Serializer/Normalizer/RppsNormalizer.php
new file mode 100644
index 0000000..5c76f50
--- /dev/null
+++ b/src/Serializer/Normalizer/RppsNormalizer.php
@@ -0,0 +1,99 @@
+normalizer->normalize($object, $format, $context);
+
+ // Aplatis les champs legacy (adresse ET spécialité) pour rétrocompatibilité mobile.
+ $this->legacyFlattenAddress($object, $data);
+ $this->legacyFlattenSpecialty($object, $data);
+
+ return $data;
+ }
+
+ /**
+ * Aplatissement legacy de l'adresse au niveau RPPS.
+ */
+ private function legacyFlattenAddress(RPPS $object, array &$data): void
+ {
+ /** @var RPPSAddress|null $primary */
+ $primary = $object->getAddresses()->first() ?: null;
+
+ // Valeurs par défaut nulles
+ $address = null;
+ $addressExt = null;
+ $zipcode = null;
+ $cityName = null;
+ $lat = null;
+ $lng = null;
+ $coords = ['latitude' => null, 'longitude' => null];
+
+ if ($primary) {
+ $address = $primary->getAddress();
+ $addressExt = $primary->getAddressExtension();
+ $zipcode = $primary->getZipcode();
+ $cityName = $primary->getCityName();
+
+ // Coordonnées de l'ADRESSE (pas de la ville)
+ $lat = $primary->getLatitude();
+ $lng = $primary->getLongitude();
+ $coords = $primary->getCoordinates();
+ }
+
+ // Assigner systématiquement les clés legacy au niveau RPPS
+ $data['address'] = $address;
+ $data['addressExtension'] = $addressExt;
+ $data['zipcode'] = $zipcode;
+ $data['city'] = $cityName;
+ $data['latitude'] = $lat;
+ $data['longitude'] = $lng;
+ $data['coordinates'] = $coords;
+ }
+
+ /**
+ * Aplatissement legacy de la spécialité au niveau RPPS.
+ */
+ private function legacyFlattenSpecialty(RPPS $object, array &$data): void
+ {
+ $entity = $object->getSpecialtyEntity();
+ $data['specialty'] = $entity?->getName();
+ }
+}
diff --git a/src/Service/RPPSService.php b/src/Service/RPPSService.php
index 6f3762c..735ba4b 100755
--- a/src/Service/RPPSService.php
+++ b/src/Service/RPPSService.php
@@ -5,39 +5,42 @@
use App\DataFixtures\LoadRPPS;
use App\Entity\City;
use App\Entity\RPPS;
+use App\Entity\RPPSAddress;
use App\Entity\Specialty;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\NonUniqueResultException;
use Exception;
+use Random\RandomException;
+use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\HttpKernel\KernelInterface;
+use Throwable;
use function Symfony\Component\String\u;
-/**
- * Contains all useful methods to process files and import them into database.
- */
class RPPSService extends ImporterService
{
private int $matchedCitiesCount = 0;
private int $unmatchedCitiesCount = 0;
+ private const int MAX_CANONICAL_CACHE_SIZE = 50000;
+
// Hashmaps for specialties to avoid unnecessary DB queries
private array $specialtyByName = []; // The name field of the Specialty entity
private array $specialtyByAltName = []; // Mapping of Instamed RPPS db specialties to our specialties
private array $existingCanonicals = []; // Hashmap to store existing canonicals to avoid duplicate queries
+ // Cache pour éviter les doublons d'adresses dans le même batch
+ // Taille limitée au batch size pour optimiser la mémoire
+ private array $addressCache = []; // Format: "rpps_id|md5_hash" => RPPSAddress
+
public function __construct(
protected readonly string $cps,
protected readonly string $rpps,
FileProcessor $fileProcessor,
EntityManagerInterface $em,
- private readonly KernelInterface $kernel,
) {
parent::__construct(RPPS::class, $fileProcessor, $em);
$this->initializeSpecialtyMaps();
- // $this->initializeCanonicalMap();
}
// Initialize the hashmaps for specialties
@@ -52,19 +55,6 @@ private function initializeSpecialtyMaps(): void
$this->specialtyByAltName = SpecialtyMappingService::SPECIALTY_MAPPING;
}
- // Initialize the hashmap for existing canonicals
- /* private function initializeCanonicalMap(): void
- {
- $existingCanonicals = $this->em->getRepository(RPPS::class)->createQueryBuilder('r')
- ->select('r.canonical')
- ->getQuery()
- ->getResult();
-
- foreach ($existingCanonicals as $entry) {
- $this->existingCanonicals[$entry['canonical']] = true;
- }
- } */
-
/**
* @throws \Doctrine\DBAL\Exception
*/
@@ -77,8 +67,8 @@ public function loadTestData(): void
'20987654321',
];
for ($j = 1; $j <= 9; ++$j) {
- $ids[] = "1{$j}{$j}{$j}{$j}{$j}{$j}{$j}{$j}{$j}{$j}";
- $ids[] = "2{$j}{$j}{$j}{$j}{$j}{$j}{$j}{$j}{$j}{$j}";
+ $ids[] = "1$j$j$j$j$j$j$j$j$j$j";
+ $ids[] = "2$j$j$j$j$j$j$j$j$j$j";
}
$this->em->getConnection()->executeQuery(
@@ -90,6 +80,7 @@ public function loadTestData(): void
$this->output->writeln('Existing data successfully deleted');
$fixture = new LoadRPPS();
+ /* @phpstan-ignore-next-line */
$fixture->importId = $this->getImportId();
$fixture->load($this->em);
@@ -109,8 +100,14 @@ public function importFile(OutputInterface $output, string $type, int $start = 0
} elseif ('cps' === $type) {
$options = ['delimiter' => '|', 'utf8' => false, 'headers' => true];
} else {
- throw new Exception("Type $type not working");
+ throw new RuntimeException("Type $type not working");
}
+ // ===== DEV -(DELETE WHEN OK ON PROD) =====
+ // Use a plain CSV file (not zip). Ensure the path exists INSIDE the container.
+ // $file = '/var/www/html/var/rpps/duplicates_test_100k.csv';
+ // $output->writeln("[DEV] Using CSV (not zip): $file");
+ // if (!is_readable($file)) {$output->writeln("[DEV] File is not found or unreadable: $file");return false;}
+ // ===== END DEV HARDCODE =====
$process = $this->processFile($output, $file, $type, $options, $start, $limit);
@@ -119,11 +116,22 @@ public function importFile(OutputInterface $output, string $type, int $start = 0
$this->output->writeln('Total Matched Cities: ' . $this->matchedCitiesCount);
$this->output->writeln('Total Unmatched Cities: ' . $this->unmatchedCitiesCount);
+ if ('rpps' === $type) {
+ // Purge addresses not touched in this run (not having current importId)
+ $this->purgeStaleAddresses();
+ }
+
return $process;
}
+ protected function clearCache(): void
+ {
+ parent::clearCache();
+ // Vider le cache d'adresses à chaque batch (tous les 50 éléments)
+ $this->addressCache = [];
+ }
+
/**
- * @throws NonUniqueResultException
* @throws Exception
*/
protected function processData(array $data, string $type): ?RPPS
@@ -149,14 +157,30 @@ protected function processCPS(array $data): ?RPPS
return $rpps;
}
+ /**
+ * @throws RandomException
+ */
protected function processRPPS(array $data): ?RPPS
{
$rpps = $this->entities[$data[1]] ?? $this->repository->find($data[1]);
- if (!($rpps instanceof RPPS)) {
- $rpps = new RPPS();
+ if ($rpps instanceof RPPS) {
+ return $this->updateRppsFromRow($rpps, $data);
}
+ return $this->createRppsFromRow($data);
+ }
+
+ /**
+ * Create a new RPPS from a CSV row.
+ * - Fill the RPPS entity (title/firstName/lastName/specialty/...).
+ * - Try to create the RPPSAddress if address data is present.
+ *
+ * @throws RandomException
+ */
+ private function createRppsFromRow(array $data): RPPS
+ {
+ $rpps = new RPPS();
$rpps->setIdRpps($data[1]);
// $data[3] : DR
@@ -181,86 +205,236 @@ protected function processRPPS(array $data): ?RPPS
];
$expandedTitle = $titleMapping[$title] ?? $title;
- $rpps->setTitle($expandedTitle);
+ $rpps->setTitle($expandedTitle);
$rpps->setLastName($data[7]);
$rpps->setFirstName($data[8]);
- // Determine which specialty field to use
- if ($data[16] && in_array($data[13], ['S', 'CEX'])) {
- $specialtyName = $data[16];
- } else {
- // Fallback to $data[10] if $data[16] is not valid
- $specialtyName = $data[10];
+ $this->handleSpecialty($rpps, $data);
+
+ $this->createRppsAddress($rpps, $data);
+
+ // Contacts and numbers on RPPS remain updated
+ $rpps->setPhoneNumber(str_replace(' ', '', (string) ($data[40] ?? '')));
+ $rpps->setEmail($data[43] ?? null);
+ $rpps->setFinessNumber($data[21] ?? null);
+
+ // Set canonical only if it is not already set
+ if (!$rpps->getId() || !$rpps->getCanonical()) {
+ $canonical = $this->generateCanonical($rpps);
+ $rpps->setCanonical($canonical);
}
- if ($specialtyName) {
- $specialtyEntity = $this->findSpecialtyEntity($specialtyName);
- if ($specialtyEntity) {
- $rpps->setSpecialtyEntity($specialtyEntity);
- } else {
- // Fallback previous flow
- $rpps->setSpecialty($specialtyName);
- // Log or handle cases where the specialty is not found
- $this->output->writeln('No specialty found for: ' . $specialtyName);
- }
+ $rpps->setImportId($this->getImportId());
+
+ $this->entities[$rpps->getIdRpps()] = $rpps;
+
+ $this->em->persist($rpps);
+ // No flush here; batch flushing is handled by FileParserService
+
+ return $rpps;
+ }
+
+ /**
+ * Helper unique pour créer une RPPSAddress à partir d'une ligne CSV.
+ * - Retourne null si aucune donnée d'adresse exploitable (rien fait).
+ * - Retourne RPPSAddress si une adresse a été créée/mise à jour et persistée.
+ */
+ private function createRppsAddress(RPPS $rpps, array $data): ?RPPSAddress
+ {
+ // Build address parts from the CSV (do NOT set on RPPS; we work on RPPSAddress)
+ $addressLine = trim(($data[28] ?? '') . ' ' . ($data[31] ?? '') . ' ' . ($data[32] ?? ''));
+ $addressExt = $data[33] ?? null;
+ $zipcode = $data[35] ?? null;
+ $cityName = $data[37] ?? null;
+
+ $hasAnyAddressData = ('' !== $addressLine)
+ || (null !== $zipcode && '' !== trim((string) $zipcode))
+ || (null !== $cityName && '' !== trim((string) $cityName));
+
+ if (!$hasAnyAddressData) {
+ return null;
}
- $rpps->setAddress($data[28] . ' ' . $data[31] . ' ' . $data[32]);
- $rpps->setAddressExtension($data[33]);
- $rpps->setZipcode($data[35]);
- $rpps->setCity($data[37]);
+ // Compute MD5 (hex) for normalized address parts
+ $md5Hex = $this->computeAddressMd5Hex($addressLine, $cityName, $zipcode);
- $originalAddress = $data[28] . ' ' . $data[31] . ' ' . $data[32] . ' ' . $data[33] . ' ' . $data[35] . ' ' . $data[37];
+ // Clé de cache unique : rpps_id + md5_hash
+ $cacheKey = $rpps->getIdRpps() . '|' . $md5Hex;
- if ($originalAddress !== $rpps->getOriginalAddress()) {
- $rpps->setLatitude(null);
- $rpps->setLongitude(null);
+ // Vérifier le cache en cours de batch
+ if (isset($this->addressCache[$cacheKey])) {
+ $addr = $this->addressCache[$cacheKey];
+ $addr->setImportId($this->getImportId());
+
+ return $addr;
}
- $rpps->setOriginalAddress($originalAddress);
- $cityEntity = $this->findCityEntity($data[35], $data[37]);
+ // City resolution for the address
+ $cityEntity = $this->findCityEntity($zipcode, $cityName);
if ($cityEntity) {
- $rpps->setCityEntity($cityEntity);
++$this->matchedCitiesCount;
} else {
++$this->unmatchedCitiesCount;
}
- $rpps->setPhoneNumber(str_replace(' ', '', (string) $data[40]));
- $rpps->setEmail($data[43]);
- $rpps->setFinessNumber($data[21]);
+ // Find an existing address by (rpps, md5)
+ /** @var RPPSAddress|null $addr */
+ $addr = $this->em->getRepository(RPPSAddress::class)->findOneBy([
+ 'rpps' => $rpps,
+ 'md5Address' => $md5Hex,
+ ]);
- // Set canonical only if it is not already set
- if (!$rpps->getId() || !$rpps->getCanonical()) {
+ if ($addr) {
+ // Address already exists, update it with new import_id and return
+ $addr->setImportId($this->getImportId());
+ $this->em->persist($addr);
+
+ return $addr;
+ }
+
+ $addr = new RPPSAddress();
+ $addr->setRpps($rpps);
+ $addr->setMd5AddressHex($md5Hex);
+ $addr->setAddress($addressLine);
+ $addr->setAddressExtension($addressExt);
+ $addr->setZipcode($zipcode);
+ $addr->setCity($cityEntity);
+ $addr->refreshOriginalAddress();
+ $addr->setImportId($this->getImportId());
+
+ // Cache la nouvelle adresse pour éviter les doublons dans le batch
+ $this->addressCache[$cacheKey] = $addr;
+
+ $this->em->persist($addr);
+
+ return $addr;
+ }
+
+ /**
+ * Update flow:
+ * If there is no usable address: SKIP + LOG and return null.
+ * Try to complete missing info on the original RPPS with non-empty values from the new row
+ * (never overwrite existing data).
+ *
+ * @throws RandomException
+ */
+ private function updateRppsFromRow(RPPS $rpps, array $data): ?RPPS
+ {
+ // Address first: if none -> skip entire update with log
+ $rppsAddress = $this->createRppsAddress($rpps, $data);
+ if (!$rppsAddress) {
+ $this->output->writeln(
+ sprintf(
+ '[RPPS import] Skipping empty address for idRpps=%s | line=%s',
+ $data[1] ?? '',
+ implode('|', array_map(static fn ($v) => (string) $v, $data))
+ )
+ );
+
+ return null;
+ }
+
+ // Complete (never overwrite) identity/title
+ // Priority: [libellé exercice, code exercice, libellé civilité, code civilité]
+ if (!$rpps->getTitle()) {
+ $title = null;
+ foreach ([$data[4] ?? null, $data[3] ?? null, $data[6] ?? null, $data[5] ?? null] as $candidate) {
+ if (!empty($candidate)) {
+ $title = $candidate;
+ break;
+ }
+ }
+ if ($title) {
+ $map = ['M' => 'Monsieur', 'DR' => 'Docteur', 'MME' => 'Madame', 'PR' => 'Professeur'];
+ $expanded = $map[$title] ?? $title;
+ $rpps->setTitle($expanded);
+ }
+ }
+
+ if (!empty($data[7]) && !$rpps->getLastName()) {
+ $rpps->setLastName($data[7]);
+ }
+ if (!empty($data[8]) && !$rpps->getFirstName()) {
+ $rpps->setFirstName($data[8]);
+ }
+
+ // Complete specialty only if none set (entity nor legacy)
+ if (!$rpps->getSpecialtyEntity() && !$rpps->getSpecialty()) {
+ $this->handleSpecialty($rpps, $data);
+ }
+
+ // Contacts: complete if missing only
+ $newPhone = isset($data[40]) ? str_replace(' ', '', (string) $data[40]) : null;
+ if (!empty($newPhone) && !$rpps->getPhoneNumber()) {
+ $rpps->setPhoneNumber($newPhone);
+ }
+
+ $newEmail = $data[43] ?? null;
+ if (!empty($newEmail) && !$rpps->getEmail()) {
+ $rpps->setEmail($newEmail);
+ }
+
+ $newFiness = $data[21] ?? null;
+ if (!empty($newFiness) && !$rpps->getFinessNumber()) {
+ $rpps->setFinessNumber($newFiness);
+ }
+
+ // Canonical: set only if empty
+ if (!$rpps->getCanonical()) {
$canonical = $this->generateCanonical($rpps);
$rpps->setCanonical($canonical);
}
+
+ // Keep import trace if desired
$rpps->setImportId($this->getImportId());
$this->entities[$rpps->getIdRpps()] = $rpps;
-
$this->em->persist($rpps);
- $this->em->flush();
return $rpps;
}
+ private function handleSpecialty(RPPS $rpps, array $data): void
+ {
+ // Determine which specialty field to use
+ if ($data[16] && in_array($data[13], ['S', 'CEX'])) {
+ $specialtyName = $data[16];
+ } else {
+ // Fallback to $data[10] if $data[16] is not valid
+ $specialtyName = $data[10];
+ }
+
+ if ($specialtyName) {
+ $specialtyEntity = $this->findSpecialtyEntity($specialtyName);
+ if ($specialtyEntity) {
+ $rpps->setSpecialtyEntity($specialtyEntity);
+ } else {
+ // Fallback previous flow
+ $rpps->setSpecialty($specialtyName);
+ // Log or handle cases where the specialty is not found
+ $this->output->writeln('No specialty found for: ' . $specialtyName);
+ }
+ }
+ }
+
private function findSpecialtyEntity(string $specialtyName): ?Specialty
{
$specialtyName = trim($specialtyName);
- // Check for exact match
+ // Check for the exact match
if (isset($this->specialtyByName[$specialtyName])) {
// Fetch from DB to ensure we have the most up-to-date entity,
// avoiding memory overhead of storing full entities in the hashmap.
- // If we keep assigning the same entity in batch processing, form the hashmap value, somehow doctrine will not be happy.
+ // If we keep assigning the same entity in batch processing,
+ // form the hashmap value, somehow doctrine will not be happy.
return $this->em->getRepository(Specialty::class)->findOneBy(['name' => $specialtyName]);
}
// Check for alternative name match using the static array
if (isset($this->specialtyByAltName[$specialtyName])) {
- return $this->em->getRepository(Specialty::class)->findOneBy(['name' => $this->specialtyByAltName[$specialtyName]]);
+ return $this->em->getRepository(Specialty::class)
+ ->findOneBy(['name' => $this->specialtyByAltName[$specialtyName]]);
}
// Log or handle case when no match is found
@@ -279,7 +453,7 @@ private function findCityEntity(mixed $zipCode, mixed $cityName): ?City
// Find by postal code
$cities = $this->em->getRepository(City::class)->findBy(['postalCode' => $zipCode]);
} else {
- // Find by city name (lowercase comparison)
+ // Find by city name (lowercase comparison)
$cities = $this->em->getRepository(City::class)->createQueryBuilder('c')
->where('LOWER(c.name) = :cityName OR LOWER(c.altName) = :cityName')
->setParameter('cityName', strtolower($cityName)) // Lowercase the input city name
@@ -298,11 +472,13 @@ private function findCityEntity(mixed $zipCode, mixed $cityName): ?City
// Try to find a city matching the normalized name
if ($cityName) {
- $normalizedCityName = u($cityName)->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
+ $normalizedCityName = u($cityName)
+ ->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
// Check for matching city name
- $matchingNameCities = array_filter($cities, function ($city) use ($normalizedCityName) {
- $n2 = u($city->getName())->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
+ $matchingNameCities = array_filter($cities, static function ($city) use ($normalizedCityName) {
+ $n2 = u($city->getName())
+ ->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
return $n2 === $normalizedCityName;
});
@@ -311,8 +487,8 @@ private function findCityEntity(mixed $zipCode, mixed $cityName): ?City
return array_pop($matchingNameCities);
}
- // From the cities with same name, is there a unique main city?
- $matchingNameMainCities = array_filter($matchingNameCities, function ($city) {
+ // From the cities with the same name, is there a unique main city?
+ $matchingNameMainCities = array_filter($matchingNameCities, static function ($city) {
return $city->isMainCity();
});
@@ -320,13 +496,14 @@ private function findCityEntity(mixed $zipCode, mixed $cityName): ?City
return array_pop($matchingNameMainCities);
}
- // Check for matching sub-city name
- $matchingSubCities = array_filter($matchingNameCities, function ($city) use ($normalizedCityName) {
+ // Check for matching subcity name
+ $matchingSubCities = array_filter($matchingNameCities, static function ($city) use ($normalizedCityName) {
if (null === $city->getSubCityName()) {
return false;
}
- $normalized = u($city->getSubCityName())->trim()->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
+ $normalized = u($city->getSubCityName())
+ ->trim()->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
return $normalized === $normalizedCityName;
});
@@ -338,7 +515,7 @@ private function findCityEntity(mixed $zipCode, mixed $cityName): ?City
// Try to find a main city with the same zip code
if (!empty($zipCode)) {
- $mainCities = array_filter($cities, function ($city) {
+ $mainCities = array_filter($cities, static function ($city) {
return $city->isMainCity();
});
@@ -359,27 +536,65 @@ private function findCityEntity(mixed $zipCode, mixed $cityName): ?City
* The canonical format is "firstname-lastname-city-zipcode".
* If duplicates are found, a numerical suffix is added to ensure uniqueness,
* e.g., "anatole-cessot-neuilly-sur-seine-92200", "anatole-cessot-neuilly-sur-seine-92200-2".
+ *
+ * @throws RandomException
*/
private function generateCanonical(RPPS $rpps): string
{
- $canonicalBase = u(implode('-', [
+ // Try to get city/zipcode from RPPSAddress first (preferred), then legacy fields
+ $city = null;
+ $zipcode = null;
+
+ // Check if RPPS has addresses - use the first one available
+ if (!$rpps->getAddresses()->isEmpty()) {
+ $firstAddress = $rpps->getAddresses()->first();
+ $city = $firstAddress->getCity();
+ $zipcode = $firstAddress->getZipcode();
+ }
+
+ // Fallback to legacy fields if no address data
+ if (!$city && !$zipcode) {
+ $city = $rpps->getCity();
+ $zipcode = $rpps->getZipcode();
+ }
+
+ // Build parts array from available data
+ $parts = array_filter([
$rpps->getFirstName(),
$rpps->getLastName(),
- $rpps->getCity(),
- $rpps->getZipcode(),
- ]))->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
+ $city,
+ $zipcode,
+ ], static fn ($p) => null !== $p && '' !== $p);
+
+ // Fallback to idRpps if no usable data
+ if (empty($parts)) {
+ $idRpps = $rpps->getIdRpps();
+ if ($idRpps) {
+ $parts = ['rpps', $idRpps];
+ } else {
+ // Ultimate fallback with timestamp for guaranteed uniqueness
+ $parts = ['unknown', 'user', (string) time(), (string) random_int(1000, 9999)];
+ }
+ }
+ $base = u(implode('-', $parts))
+ ->lower()->ascii()->replace('_', '-')->replace(' ', '-')->replace('--', '-')->toString();
+ $base = trim($base, '-');
- $canonicalBase = trim($canonicalBase, '-');
- $canonical = $canonicalBase;
+ // Ensure we have something to work with
+ if ('' === $base) {
+ $base = 'fallback-' . time();
+ }
+
+ $canonical = $base;
$suffix = 1;
- // Check if canonical already exists and add suffix if needed
+ // Handle duplicates with numerical suffix
while ($this->canonicalExists($canonical)) {
++$suffix;
- $canonical = $canonicalBase . '-' . $suffix;
+ $canonical = $base . '-' . $suffix;
}
- // Add the generated canonical to the hashmap to prevent future duplicates
+ // Cache the result
$this->existingCanonicals[$canonical] = true;
return $canonical;
@@ -387,13 +602,26 @@ private function generateCanonical(RPPS $rpps): string
private function canonicalExists(string $canonical): bool
{
+ // Hit cache
if (isset($this->existingCanonicals[$canonical])) {
return true;
}
- $existing = $this->em->getConnection()->fetchOne('SELECT 1 FROM rpps WHERE canonical = ?', [$canonical]);
+ // Trim cache si nécessaire
+ $this->manageCanonicalCacheMemory();
+
+ try {
+ $exists = $this->em->getConnection()->fetchOne(
+ 'SELECT 1 FROM rpps WHERE canonical = ? LIMIT 1',
+ [$canonical]
+ );
+ } catch (Throwable $e) {
+ $this->output->writeln("DB error on canonical check: {$e->getMessage()}");
+
+ return false;
+ }
- if ($existing) {
+ if ($exists) {
$this->existingCanonicals[$canonical] = true;
return true;
@@ -401,4 +629,67 @@ private function canonicalExists(string $canonical): bool
return false;
}
+
+ private function computeAddressMd5Hex(?string $address, ?string $city, ?string $zip): string
+ {
+ $normAddr = $this->normalizeText($address);
+ $normCity = $this->normalizeText($city);
+ $normZip = $this->normalizeText($zip);
+
+ $toHash = $normAddr . '|' . $normCity . '|' . $normZip;
+
+ return md5($toHash);
+ }
+
+ private function normalizeText(?string $value): string
+ {
+ if (null === $value) {
+ return '';
+ }
+
+ $v = trim(preg_replace('#\s+#', ' ', $value));
+ if ('' === $v || '0' === $v) {
+ return '';
+ }
+
+ $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v);
+ if (false === $ascii) {
+ $ascii = $v;
+ }
+
+ return strtolower($ascii);
+ }
+
+ /**
+ * Hard delete all addresses not updated in the current run (importId != current).
+ *
+ * @throws \Doctrine\DBAL\Exception
+ */
+ private function purgeStaleAddresses(): void
+ {
+ $currentImportId = $this->getImportId();
+ $this->output->writeln(
+ "Purging stale addresses for current import id: $currentImportId..."
+ );
+
+ $deleted = $this->em->getConnection()->executeStatement(
+ 'DELETE FROM rpps_address WHERE import_id != :currentImportId',
+ ['currentImportId' => $currentImportId]
+ );
+
+ $this->output->writeln("Stale addresses deleted: $deleted");
+ }
+
+ private function manageCanonicalCacheMemory(): void
+ {
+ if (count($this->existingCanonicals) >= self::MAX_CANONICAL_CACHE_SIZE) {
+ // Garde seulement les 25% les plus récents (stratégie LRU simplifiée)
+ $keepCount = (int) (self::MAX_CANONICAL_CACHE_SIZE * 0.25);
+ $this->existingCanonicals = array_slice($this->existingCanonicals, -$keepCount, null, true);
+
+ $this->output->writeln(
+ "Canonical cache cleared. Kept $keepCount entries to prevent memory issues."
+ );
+ }
+ }
}
diff --git a/tests/Functional/ApiTestCase.php b/tests/Functional/ApiTestCase.php
index 3751bd5..0f5d99b 100755
--- a/tests/Functional/ApiTestCase.php
+++ b/tests/Functional/ApiTestCase.php
@@ -19,6 +19,7 @@
use App\DataFixtures\LoadNGAP;
use App\DataFixtures\LoadRegion;
use App\DataFixtures\LoadRPPS;
+use App\DataFixtures\LoadRPPSAddress;
use App\DataFixtures\LoadSpecialty;
use App\Entity\City;
use App\Entity\Specialty;
@@ -59,6 +60,7 @@ abstract class ApiTestCase extends \ApiPlatform\Symfony\Bundle\Test\ApiTestCase
LoadInseeCommune1943::class,
LoadInseePays::class,
LoadInseePays1943::class,
+ LoadRPPSAddress::class,
];
protected function setUp(): void
@@ -282,4 +284,11 @@ protected function getCity(string $canonical = 'paris'): ?City
{
return $this->em->getRepository(City::class)->findOneBy(['canonical' => $canonical]);
}
+
+ protected function assertArrayHasKeys(array $array, array $keys): void
+ {
+ foreach ($keys as $key) {
+ $this->assertArrayHasKey($key, $array, "Missing key: $key");
+ }
+ }
}
diff --git a/tests/Functional/RPPSTest.php b/tests/Functional/RPPSTest.php
index f2f400b..2c03f5c 100755
--- a/tests/Functional/RPPSTest.php
+++ b/tests/Functional/RPPSTest.php
@@ -1,24 +1,17 @@
get("rpps");
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
-
- $this->assertCollectionKeyContains(
- $data['hydra:member'],
- "firstName",
- ["Bastien", "Emilie", "Jérémie"]
- );
- $this->assertCollectionKeyContains($data['hydra:member'], "lastName", ["TEST"]);
+ $this->get('rpps');
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ $data = $this->get('rpps');
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Vérifie la structure Hydra de base
+ $this->assertArrayHasKey('hydra:member', $data);
+ $this->assertIsArray($data['hydra:member']);
+ $this->assertArrayHasKey('hydra:totalItems', $data);
+ $this->assertIsInt($data['hydra:totalItems']);
+ $this->assertGreaterThanOrEqual(1, $data['hydra:totalItems']);
+
+ // Nous testons la forme des éléments (pas les valeurs exactes).
+ $this->assertGreaterThanOrEqual(1, count($data['hydra:member']));
+
+ // Échantillonne 1 élément (le premier) pour valider la forme
+ $item = $data['hydra:member'][0];
+
+ // Clés essentielles de l'item
+ $this->assertArrayHasKeys($item, [
+ '@id',
+ '@type',
+ 'id',
+ 'canonical',
+ 'idRpps',
+ 'lastName',
+ 'firstName',
+ 'title',
+ 'phoneNumber',
+ 'email',
+ 'specialty',
+ 'specialtyEntity',
+ 'addresses',
+ // Legacy flatten
+ 'address',
+ 'addressExtension',
+ 'zipcode',
+ 'city',
+ 'latitude',
+ 'longitude',
+ 'coordinates',
+ ]);
- // Removing Paramedical
- $this->assertCollectionKeyNotContains($data['hydra:member'], "firstName", ["Achile","Julien"]);
+ // Types basiques
+ $this->assertIsString($item['idRpps']);
+ $this->assertIsString($item['canonical']);
+ $this->assertIsString($item['lastName']);
+ $this->assertIsString($item['firstName']);
+ $this->assertIsArray($item['specialtyEntity']);
+ $this->assertArrayHasKey('name', $item['specialtyEntity']);
+ $this->assertArrayHasKey('canonical', $item['specialtyEntity']);
+ $this->assertIsArray($item['addresses']);
+
+ // Si des adresses existent, vérifie la forme d'une adresse
+ if (!empty($item['addresses'])) {
+ $addr = $item['addresses'][0];
+ $this->assertArrayHasKeys($addr, [
+ '@id',
+ '@type',
+ 'id',
+ 'address',
+ 'zipcode',
+ 'originalAddress',
+ 'city',
+ 'latitude',
+ 'longitude',
+ 'coordinates',
+ ]);
+ $this->assertIsArray($addr['city']);
+ $this->assertArrayHasKey('name', $addr['city']);
+ $this->assertArrayHasKey('canonical', $addr['city']);
+ $this->assertIsArray($addr['coordinates']);
+ $this->assertArrayHasKey('latitude', $addr['coordinates']);
+ $this->assertArrayHasKey('longitude', $addr['coordinates']);
+ }
}
+ /**
+ * @throws ClientExceptionInterface
+ * @throws RedirectionExceptionInterface
+ * @throws ServerExceptionInterface
+ * @throws TransportExceptionInterface
+ */
+ public function testGetOneRppsData(): void
+ {
+ $data = $this->get('rpps/' . LoadRPPS::RPPS_USER_1);
+
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Champs de base
+ $this->assertEquals('fixture-canonical-0', $data['canonical']);
+ $this->assertEquals(LoadRPPS::RPPS_USER_1, $data['idRpps']);
+ $this->assertEquals('Docteur', $data['title']);
+ $this->assertEquals('OCHROME', $data['lastName']); // uppercased by accessor
+ $this->assertEquals('Mercure', $data['firstName']);
+ $this->assertEquals('+33123456789', $data['phoneNumber']);
+ $this->assertEquals('mercure.ochrome@example.test', $data['email']);
+
+ // Specialty legacy + v2
+ $this->assertEquals('Médecine Générale', $data['specialty']);
+ $this->assertArrayHasKey('specialtyEntity', $data);
+ $this->assertEquals('Médecine Générale', $data['specialtyEntity']['name']);
+ $this->assertEquals('medecine-generale', $data['specialtyEntity']['canonical']);
+
+ // Adresses RPPS (v2)
+ $this->assertArrayHasKey('addresses', $data);
+ $this->assertNotEmpty($data['addresses']);
+ $this->assertGreaterThanOrEqual(2, count($data['addresses']));
+
+ // Indexer par libellé (ordre non garanti)
+ $byAddress = [];
+ foreach ($data['addresses'] as $addr) {
+ $byAddress[$addr['address']] = $addr;
+ }
+
+ // Adresse complète 1
+ $a1 = $byAddress['10 Rue de la Paix'] ?? null;
+ $this->assertNotNull($a1, 'Expected address "10 Rue de la Paix" not found');
+ $this->assertEquals('Bât A', $a1['addressExtension']);
+ $this->assertEquals('75002', $a1['zipcode']);
+ $this->assertEquals('10 Rue de la Paix Bât A 75002 Paris', $a1['originalAddress']);
+ $this->assertEquals('Paris', $a1['city']['name']);
+ $this->assertEquals('paris-2eme', $a1['city']['canonical']);
+ $this->assertEquals(48.8686, $a1['latitude']);
+ $this->assertEquals(2.3314, $a1['longitude']);
+ $this->assertEquals(48.8686, $a1['coordinates']['latitude']);
+ $this->assertEquals(2.3314, $a1['coordinates']['longitude']);
+
+ // Adresse complète 2
+ $a2 = $byAddress['25 Avenue des Champs'] ?? null;
+ $this->assertNotNull($a2, 'Expected address "25 Avenue des Champs" not found');
+ $this->assertEquals('Bât B', $a2['addressExtension']);
+ $this->assertEquals('75008', $a2['zipcode']);
+ $this->assertEquals('25 Avenue des Champs Bât B 75008 Paris', $a2['originalAddress']);
+ $this->assertEquals('Paris', $a2['city']['name']);
+ $this->assertEquals('paris-8eme', $a2['city']['canonical']);
+ $this->assertEquals(48.870637, $a2['latitude']);
+ $this->assertEquals(2.318747, $a2['longitude']);
+ $this->assertEquals(48.870637, $a2['coordinates']['latitude']);
+ $this->assertEquals(2.318747, $a2['coordinates']['longitude']);
+
+ // Legacy flatten : DOIT refléter la première adresse renvoyée
+ $primary = $data['addresses'][0];
+ $this->assertEquals($primary['address'], $data['address']);
+ $this->assertEquals($primary['zipcode'], $data['zipcode']);
+ $this->assertEquals($primary['cityName'] ?? $primary['city']['name'], $data['city']);
+ $this->assertEquals($primary['addressExtension'] ?? null, $data['addressExtension'] ?? null);
+ $this->assertEquals($primary['latitude'], $data['latitude']);
+ $this->assertEquals($primary['longitude'], $data['longitude']);
+ $this->assertEquals($primary['coordinates']['latitude'], $data['coordinates']['latitude']);
+ $this->assertEquals($primary['coordinates']['longitude'], $data['coordinates']['longitude']);
+ }
/**
- * @group
+ * Purpose: ensure the "demo" filter returns ONLY demo RPPS.
+ * Implementation detail: demo RPPS is identified by idRpps starting with "2" (see RPPSFilter::addDemoFilter).
+ *
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
- public function testSearchRppsData(): void
+ public function testWithDemoTrueReturnsRppsDemoData(): void
{
+ $data = $this->get('rpps', ['demo' => true]);
- // Search by first name - partial match
- $data = $this->get("rpps", [
- 'search' => "Bas"
- ]);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Hydra structure sanity checks
+ $this->assertArrayHasKey('hydra:member', $data, 'Missing hydra:member');
+ $this->assertIsArray($data['hydra:member'], 'hydra:member must be an array');
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ // We expect at least one demo entry
+ $this->assertNotEmpty($data['hydra:member'], 'Expected at least one demo RPPS');
- $this->assertCollectionKeyContains($data['hydra:member'], "firstName", ["Bastien"]);
- $this->assertCollectionKeyNotContains($data['hydra:member'], "firstName", ["Julien", "Emilie", "Jérémie"]);
- $this->assertCollectionKeyContains($data['hydra:member'], "lastName", ["TEST"]);
+ // All returned RPPS must start with "2"
+ foreach ($data['hydra:member'] as $item) {
+ $this->assertArrayHasKey('idRpps', $item, 'RPPS item must have idRpps');
+ $this->assertIsString($item['idRpps']);
+ $this->assertStringStartsWith('2', $item['idRpps'], 'All demo results must have idRpps starting with "2"');
+ }
- $this->assertCollectionKeyContains($data['hydra:member'], "canonical", ["fixture-canonical-0"]);
+ // Optionally: assert at least two demo entries if your fixtures guarantee that
+ $this->assertCount(2, $data['hydra:member']);
+ }
- // Legacy specialty
- $this->assertCollectionKeyContains($data['hydra:member'], "specialty", ["Médecine Générale"]);
+ /**
+ * Purpose: ensure the "demo=false" filter excludes demo RPPS.
+ * Implementation detail: demo RPPS are identified by idRpps starting with "2", so they must NOT appear here.
+ *
+ * @throws ClientExceptionInterface
+ * @throws RedirectionExceptionInterface
+ * @throws ServerExceptionInterface
+ * @throws TransportExceptionInterface
+ */
+ public function testWithDemoFalseDoesNotReturnRppsDemoData(): void
+ {
+ // include_paramedical is orthogonal and should not affect demo filtering; we keep it if needed elsewhere
+ $data = $this->get('rpps', [
+ 'demo' => false,
+ 'include_paramedical' => true,
+ ]);
- // Specialty v2
- $specialty = $data['hydra:member'][0]['specialtyEntity'];
- $this->assertEquals("Médecine Générale", $specialty['name']);
- $this->assertEquals("medecine-generale", $specialty['canonical']);
- $this->assertEquals("Médecin généraliste", $specialty['specialistName']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
- // Legacy city
- $this->assertCollectionKeyContains($data['hydra:member'], "city", ["Paris"]);
+ // Hydra structure sanity checks
+ $this->assertArrayHasKey('hydra:member', $data, 'Missing hydra:member');
+ $this->assertIsArray($data['hydra:member'], 'hydra:member must be an array');
- // City v2
- $specialty = $data['hydra:member'][0]['cityEntity'];
- $this->assertEquals("Paris", $specialty['name']);
- $this->assertEquals("Paris 04", $specialty['subCityName']);
- $this->assertEquals("75004", $specialty['postalCode']);
+ // If there are any results, ensure none is a "demo" RPPS
+ foreach ($data['hydra:member'] as $item) {
+ $this->assertArrayHasKey('idRpps', $item, 'RPPS item must have idRpps');
+ $this->assertIsString($item['idRpps']);
+ $this->assertFalse(
+ str_starts_with($item['idRpps'], '2'),
+ 'Non-demo results must NOT include idRpps starting with "2"'
+ );
+ }
- $this->assertCount(1, $data['hydra:member']);
+ // Optional: ensure we got at least one non-demo entry (depends on your fixtures)
+ $this->assertGreaterThanOrEqual(1, count($data['hydra:member']));
}
-
/**
+ * Purpose: ensure the "excluded_rpps" filter excludes one or several RPPS ids from the collection.
+ * Notes:
+ * - We use ids that are actually present in the current fixtures:
+ * * '10101485653' (RPPS_USER_1 - non-demo)
+ * * '21234567890' (demo).
+ *
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
- public function testWithDemoTrueReturnsRppsDemoData() : void
+ public function testExcludedRppsFilter(): void
{
- $data = $this->get("rpps", [
- 'demo' => true
- ]);
+ // Single exclusion: exclude one known idRpps (non-demo)
+ $excludedSingle = '10101485653';
+ $data = $this->get('rpps', ['excluded_rpps' => $excludedSingle]);
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertCollectionKeyContains($data['hydra:member'], "firstName", ["Emilie"]);
- $this->assertCollectionKeyNotContains($data['hydra:member'], "firstName", ["Julien", "Jérémie"]);
- }
+ // Assert the excluded RPPS is not present
+ $this->assertCollectionKeyNotContains($data['hydra:member'], 'idRpps', [$excludedSingle]);
+ // And we still have results (assuming fixtures contain other RPPS)
+ $this->assertGreaterThanOrEqual(1, count($data['hydra:member']), 'Expected remaining RPPS');
+
+ // Multiple exclusions: exclude one non-demo + one demo idRpps
+ $excludedMultiple = ['10101485653', '21234567890'];
+ $data = $this->get('rpps', ['excluded_rpps' => $excludedMultiple]);
+
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Assert all excluded RPPS are not present
+ $this->assertCollectionKeyNotContains($data['hydra:member'], 'idRpps', $excludedMultiple);
+
+ // Optional: if you expect more entries in fixtures, ensure at least one remains
+ $this->assertGreaterThanOrEqual(1, count($data['hydra:member']));
+ }
/**
+ * Purpose: validate the "search" filter using the new model (no legacy fields).
+ * Notes:
+ * - Search currently matches:
+ * * fullName (" ") with a prefix match
+ * * fullNameInversed (" ") with a prefix match
+ * * idRpps (exact match)
+ * - We therefore use a prefix of the first name to ensure a predictable hit.
+ *
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
- public function testWithDemoFalseDoesNotReturnRppsDemoData() : void
+ public function testSearchRppsData(): void
{
- $data = $this->get("rpps", [
- 'demo' => false,
- 'include_paramedical' => true
+ // Search by first name prefix (matches fullName prefix): should find "Mercure Ochrome"
+ $data = $this->get('rpps', [
+ 'search' => 'Mer',
]);
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // We expect a single item: "Mercure Ochrome"
+ $this->assertCount(1, $data['hydra:member'], 'Search should return exactly one RPPS for "Mer"');
+
+ // The one result should be our expected item
+ $first = $data['hydra:member'][0];
- $this->assertCollectionKeyContains($data['hydra:member'], "firstName", ["Julien", "Jérémie"]);
- $this->assertCollectionKeyNotContains($data['hydra:member'], "firstName", ["Emilie"]);
+ // Basic identity checks
+ $this->assertArrayHasKeys($first, [
+ 'firstName',
+ 'lastName',
+ 'idRpps',
+ 'canonical',
+ 'specialtyEntity',
+ 'addresses',
+ ]);
+ $this->assertSame('Mercure', $first['firstName']);
+ $this->assertSame('OCHROME', $first['lastName']); // uppercased by accessor
+ $this->assertSame('fixture-canonical-0', $first['canonical']);
+
+ // Negative checks
+ $this->assertCollectionKeyNotContains($data['hydra:member'], 'firstName', ['Emilie', 'Jeremie']);
+
+ // Tricky case: searching a mid-token should NOT match (prefix logic)
+ // "rcu" is inside "Mercure" but not a prefix of fullName/fullNameInversed
+ $noMidToken = $this->get('rpps', ['search' => 'rcu']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(0, $noMidToken['hydra:member'], 'Mid-token search should not match with prefix logic');
}
+ /**
+ * Purpose: verify that searching by RPPS number works as an exact match (no partial match).
+ * We cover:
+ * - Exact match on a non-demo RPPS (10101485653 → Mercure OCHROME)
+ * - Exact match on a demo RPPS (21234567890 → Emilie Demo)
+ * - Negative cases: partial match (should return 0), wrong id (0 results).
+ *
+ * @throws ClientExceptionInterface
+ * @throws RedirectionExceptionInterface
+ * @throws ServerExceptionInterface
+ * @throws TransportExceptionInterface
+ */
+ public function testSearchByRppsNumber(): void
+ {
+ // Case 1: exact idRpps (non-demo)
+ $data = $this->get('rpps', ['search' => '10101485653']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ $this->assertCount(1, $data['hydra:member'], 'Exact idRpps search should return exactly one result');
+
+ $rpps = $data['hydra:member'][0];
+ $this->assertArrayHasKeys($rpps, ['firstName', 'lastName', 'idRpps', 'canonical']);
+ $this->assertSame('10101485653', $rpps['idRpps']);
+ $this->assertSame('Mercure', $rpps['firstName']);
+ $this->assertSame('OCHROME', $rpps['lastName']); // accessor uppercases lastName
+
+ // Case 2: exact idRpps (demo)
+ $data = $this->get('rpps', ['search' => '21234567890']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(1, $data['hydra:member'], 'Exact idRpps search should return exactly one result');
+
+ $demo = $data['hydra:member'][0];
+ $this->assertArrayHasKeys($demo, ['firstName', 'lastName', 'idRpps']);
+ $this->assertSame('21234567890', $demo['idRpps']);
+ $this->assertSame('Emilie', $demo['firstName']);
+ $this->assertSame('DEMO', $demo['lastName']);
+
+ // Negative case 1: partial id should NOT match
+ $data = $this->get('rpps', ['search' => '1010148565']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(0, $data['hydra:member'], 'Partial idRpps must not match');
+
+ // Negative case 2: unknown id
+ $data = $this->get('rpps', ['search' => '99999999999']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(0, $data['hydra:member'], 'Unknown idRpps should return 0 results');
+ }
/**
+ * Validate the "first_letter" filter (prefix on lastName, case-insensitive behavior expected by DB collation).
+ * We cover:
+ * - Basic prefix "O" → returns only "OCHROME" (Mercure)
+ * - Prefix "D"/"d" → returns both DEMO entries (Emilie, Jeremie), and excludes others
+ * - Negative case: an unused prefix returns 0 results.
+ *
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
- public function testGetOneRppsData()
+ public function testFilterByFirstLetter(): void
{
- $data = $this->get("rpps/11111111111");
+ // Case 1: "O" should return only Ochrome (Mercure)
+ $data = $this->get('rpps', ['first_letter' => 'O']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertEquals("Bastien", $data['firstName']);
- $this->assertEquals("TEST", $data['lastName']);
+ $this->assertCount(
+ 1,
+ $data['hydra:member'],
+ 'Expected exactly one RPPS with lastName starting by "O"'
+ );
+
+ $only = $data['hydra:member'][0];
+ $this->assertArrayHasKeys($only, ['lastName', 'firstName', 'idRpps']);
+ $this->assertSame('OCHROME', $only['lastName']);
+ $this->assertSame('Mercure', $only['firstName']);
+ $this->assertSame('10101485653', $only['idRpps']);
+
+ // Case 2: "D" should return both DEMO entries (Emilie, Jeremie)
+ $data = $this->get('rpps', ['first_letter' => 'D']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ $this->assertArrayHasKey('hydra:member', $data);
+ $this->assertIsArray($data['hydra:member']);
+ $this->assertGreaterThanOrEqual(
+ 2,
+ count($data['hydra:member']),
+ 'Expected at least two RPPS with lastName starting by "D"'
+ );
+
+ // All last names must be DEMO
+ $this->assertCollectionKeyContains($data['hydra:member'], 'lastName', ['DEMO']);
+ // Ensure both demo ids are present
+ $this->assertCollectionKeyContains($data['hydra:member'], 'idRpps', ['21234567890', '20987654321']);
+ // And Ochrome is not present
+ $this->assertCollectionKeyNotContains($data['hydra:member'], 'lastName', ['OCHROME']);
+
+ // Case 2b: lower-case "d" behaves the same (collation/LIKE)
+ $data = $this->get('rpps', ['first_letter' => 'd']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCollectionKeyContains($data['hydra:member'], 'idRpps', ['21234567890', '20987654321']);
+ $this->assertCollectionKeyNotContains($data['hydra:member'], 'lastName', ['OCHROME']);
+
+ // Negative: "Z" returns no results
+ $data = $this->get('rpps', ['first_letter' => 'Z']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(0, $data['hydra:member'], 'Unexpected results for unused prefix "Z"');
}
/**
+ * Purpose: validate the "specialty" filter using specialty canonical.
+ * We cover:
+ * - Positive: filtering by 'medecine-generale' returns only RPPS whose specialtyEntity matches it
+ * - Negative: filtering by a non-existent canonical returns 0 items.
+ *
+ * Assumptions (fixtures):
+ * - All three RPPS in fixtures are assigned to 'medecine-generale'
+ *
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
- public function testSearchByRppsNumber(): void
+ public function testFilterBySpecialty(): void
{
- $data = $this->get("rpps", [
- 'search' => "12222222222"
- ]);
+ // Positive: filter by an existing specialty canonical
+ $canonical = 'medecine-generale';
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertCount(1, $data['hydra:member']);
+ $data = $this->get('rpps', ['specialty' => $canonical]);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
- $rpps = $data['hydra:member'][0];
- $this->assertEquals("Jérémie", $rpps['firstName']);
- $this->assertEquals("TEST", $rpps['lastName']);
- $this->assertEquals("12222222222", $rpps['idRpps']);
+ // With current fixtures, we expect 4 entries (2 non-demo, 2 demo)
+ $this->assertCount(
+ 4,
+ $data['hydra:member'],
+ 'Expected exactly 4 RPPS for medecine-generale'
+ );
+
+ // All results must have the requested specialty canonical
+ foreach ($data['hydra:member'] as $item) {
+ $this->assertArrayHasKey('specialtyEntity', $item);
+ $this->assertIsArray($item['specialtyEntity']);
+ $this->assertArrayHasKey('canonical', $item['specialtyEntity']);
+ $this->assertSame(
+ $canonical,
+ $item['specialtyEntity']['canonical'],
+ 'Returned RPPS has a different specialty than requested'
+ );
+ }
+
+ // Negative: filter by an unknown canonical → no result
+ $data = $this->get('rpps', ['specialty' => 'unknown-canonical-specialty']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(0, $data['hydra:member'], 'Unknown specialty should return 0 RPPS');
+ }
+ /**
+ * Purpose: validate the "city" filter using the addresses list (RPPSAddress.city), not the legacy RPPS.cityEntity.
+ * We cover:
+ * - Positive: filtering by 'paris-2eme' returns the RPPS that has an address in that city
+ * - Positive: filtering by 'paris-8eme' also returns the same RPPS (has multiple addresses)
+ * - Negative: unknown city canonical returns 0 items.
+ *
+ * Notes:
+ * - Fixtures attach two addresses to RPPS 10101485653: paris-2eme and paris-8eme
+ * - Demo RPPS have no addresses in fixtures and should not appear for city filtering
+ *
+ * @throws ClientExceptionInterface
+ * @throws RedirectionExceptionInterface
+ * @throws ServerExceptionInterface
+ * @throws TransportExceptionInterface
+ */
+ public function testFilterByCityUsingAddresses(): void
+ {
+ // Case 1: City = paris-2eme
+ $data = $this->get('rpps', ['city' => 'paris-2eme']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ $this->assertCount(1, $data['hydra:member'], 'Expected exactly one RPPS in paris-2eme');
+
+ $item = $data['hydra:member'][0];
+ $this->assertArrayHasKeys($item, ['idRpps', 'addresses']);
+ $this->assertSame('10101485653', $item['idRpps'], 'Only the RPPS with Paris addresses should match');
+
+ // Ensure at least one matching address has the requested city canonical
+ $hasCity = false;
+ foreach ($item['addresses'] as $addr) {
+ if (($addr['city']['canonical'] ?? null) === 'paris-2eme') {
+ $hasCity = true;
+ break;
+ }
+ }
+ $this->assertTrue($hasCity, 'At least one address must be in paris-2eme');
+
+ // Case 2: City = paris-8eme (same RPPS should match due to second address)
+ $data = $this->get('rpps', ['city' => 'paris-8eme']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(1, $data['hydra:member'], 'Expected exactly one RPPS in paris-8eme');
+
+ $item = $data['hydra:member'][0];
+ $this->assertSame('10101485653', $item['idRpps']);
+
+ $hasCity = false;
+ foreach ($item['addresses'] as $addr) {
+ if (($addr['city']['canonical'] ?? null) === 'paris-8eme') {
+ $hasCity = true;
+ break;
+ }
+ }
+ $this->assertTrue($hasCity, 'At least one address must be in paris-8eme');
+
+ // Negative: unknown canonical
+ $data = $this->get('rpps', ['city' => 'unknown']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertCount(0, $data['hydra:member'], 'Unknown city canonical should return 0 results');
+ }
- //Check partial search not working on idRpps
- $data = $this->get("rpps", [
- 'search' => "1222222222"
+ /**
+ * Purpose: validate latitude/longitude filtering uses RPPSAddress coordinates (cabinet),
+ * not legacy RPPS coords. We keep the existing preparation logic (fixed 30km radius),
+ * and only assert that the right RPPS are returned around known points.
+ *
+ * Cases:
+ * - Near Paris (within 30km): only RPPS_USER_1 (Mercure Ochrome) should match
+ *
+ * @throws ClientExceptionInterface
+ * @throws RedirectionExceptionInterface
+ * @throws ServerExceptionInterface
+ * @throws TransportExceptionInterface
+ */
+ public function testLatitudeLongitudeFilterUsesRppsAddressCoordinates(): void
+ {
+ // Paris area (between 48.8686,2.3314 and 48.870637,2.318747)
+ $paris = $this->get('rpps', [
+ 'latitude' => 48.8695,
+ 'longitude' => 2.3255,
]);
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertCount(0, $data['hydra:member']);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertArrayHasKey('hydra:member', $paris);
+ $this->assertIsArray($paris['hydra:member']);
+
+ // Expect exactly one RPPS around Paris in 30km with current fixtures
+ $this->assertCount(1, $paris['hydra:member']);
+ $this->assertSame('10101485653', $paris['hydra:member'][0]['idRpps']);
}
/**
- * @group
+ *
+ * Purpose: validate latitude/longitude filtering uses RPPSAddress coordinates (cabinet),
+ * not legacy RPPS coords. We keep the existing preparation logic (fixed 30km radius),
+ * and only assert that the right RPPS are returned around known points.
+ *
+ * Cases:
+ * - Near Paris (within 30km): only RPPS_USER_1 (Mercure Ochrome) should match
+ *
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
- public function testExcludedRppsFilter(): void
+ public function testLatitudeLongitudeFilterUsesRppsAddressCoordinates2(): void
{
- //Exclude single syntax
- $data = $this->get("rpps", ['excluded_rpps' => '12222222222']);
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertCollectionKeyNotContains($data['hydra:member'], "idRpps", ["12222222222"]);
-
- // Exclude multiple syntax
- $excludedRpps = ["12222222222", "13333333333"];
- $data = $this->get("rpps", ['excluded_rpps' => $excludedRpps]);
- $this->assertResponseStatusCodeSame(Response::HTTP_OK);
- $this->assertCollectionKeyNotContains($data['hydra:member'], "idRpps", $excludedRpps);
- }
+ // Bourg-en-Bresse area (46.2052, 5.2460)
+ $bourg = $this->get('rpps', [
+ 'latitude' => 46.2052,
+ 'longitude' => 5.2460,
+ ]);
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertArrayHasKey('hydra:member', $bourg);
+ $this->assertIsArray($bourg['hydra:member']);
+
+ $this->assertCount(1, $bourg['hydra:member']);
+ $this->assertSame('19900000002', $bourg['hydra:member'][0]['idRpps']);
+ }
}