• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inclusion-numerique / coop-mediation-numerique / f16a5492-ead1-4914-a5e0-1b1299c84f3a

01 Apr 2026 04:06PM UTC coverage: 7.472% (+0.5%) from 6.94%
f16a5492-ead1-4914-a5e0-1b1299c84f3a

push

circleci

web-flow
Merge pull request #469 from inclusion-numerique/dev

MEP 2026-01-01

500 of 10542 branches covered (4.74%)

Branch coverage included in aggregate %.

145 of 414 new or added lines in 38 files covered. (35.02%)

13 existing lines in 10 files now uncovered.

1500 of 16224 relevant lines covered (9.25%)

36.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/apps/web/src/jobs/normalize-structures-employeuses/executeNormalizeStructuresEmployeuses.ts
1
import { searchAdresse } from '@app/web/external-apis/apiAdresse'
2
import { banFeatureToAdresseBanData } from '@app/web/external-apis/ban/banFeatureToAdresseBanData'
3
import { fetchSiretApiData } from '@app/web/features/structures/siret/fetchSiretData'
4
import type { SiretApiResponse } from '@app/web/features/structures/siret/SiretApiResponse'
5
import { prismaClient } from '@app/web/prismaClient'
6
import type { JobExecutor } from '../jobExecutors'
7
import { output } from '../output'
8

9
// 250 req/min max sur l'API Entreprise = ~4 req/s = 250ms minimum entre chaque appel
NEW
10
const API_ENTREPRISE_THROTTLE_MS = 250
×
11

12
type StructureToNormalize = {
13
  id: string
14
  siret: string | null
15
  nom: string
16
  adresse: string
17
  commune: string
18
  codePostal: string
19
  codeInsee: string | null
20
  latitude: number | null
21
  longitude: number | null
22
  synchronisationSiret: Date | null
23
}
24

25
type NormalizedStructureData = {
26
  nom: string
27
  adresse: string
28
  commune: string
29
  codePostal: string
30
  codeInsee: string
31
}
32

33
type Coordinates = {
34
  latitude: number | null
35
  longitude: number | null
36
}
37

NEW
38
const getStructuresEmployeusesToNormalize = async (): Promise<
×
39
  StructureToNormalize[]
40
> =>
NEW
41
  prismaClient.structure.findMany({
×
42
    where: {
43
      siret: { not: null },
44
      suppression: null,
45
      emplois: {
46
        some: {
47
          suppression: null,
48
        },
49
      },
50
      mediateursEnActivite: {
51
        none: {},
52
      },
53
    },
54
    select: {
55
      id: true,
56
      siret: true,
57
      nom: true,
58
      adresse: true,
59
      commune: true,
60
      codePostal: true,
61
      codeInsee: true,
62
      latitude: true,
63
      longitude: true,
64
      synchronisationSiret: true,
65
    },
66
  })
67

NEW
68
const shouldSkipStructure = (
×
69
  structure: StructureToNormalize,
70
  cutoffDate: Date,
71
): boolean =>
NEW
72
  structure.siret
×
73
    ? structure.synchronisationSiret != null &&
×
74
      structure.synchronisationSiret > cutoffDate
75
    : true
76

NEW
77
const buildAddressFromApiData = (
×
78
  adresse: SiretApiResponse['data']['adresse'],
79
): string =>
NEW
80
  [
×
81
    adresse.numero_voie,
82
    adresse.indice_repetition_voie,
83
    adresse.type_voie,
84
    adresse.libelle_voie,
85
    adresse.complement_adresse,
86
  ]
NEW
87
    .filter((part) => Boolean(part) && part !== 'null')
×
88
    .join(' ')
89

NEW
90
const parseApiEntrepriseResponse = (
×
91
  siretResult: SiretApiResponse,
92
): { error: string } | { data: NormalizedStructureData } => {
93
  const {
94
    data: {
95
      unite_legale: { personne_morale_attributs },
96
      etat_administratif,
97
      adresse,
98
    },
NEW
99
  } = siretResult
×
100

NEW
101
  if (!personne_morale_attributs?.raison_sociale) {
×
NEW
102
    return { error: 'no raison sociale' }
×
103
  }
104

NEW
105
  if (etat_administratif === 'F') {
×
NEW
106
    return { error: 'establishment closed' }
×
107
  }
108

NEW
109
  return {
×
110
    data: {
111
      nom: personne_morale_attributs.raison_sociale,
112
      adresse: buildAddressFromApiData(adresse),
113
      commune: adresse.libelle_commune || '',
×
114
      codePostal: adresse.code_postal,
115
      codeInsee: adresse.code_commune || '',
×
116
    },
117
  }
118
}
119

