diff --git a/backend/migrations/Version20260408000000.php b/backend/migrations/Version20260408000000.php new file mode 100644 index 0000000..eede796 --- /dev/null +++ b/backend/migrations/Version20260408000000.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE impact_score ADD COLUMN source_observation_id UUID NULL'); + $this->addSql('ALTER TABLE impact_score ADD CONSTRAINT fk_impact_score_observation + FOREIGN KEY (source_observation_id) REFERENCES sargassum_observation(id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX idx_impact_score_source_obs ON impact_score (source_observation_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_impact_score_source_obs'); + $this->addSql('ALTER TABLE impact_score DROP CONSTRAINT fk_impact_score_observation'); + $this->addSql('ALTER TABLE impact_score DROP COLUMN source_observation_id'); + } +} diff --git a/backend/src/Entity/ImpactScore.php b/backend/src/Entity/ImpactScore.php index cfca589..ebdf634 100644 --- a/backend/src/Entity/ImpactScore.php +++ b/backend/src/Entity/ImpactScore.php @@ -31,6 +31,11 @@ class ImpactScore #[ORM\JoinColumn(nullable: false)] private CoastalPoint $coastalPoint; + /** Observation source ayant produit ce score — permet de trier par distance observation→spot. */ + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?SargassumObservation $sourceObservation = null; + #[ORM\Column] private \DateTimeImmutable $timestamp; @@ -80,4 +85,6 @@ class ImpactScore public function setTrend(?string $trend): static { $this->trend = $trend; return $this; } public function getEtaHours(): ?int { return $this->etaHours; } public function setEtaHours(?int $etaHours): static { $this->etaHours = $etaHours; return $this; } + public function getSourceObservation(): ?SargassumObservation { return $this->sourceObservation; } + public function setSourceObservation(?SargassumObservation $obs): static { $this->sourceObservation = $obs; return $this; } } diff --git a/backend/src/Service/Forecast/ImpactScoreService.php b/backend/src/Service/Forecast/ImpactScoreService.php index d6c6ee4..d26645b 100644 --- a/backend/src/Service/Forecast/ImpactScoreService.php +++ b/backend/src/Service/Forecast/ImpactScoreService.php @@ -77,7 +77,7 @@ class ImpactScoreService } $score = $this->computeScore($spot, $obsId, $forecasts); - $this->persistScore($spot, $score); + $this->persistScore($spot, $score, $observation); $this->invalidateCache($spotId); // Alerte push si score passe en "high" @@ -264,28 +264,11 @@ class ImpactScoreService : null; } - /** @param array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null} $data */ - private function persistScore(CoastalPoint $spot, array $data): void + /** + * @param array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null} $data + */ + private function persistScore(CoastalPoint $spot, array $data, SargassumObservation $observation): void { - // "L'observation la plus proche gagne" : ne pas écraser un score avec une distance - // plus grande que le score actuel, sauf si celui-ci a plus de 6h (stale). - if ($data['distance'] !== null) { - $current = $this->connection->fetchAssociative( - 'SELECT distance_to_nearest_sargassum AS dist, timestamp - FROM impact_score WHERE coastal_point_id = :id ORDER BY timestamp DESC LIMIT 1', - ['id' => (string) $spot->getId()] - ); - if ($current !== false && $current['dist'] !== null) { - $ageSeconds = (new \DateTimeImmutable())->getTimestamp() - - (new \DateTimeImmutable($current['timestamp']))->getTimestamp(); - $isStale = $ageSeconds > 21_600; // 6h - $isCloser = $data['distance'] < (float) $current['dist']; - if (!$isStale && !$isCloser) { - return; // garder le score de l'observation plus proche - } - } - } - $s = new ImpactScore(); $s->setCoastalPoint($spot); $s->setScore($data['score']); @@ -294,6 +277,7 @@ class ImpactScoreService $s->setDensityEstimate($data['density']); $s->setTrend($data['trend']); $s->setEtaHours($data['etaHours']); + $s->setSourceObservation($observation); $this->em->persist($s); } @@ -301,12 +285,25 @@ class ImpactScoreService /** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}|null */ private function loadLatestScore(string $spotId): ?array { + // Parmi tous les scores récents (7j), prendre celui dont l'observation source + // est la plus proche du spot — déterministe, sans garde arbitraire. + // Les anciens scores sans source_observation_id sont conservés en fallback. $row = $this->connection->fetchAssociative( - 'SELECT score, level, distance_to_nearest_sargassum AS distance, - density_estimate AS density, trend, eta_hours, timestamp AS computed_at - FROM impact_score - WHERE coastal_point_id = :spotId - ORDER BY timestamp DESC LIMIT 1', + 'SELECT ist.score, ist.level, + ist.distance_to_nearest_sargassum AS distance, + ist.density_estimate AS density, ist.trend, ist.eta_hours, + ist.timestamp AS computed_at + FROM impact_score ist + LEFT JOIN sargassum_observation o ON o.id = ist.source_observation_id + JOIN coastal_point cp ON cp.id = :spotId + WHERE ist.coastal_point_id = :spotId + AND (o.detected_at IS NULL OR o.detected_at > NOW() - INTERVAL \'7 days\') + ORDER BY + CASE WHEN ist.source_observation_id IS NOT NULL + THEN ST_Distance(o.geometry::geography, cp.geometry::geography) + ELSE 999999999.0 END ASC, + ist.timestamp DESC + LIMIT 1', ['spotId' => $spotId] );