Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions migrations/Version20250915094404.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250915094404 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add RPPS address';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
113 changes: 74 additions & 39 deletions src/ApiPlatform/Filter/RPPSFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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',
Expand Down
151 changes: 151 additions & 0 deletions src/Command/RppsDetectDuplicates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

namespace App\Command;

use SplFileObject;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

// Do not review. @bastien
// Do not run on production or staging. Local dev only

#[AsCommand(
name: 'app:rpps:detect-duplicates',
description: 'Scan the hardcoded RPPS CSV and write a CSV with duplicate lines (incl. original for first duplicate)'
)]
class RppsDetectDuplicates extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Hardcoded paths and settings
$inputPath = 'var/rpps/PS_LibreAcces_Personne_activite_202406110803.txt';
$outputPath = 'var/rpps/duplicates_test_100k.csv';
$delimiter = '|';
$progressEvery = 500;
$hardLimit = 100000; // data lines (excludes header)

if (!is_file($inputPath)) {
$output->writeln("<error>File not found: {$inputPath}</error>");

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("<error>Cannot open file: {$e->getMessage()}</error>");

return Command::FAILURE;
}

// Open output CSV
$outHandle = @fopen($outputPath, 'w');
if (false === $outHandle) {
$output->writeln("<error>Cannot open output file for writing: {$outputPath}</error>");

return Command::FAILURE;
}

// Parse header
$header = $in->fgetcsv($delimiter);
if (false === $header) {
fclose($outHandle);
$output->writeln('<error>Empty file or unreadable header.</error>');

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('<error>Required columns not found in header.</error>');

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;
}
}
2 changes: 0 additions & 2 deletions src/Command/RppsImport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading