From 8c74f1697b1a49c80d57bea5f98f35cac51b9f67 Mon Sep 17 00:00:00 2001 From: Marc Gavanier Date: Thu, 3 Oct 2024 10:12:20 +0200 Subject: [PATCH] feat: deduplicate france services lieux by compatible name --- .../find-duplicates/find-duplicates.spec.ts | 60 ++++++++++++- .../steps/find-duplicates/find-duplicates.ts | 85 +++++++++---------- 2 files changed, 100 insertions(+), 45 deletions(-) diff --git a/src/dedupliquer/steps/find-duplicates/find-duplicates.spec.ts b/src/dedupliquer/steps/find-duplicates/find-duplicates.spec.ts index f21e778c..59116c53 100644 --- a/src/dedupliquer/steps/find-duplicates/find-duplicates.spec.ts +++ b/src/dedupliquer/steps/find-duplicates/find-duplicates.spec.ts @@ -147,7 +147,7 @@ describe('find duplicates', (): void => { ]); }); - it('should not need to deduplicate when only lieu 1 has RFS typologie', (): void => { + it('should not deduplicate when only lieu 1 has RFS typologie', (): void => { const lieux: SchemaLieuMediationNumerique[] = [ { id: '574-mediation-numerique-hinaura', @@ -177,6 +177,64 @@ describe('find duplicates', (): void => { expect(duplicates).toStrictEqual([]); }); + it('should allow deduplicate when both lieux contain "France Service" in the name', (): void => { + const lieux: SchemaLieuMediationNumerique[] = [ + { + id: '574-mediation-numerique-hinaura', + nom: "France services d'Etrechy", + adresse: '26 rue Jean Moulin', + code_postal: '38000', + commune: 'Grenoble', + latitude: 45.186115, + longitude: 5.716962, + source: 'conseiller-numerique' + } as SchemaLieuMediationNumerique, + { + id: '2848-mediation-numerique-france-services', + nom: "France services d'Etrechy", + adresse: '26 rue Jean Moulin', + code_postal: '38000', + commune: 'Grenoble', + latitude: 45.186115, + longitude: 5.716962, + typologie: Typologie.RFS, + source: 'france-services' + } as SchemaLieuMediationNumerique + ]; + + const duplicates: CommuneDuplications[] = findDuplicates(lieux, false); + + expect(duplicates).toStrictEqual([ + { + codePostal: '38000', + lieux: [ + { + id: '574-mediation-numerique-hinaura', + duplicates: [ + { + id: '2848-mediation-numerique-france-services', + distanceScore: 100, + nomFuzzyScore: 100, + voieFuzzyScore: 100 + } + ] + }, + { + id: '2848-mediation-numerique-france-services', + duplicates: [ + { + id: '574-mediation-numerique-hinaura', + distanceScore: 100, + nomFuzzyScore: 100, + voieFuzzyScore: 100 + } + ] + } + ] + } + ]); + }); + it('should not need to deduplicate when only lieu 1 has RFS typologie in lieux to deduplicate', (): void => { const lieuxToDeduplicate: SchemaLieuMediationNumerique[] = [ { diff --git a/src/dedupliquer/steps/find-duplicates/find-duplicates.ts b/src/dedupliquer/steps/find-duplicates/find-duplicates.ts index 08ac2569..e1f4d43d 100644 --- a/src/dedupliquer/steps/find-duplicates/find-duplicates.ts +++ b/src/dedupliquer/steps/find-duplicates/find-duplicates.ts @@ -1,22 +1,11 @@ import { SchemaLieuMediationNumerique, Typologie } from '@gouvfr-anct/lieux-de-mediation-numerique'; import { ratio } from 'fuzzball'; -export type Duplicate = { - id: string; - distanceScore: number; - nomFuzzyScore: number; - voieFuzzyScore: number; -}; - -export type LieuDuplications = { - id: string; - duplicates: Duplicate[]; -}; - -export type CommuneDuplications = { - codePostal: string; - lieux: LieuDuplications[]; -}; +export type Duplicate = { id: string; distanceScore: number; nomFuzzyScore: number; voieFuzzyScore: number }; + +export type LieuDuplications = { id: string; duplicates: Duplicate[] }; + +export type CommuneDuplications = { codePostal: string; lieux: LieuDuplications[] }; const sameSource = (lieu: SchemaLieuMediationNumerique, lieuToDeduplicate: SchemaLieuMediationNumerique): boolean => lieu.source === lieuToDeduplicate.source; @@ -30,8 +19,18 @@ const sameCodePostal = (lieu: SchemaLieuMediationNumerique, lieuToDeduplicate: S const hasRFSCompatibleTypology = (lieu: SchemaLieuMediationNumerique): boolean => [`${Typologie.RFS}`, `${Typologie.PIMMS}`].includes(lieu.typologie ?? 'NO_TYPOLOGY'); -const compatibleTypologies = (lieu: SchemaLieuMediationNumerique, lieuToDeduplicate: SchemaLieuMediationNumerique): boolean => - hasRFSCompatibleTypology(lieu) && hasRFSCompatibleTypology(lieuToDeduplicate) +const isCompatibleForFranceServices = ( + lieu: SchemaLieuMediationNumerique, + lieuToDeduplicate: SchemaLieuMediationNumerique +): boolean => + (hasRFSCompatibleTypology(lieu) && hasRFSCompatibleTypology(lieuToDeduplicate)) || + (/france services?/giu.test(lieu.nom.toLowerCase()) && /france services?/giu.test(lieuToDeduplicate.nom.toLowerCase())); + +const compatibilitySpecialCases = ( + lieu: SchemaLieuMediationNumerique, + lieuToDeduplicate: SchemaLieuMediationNumerique +): boolean => + isCompatibleForFranceServices(lieu, lieuToDeduplicate) ? true : lieuToDeduplicate.typologie !== Typologie.RFS && lieu.typologie !== Typologie.RFS; @@ -41,7 +40,7 @@ const onlyPotentialDuplicates = sameCodePostal(lieu, lieuToDeduplicate) && !sameId(lieu, lieuToDeduplicate) && (allowInternalMerge || !sameSource(lieu, lieuToDeduplicate)) && - compatibleTypologies(lieu, lieuToDeduplicate); + compatibilitySpecialCases(lieu, lieuToDeduplicate); const MINIMAL_CARTESIAN_DISTANCE: 0.0004 = 0.0004 as const; @@ -59,9 +58,9 @@ const hasDefinedCoordinates = ( /* eslint-disable-next-line no-mixed-operators */ const pythagore = (x1: number, x2: number, y1: number, y2: number): number => Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2); -const cartesianDistanceBetween = (lieu: SchemaLieuMediationNumerique, curentLieu: SchemaLieuMediationNumerique): number => - hasDefinedCoordinates(lieu) && hasDefinedCoordinates(curentLieu) - ? pythagore(lieu.latitude, curentLieu.latitude, lieu.longitude, curentLieu.longitude) +const cartesianDistanceBetween = (lieu: SchemaLieuMediationNumerique, cible: SchemaLieuMediationNumerique): number => + hasDefinedCoordinates(lieu) && hasDefinedCoordinates(cible) + ? pythagore(lieu.latitude, cible.latitude, lieu.longitude, cible.longitude) : NaN; const duplicatesWithScores = @@ -76,6 +75,12 @@ const duplicatesWithScores = }) ); +const toLieuDuplications = ( + lieu: SchemaLieuMediationNumerique, + lieux: SchemaLieuMediationNumerique[], + allowInternalMerge: boolean +): LieuDuplications => ({ id: lieu.id, duplicates: duplicatesWithScores(lieux)(lieu, allowInternalMerge) }); + const appendCommuneDuplications = (lieux: SchemaLieuMediationNumerique[]) => ( @@ -84,10 +89,7 @@ const appendCommuneDuplications = allowInternalMerge: boolean ): CommuneDuplications[] => [ ...duplications, - { - codePostal: lieuToDeduplicate.code_postal, - lieux: [{ id: lieuToDeduplicate.id, duplicates: duplicatesWithScores(lieux)(lieuToDeduplicate, allowInternalMerge) }] - } + { codePostal: lieuToDeduplicate.code_postal, lieux: [toLieuDuplications(lieuToDeduplicate, lieux, allowInternalMerge)] } ]; const toUpdatedCommuneDuplications = @@ -97,17 +99,14 @@ const toUpdatedCommuneDuplications = communeDuplications.codePostal === lieu.code_postal ? { codePostal: lieu.code_postal, - lieux: [ - ...duplicationsWithSameCodePostal.lieux, - { id: lieu.id, duplicates: duplicatesWithScores(lieux)(lieu, allowInternalMerge) } - ] + lieux: [...duplicationsWithSameCodePostal.lieux, toLieuDuplications(lieu, lieux, allowInternalMerge)] } : communeDuplications; const withSameCodePostal = (lieu: SchemaLieuMediationNumerique) => - (communeDuplications: CommuneDuplications): boolean => - communeDuplications.codePostal === lieu.code_postal; + ({ codePostal }: CommuneDuplications): boolean => + codePostal === lieu.code_postal; const toCommunesDuplications = (lieux: SchemaLieuMediationNumerique[], allowInternalMerge: boolean) => @@ -119,25 +118,23 @@ const toCommunesDuplications = toUpdatedCommuneDuplications(lieux)(lieuToDeduplicate, duplicationsWithSameCodePostal, allowInternalMerge) ))(duplications.find(withSameCodePostal(lieuToDeduplicate))); -const onlyWithDuplicates = (lieu: LieuDuplications): boolean => lieu.duplicates.length > 0; +const onlyWithDuplicates = ({ duplicates }: LieuDuplications): boolean => duplicates.length > 0; -const onlyWithoutDuplicates = (lieu: LieuDuplications): boolean => lieu.duplicates.length === 0; +const onlyWithoutDuplicates = ({ duplicates }: LieuDuplications): boolean => duplicates.length === 0; const toDuplicatesWithout = (noDuplicatesIds: string[]) => - (lieu: LieuDuplications): LieuDuplications => ({ - id: lieu.id, - duplicates: lieu.duplicates.filter((duplicate: Duplicate): boolean => !noDuplicatesIds.includes(duplicate.id)) + ({ id, duplicates }: LieuDuplications): LieuDuplications => ({ + id, + duplicates: duplicates.filter((duplicate: Duplicate): boolean => !noDuplicatesIds.includes(duplicate.id)) }); -const toId = (lieu: LieuDuplications): string => lieu.id; - -const invalidDuplicatesIds = (communeDuplications: CommuneDuplications): string[] => - communeDuplications.lieux.filter(onlyWithoutDuplicates).map(toId); +const invalidDuplicatesIds = ({ lieux }: CommuneDuplications): string[] => + lieux.filter(onlyWithoutDuplicates).map((lieu: LieuDuplications): string => lieu.id); -const removeLieuxFrom = (communeDuplications: CommuneDuplications, ids: string[]): CommuneDuplications => ({ - codePostal: communeDuplications.codePostal, - lieux: communeDuplications.lieux.map(toDuplicatesWithout(ids)).filter(onlyWithDuplicates) +const removeLieuxFrom = ({ codePostal, lieux }: CommuneDuplications, ids: string[]): CommuneDuplications => ({ + codePostal, + lieux: lieux.map(toDuplicatesWithout(ids)).filter(onlyWithDuplicates) }); const toValidDuplicates = (communeDuplications: CommuneDuplications): CommuneDuplications =>