NEW
120
const hasStructureDataChanged = (
×
121
  structure: StructureToNormalize,
122
  data: NormalizedStructureData,
123
): boolean =>
NEW
124
  structure.nom !== data.nom ||
×
125
  structure.adresse !== data.adresse ||
126
  structure.commune !== data.commune ||
127
  structure.codePostal !== data.codePostal ||
128
  structure.codeInsee !== data.codeInsee
129

NEW
130
const hasAddressChanged = (
×
131
  structure: StructureToNormalize,
132
  data: NormalizedStructureData,
133
): boolean =>
NEW
134
  structure.adresse !== data.adresse ||
×
135
  structure.commune !== data.commune ||
136
  structure.codePostal !== data.codePostal ||
137
  structure.codeInsee !== data.codeInsee
138

NEW
139
const geocodeIfAddressChanged = async (
×
140
  structure: StructureToNormalize,
141
  data: NormalizedStructureData,
142
): Promise<Coordinates> => {
NEW
143
  if (!hasAddressChanged(structure, data)) {
×
NEW
144
    return { latitude: structure.latitude, longitude: structure.longitude }
×
145
  }
146

NEW
147
  const fullAdresse = `${data.adresse}, ${data.codePostal} ${data.commune}`
×
NEW
148
  const adresseResult = await searchAdresse(fullAdresse)
×
149

NEW
150
  if (adresseResult) {
×
NEW
151
    const banData = banFeatureToAdresseBanData(adresseResult)
×
NEW
152
    return { latitude: banData.latitude, longitude: banData.longitude }
×
153
  }
154

NEW
155
  return { latitude: null, longitude: null }
×
156
}
157

NEW
158
const updateStructureData = async (
×
159
  structureId: string,
160
  data: NormalizedStructureData,
161
  coordinates: Coordinates,
162
): Promise<void> => {
NEW
163
  const now = new Date()
×
NEW
164
  await prismaClient.structure.update({
×
165
    where: { id: structureId },
166
    data: {
167
      nom: data.nom,
168
      adresse: data.adresse,
169
      commune: data.commune,
170
      codePostal: data.codePostal,
171
      codeInsee: data.codeInsee,
172
      latitude: coordinates.latitude,
173
      longitude: coordinates.longitude,
174
      modification: now,
175
      synchronisationSiret: now,
176
    },
177
  })
178
}
179

NEW
180
const updateStructureSyncTimestamp = async (
×
181
  structureId: string,
182
): Promise<void> => {
NEW
183
  await prismaClient.structure.update({
×
184
    where: { id: structureId },
185
    data: { synchronisationSiret: new Date() },
186
  })
187
}
188

NEW
189
const logDryRunChanges = (
×
190
  structure: StructureToNormalize,
191
  data: NormalizedStructureData,
192
  coordinates: Coordinates,
193
  addressChanged: boolean,
194
): void => {
NEW
195
  output.log(
×
196
    `normalize-structures-employeuses: [DRY RUN] would update structure ${structure.id}:`,
197
  )
NEW
198
  output.log(`  nom: "${structure.nom}" -> "${data.nom}"`)
×
NEW
199
  output.log(`  adresse: "${structure.adresse}" -> "${data.adresse}"`)
×
NEW
200
  output.log(`  commune: "${structure.commune}" -> "${data.commune}"`)
×
NEW
201
  output.log(`  codePostal: "${structure.codePostal}" -> "${data.codePostal}"`)
×
NEW
202
  output.log(`  codeInsee: "${structure.codeInsee}" -> "${data.codeInsee}"`)
×
NEW
203
  if (addressChanged) {
×
NEW
204
    output.log(
×
205
      `  latitude: ${structure.latitude} -> ${coordinates.latitude}, longitude: ${structure.longitude} -> ${coordinates.longitude}`,
206
    )
207
  }
208
}
209

NEW
210
const throttle = () =>
×
NEW
211
  new Promise((resolve) => setTimeout(resolve, API_ENTREPRISE_THROTTLE_MS))
×
212

