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']); + } }