213
export const executeNormalizeStructuresEmployeuses: JobExecutor<
214
  'normalize-structures-employeuses'
NEW
215
> = async (job) => {
×
NEW
216
  const dryRun = job.payload?.dryRun ?? false
×
NEW
217
  const minDaysSinceLastSync = job.payload?.minDaysSinceLastSync ?? 7
×
NEW
218
  const cutoffDate = new Date(
×
219
    Date.now() - minDaysSinceLastSync * 24 * 60 * 60 * 1000,
220
  )
221

NEW
222
  output.log(
×
223
    `normalize-structures-employeuses: starting${dryRun ? ' (DRY RUN)' : ''} (minDaysSinceLastSync: ${minDaysSinceLastSync})`,
×
224
  )
225

NEW
226
  const structures = await getStructuresEmployeusesToNormalize()
×
227

NEW
228
  output.log(
×
229
    `normalize-structures-employeuses: found ${structures.length} structures to process`,
230
  )
231

NEW
232
  const results = {
×
233
    total: structures.length,
234
    updated: 0,
235
    unchanged: 0,
236
    skipped: 0,
237
    failed: 0,
238
    dryRun,
239
  }
240

NEW
241
  for (const [index, structure] of structures.entries()) {
×
NEW
242
    if ((index + 1) % 50 === 0) {
×
NEW
243
      output.log(
×
244
        `normalize-structures-employeuses: progress ${index + 1}/${structures.length}`,
245
      )
246
    }
247

NEW
248
    if (shouldSkipStructure(structure, cutoffDate)) {
×
NEW
249
      results.skipped++
×
NEW
250
      continue
×
251
    }
252

NEW
253
    try {
×
NEW
254
      const siretResult = await fetchSiretApiData(structure.siret as string)
×
NEW
255
      await throttle()
×
256

NEW
257
      if ('error' in siretResult) {
×
NEW
258
        output.log(
×
259
          `normalize-structures-employeuses: API error for structure ${structure.id} (SIRET: ${structure.siret}): ${siretResult.error.message}`,
260
        )
NEW
261
        results.failed++
×
NEW
262
        continue
×
263
      }
264

NEW
265
      const parsed = parseApiEntrepriseResponse(siretResult)
×
266

NEW
267
      if ('error' in parsed) {
×
NEW
268
        output.log(
×
269
          `normalize-structures-employeuses: ${parsed.error} for structure ${structure.id} (SIRET: ${structure.siret})`,
270
        )
NEW
271
        results.failed++
×
NEW
272
        continue
×
273
      }
274

NEW
275
      const { data } = parsed
×
276

NEW
277
      const dataChanged = hasStructureDataChanged(structure, data)
×
NEW
278
      const addressChanged = hasAddressChanged(structure, data)
×
NEW
279
      const coordinates = await geocodeIfAddressChanged(structure, data)
×
280

NEW
281
      if (dryRun) {
×
NEW
282
        if (dataChanged) {
×
NEW
283
          logDryRunChanges(structure, data, coordinates, addressChanged)
×
NEW
284
          results.updated++
×
285
        } else {
NEW
286
          output.log(
×
287
            `normalize-structures-employeuses: [DRY RUN] would update synchronisationSiret only for structure ${structure.id}`,
288
          )
NEW
289
          results.unchanged++
×
290
        }
NEW
291
        continue
×
292
      }
293

NEW
294
      if (dataChanged) {
×
NEW
295
        await updateStructureData(structure.id, data, coordinates)
×
NEW
296
        results.updated++
×
297
      } else {
NEW
298
        await updateStructureSyncTimestamp(structure.id)
×
NEW
299
        results.unchanged++
×
300
      }
301
    } catch (error) {
302
      const errorMessage =
NEW
303
        error instanceof Error ? error.message : 'Unknown error'
×
NEW
304
      output.log(
×
305
        `normalize-structures-employeuses: error processing structure ${structure.id}: ${errorMessage}`,
306
      )
NEW
307
      results.failed++
×
308
    }
309
  }
310

NEW
311
  output.log(
×
312
    `normalize-structures-employeuses: completed - updated: ${results.updated}, unchanged: ${results.unchanged}, skipped: ${results.skipped}, failed: ${results.failed}${dryRun ? ' (DRY RUN)' : ''}`,
×
313
  )
314

NEW
315
  return results
×
316
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc