Skip to content

Commit 6da956e

Browse files
Merge branch 'main' into front/feat/0224-fix-community
2 parents 777106f + f2ca8ea commit 6da956e

28 files changed

+642
-142
lines changed

back/scripts/enrichment/bareme_enricher.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,37 @@ def bareme_marchespublics(
303303
# Préparation des données marchés publics
304304
# Filtrage : uniquement acheteurs identifiés + période 2016+
305305
# Ajout indicateur booléen pour obligation de publication
306-
marches = marches_publics.filter(
307-
pl.col("acheteur_id").is_not_null() & (pl.col("annee_notification") >= 2016)
308-
).with_columns(
309-
pl.when(pl.col("obligation_publication") == "Obligatoire")
310-
.then(1) # Marché soumis à obligation
311-
.otherwise(0) # Marché publié volontairement
312-
.alias("obligation_publication_bool")
306+
# Group by des données par marché public
307+
marches = (
308+
marches_publics.filter(
309+
pl.col("acheteur_id").is_not_null() & (pl.col("annee_notification") >= 2016)
310+
)
311+
.with_columns(
312+
pl.when(pl.col("obligation_publication") == "Obligatoire")
313+
.then(1) # Marché soumis à obligation
314+
.otherwise(0) # Marché publié volontairement
315+
.alias("obligation_publication_bool")
316+
)
317+
.group_by(
318+
[
319+
"acheteur_id",
320+
"annee_notification",
321+
"obligation_publication",
322+
"id_mp",
323+
"montant_du_marche_public",
324+
"delai_publication_jours",
325+
"date_notification",
326+
"cpv_8",
327+
"lieu_execution_nom",
328+
"forme_prix",
329+
"objet",
330+
"nature",
331+
"duree_mois",
332+
"procedure",
333+
"obligation_publication_bool",
334+
]
335+
)
336+
.agg(pl.col("titulaire_id").unique().alias("titulaire_id_liste"))
313337
)
314338

315339
# Construction table de référence (comme pour subventions)
@@ -331,9 +355,9 @@ def bareme_marchespublics(
331355
# Comptage et vérification de complétude des champs obligatoires
332356
bareme_information = merged_marches.group_by(["siren", "annee"]).agg(
333357
[
334-
pl.count("id").alias("nombre_de_marches"), # Nombre total
358+
pl.count("id_mp").alias("nombre_de_marches"), # Nombre total
335359
pl.sum("obligation_publication_bool"), # Marchés obligatoires
336-
pl.sum("montant"), # Montant total
360+
pl.sum("montant_du_marche_public"), # Montant total du marche public
337361
pl.median("delai_publication_jours"), # Délai médian publication
338362
# Comptage champs renseignés (pour évaluer complétude)
339363
*[
@@ -347,7 +371,7 @@ def bareme_marchespublics(
347371
"nature", # Nature du marché
348372
"duree_mois", # Durée
349373
"procedure", # Procédure utilisée
350-
"titulaire_id", # Titulaire
374+
"titulaire_id_liste", # Liste des titulaires par marché public
351375
]
352376
],
353377
]
@@ -367,7 +391,7 @@ def bareme_marchespublics(
367391
# Critère B : Complétude des données essentielles
368392
# Tous les champs critiques doivent être renseignés
369393
(
370-
(pl.col("montant") > 0)
394+
(pl.col("montant_du_marche_public") > 0)
371395
& (pl.col("date_notification").is_not_null())
372396
& (pl.col("cpv_8").is_not_null())
373397
& (pl.col("lieu_execution_nom").is_not_null())
@@ -376,7 +400,7 @@ def bareme_marchespublics(
376400
& (pl.col("nature").is_not_null())
377401
& (pl.col("duree_mois").is_not_null())
378402
& (pl.col("procedure").is_not_null())
379-
& (pl.col("titulaire_id").is_not_null())
403+
& (pl.col("titulaire_id_liste").is_not_null())
380404
)
381405
.cast(pl.Int8)
382406
.alias("B"),

back/scripts/enrichment/communities_enricher.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ def get_input_paths(cls, main_config: dict) -> list[Path]:
2929
def _clean_and_enrich(cls, inputs: list[pl.DataFrame]) -> pl.DataFrame:
3030
communities, bareme, subventions, marches_publics = inputs
3131

32+
# Conserve une ligne par marche public et ses attributs
33+
marches_publics = cls.get_uniques_marches_publics(marches_publics)
34+
3235
# Uniformise les noms des collectivités selon leur type
3336
communities = cls.uniformiser_noms(communities)
3437

@@ -258,3 +261,17 @@ def nettoyer_nom(nom: str, type_: str) -> str:
258261
)
259262

260263
return communities
264+
265+
@classmethod
266+
def get_uniques_marches_publics(cls, marches_publics: pl.DataFrame) -> pl.DataFrame:
267+
"""
268+
Ne conserve qu'une ligne par marche public avec son montant et d'autres colonnes
269+
"""
270+
271+
return (
272+
marches_publics.select(
273+
["id_mp", "montant_du_marche_public", "acheteur_id", "annee_notification"]
274+
)
275+
.unique(["id_mp"])
276+
.rename({"montant_du_marche_public": "montant"})
277+
)

back/scripts/enrichment/marches_enricher.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,16 @@ def _clean_and_enrich(cls, inputs: list[pl.DataFrame]) -> pl.DataFrame:
6767
.pipe(normalize_date, "dateNotification")
6868
.pipe(normalize_identifiant, "acheteur_id", IdentifierFormat.SIREN)
6969
.pipe(cls._add_metadata)
70-
.assign(montant=lambda df: df["montant"] / df["countTitulaires"].fillna(1))
70+
.rename(columns={"montant": "montant_du_marche_public"})
71+
.assign(
72+
montant_du_marche_public_par_titulaire=lambda df: df["montant_du_marche_public"]
73+
/ df["countTitulaires"].fillna(1)
74+
)
7175
)
7276

7377
return (
7478
pl.from_pandas(marches_pd)
75-
.pipe(cls.generate_new_id)
79+
.pipe(cls.generate_id_mp_index)
7680
.pipe(cls.forme_prix_enrich)
7781
.pipe(cls.type_identifiant_titulaire_enrich)
7882
.pipe(
@@ -169,7 +173,7 @@ def ensure_list_of_dicts(x):
169173
else:
170174
return [x] # wrap dans une liste
171175
if isinstance(x, list):
172-
if len(x) > 0:
176+
if (len(x) > 0) and (isinstance(x[0], list)):
173177
return MarchesPublicsEnricher.ensure_list_of_dicts(x[0]) # déjà une liste
174178
else:
175179
return x
@@ -586,7 +590,7 @@ def keep_last_modifications_par_mp(marches: pl.DataFrame) -> pl.DataFrame:
586590
)
587591

588592
@staticmethod
589-
def generate_new_id(marches: pl.DataFrame) -> pl.DataFrame:
593+
def generate_id_mp_index(marches: pl.DataFrame) -> pl.DataFrame:
590594
"""
591595
Génère un nouvel id unique, entier, pour chaque MP
592596
Car le hash de id_mp est trop lourd pour la BDD
@@ -595,15 +599,13 @@ def generate_new_id(marches: pl.DataFrame) -> pl.DataFrame:
595599
id_mapping = (
596600
marches.select("id_mp")
597601
.unique()
598-
.with_row_index(name="id") # génère l'entier par valeur unique
602+
.with_row_index(name="id_mp_integer") # génère l'entier par valeur unique
599603
)
600604

601-
# Pour le moment la colonne id est remplacée par la colonne id_mp qui est l'id unique par marché public (créé par nous), car le front attend un id.
602-
# Il faudra éventuellement changer cette fonction et garder les id, uid, et uuid d'origine car ils permettent de retrouver les marchés publics sur les plateformes en ligne.
603605
return (
604-
marches.drop(["id"])
605-
.join(id_mapping, on="id_mp", how="left")
606-
.drop(["id_mp", "uid", "uuid"])
606+
marches.join(id_mapping, on="id_mp", how="left")
607+
.drop("id_mp")
608+
.rename({"id_mp_integer": "id_mp"})
607609
)
608610

609611
@staticmethod
@@ -612,5 +614,6 @@ def drop_rows_with_null_dates_or_amounts(marches: pl.DataFrame) -> pl.DataFrame:
612614
Supprime les lignes où 'date_publication_donnees' ou 'montant' sont nulles.
613615
"""
614616
return marches.filter(
615-
pl.col("date_publication_donnees").is_not_null() & pl.col("montant").is_not_null()
617+
pl.col("date_publication_donnees").is_not_null()
618+
& pl.col("montant_du_marche_public").is_not_null()
616619
)
14.3 KB
Binary file not shown.

front/app/(visualiser)/interpeller/[siren]/step2/page.tsx

Lines changed: 19 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ export default async function InterpellateStep2({ params }: InterpellateStep2Pro
8787
)}
8888
</>
8989
)}
90-
{/* CAS : pas de contact mail mais présence de l'url de la collectivité ou de son formulaire de contact */}
91-
{!emailContactsLen && formContactLen > 0 && (
90+
{/* CAS : pas de contact mail */}
91+
{!emailContactsLen && (
9292
<>
9393
<p className='rounded-t-3xl bg-secondary p-4 text-lg font-bold'>
9494
<Image
@@ -116,63 +116,29 @@ export default async function InterpellateStep2({ params }: InterpellateStep2Pro
116116
</span>
117117
</h3>
118118

119-
<ul className='flex flex-wrap justify-between gap-4 px-8 pb-8'>
119+
<ul className='grid grid-cols-3 gap-4 px-8 pb-8'>
120120
<li className='group relative basis-[100%] md:basis-[32%]'>
121121
<ContactCard cardTitleText='Visiter le site de la collectivité'>
122-
<Link href={formContact[0].contact} className='mt-4 flex' target='_blank'>
123-
Voir le site de la collectivité
124-
<ChevronRight size={14} className='ml-2 self-center' />
125-
</Link>
126-
</ContactCard>
127-
</li>
128-
</ul>
129-
<InterpellateOtherLink />
130-
</div>
131-
</>
132-
)}
133-
{/* CAS : ni contact mail générique, ni url de la collectivité */}
134-
{emailContactsLen < 1 && formContactLen < 1 && (
135-
<>
136-
<p className='rounded-t-3xl bg-secondary p-4 text-lg font-bold'>
137-
<Image
138-
src='/eclaireur/error_icon.png'
139-
alt='Interpeller'
140-
width={24}
141-
height={24}
142-
className='mr-2 inline-block'
143-
/>
144-
Nous n’avons pas de contact direct avec les élus pour {communityName}
145-
</p>
146-
<div className='rounded-b-3xl bg-white py-16'>
147-
<Image
148-
src='/eclaireur/mascotte_call.svg'
149-
alt='Interpeller'
150-
width={150}
151-
height={129}
152-
className='mx-auto block'
153-
/>
154-
<h3 className='mb-12 mt-6 px-8 text-center'>
155-
Vous pouvez toujours agir
156-
<br />
157-
<span className='text-lg font-normal'>
158-
pour faire valoir la transparence des données publiques !
159-
</span>
160-
</h3>
161-
162-
<ul className='flex flex-wrap justify-between gap-4 px-8 pb-8'>
163-
<li className='group relative basis-[100%] md:basis-[32%]'>
164-
<ContactCard cardTitleText='Envoyer un mail à la collectivité'>
165-
Information non disponible
166-
</ContactCard>
167-
</li>
168-
<li className='group relative basis-[100%] md:basis-[32%]'>
169-
<ContactCard cardTitleText='Visiter le site de la collectivité'>
170-
Information non disponible
122+
{formContactLen > 0 && (
123+
<Link href={formContact[0].contact} className='mt-4 flex' target='_blank'>
124+
Voir le site de la collectivité
125+
<ChevronRight size={14} className='ml-2 self-center' />
126+
</Link>
127+
)}
128+
{formContactLen === 0 && 'Information non disponible'}
171129
</ContactCard>
172130
</li>
173131
<li className='group relative basis-[100%] md:basis-[32%]'>
174132
<ContactCard cardTitleText='Envoyer un courrier à la collectivité'>
175-
<Link href='#' download className='mt-4 flex' target='_blank'>
133+
<Link
134+
href={{
135+
pathname: '/api/interpellate/courrier-interpellation',
136+
query: { communityName: communityName, communityType: community.type },
137+
}}
138+
download
139+
className='mt-4 flex'
140+
target='_blank'
141+
>
176142
Télécharger un courrier type
177143
<ChevronRight size={14} className='ml-2 self-center' />
178144
</Link>

front/app/api/communities/[siren]/marches_publics/contrats/download/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ sire
2828
}
2929

3030
const stream = await getStream({
31-
selectors: ['titulaire_denomination_sociale', 'objet', 'montant', 'annee_notification'],
31+
selectors: ['titulaire_denomination_sociale', 'objet', 'montant_du_marche_public', 'annee_notification'],
3232
filters: { acheteur_id: siren, annee_notification: year },
33-
orderBy: { direction: 'desc', column: 'montant' },
33+
orderBy: { direction: 'desc', column: 'montant_du_marche_public' },
3434
});
3535

3636
const headers = new Headers({
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
import { CourrierTypeInterpellation } from '#components/pdfs/CourrierTypeInterpellation';
4+
import { renderToStream } from '@react-pdf/renderer';
5+
6+
export async function GET(request: NextRequest) {
7+
const searchParams = request.nextUrl.searchParams;
8+
9+
const communityName = searchParams.get('communityName') ?? '';
10+
const communityType = searchParams.get('communityType') ?? '';
11+
12+
const stream = await renderToStream(
13+
<CourrierTypeInterpellation communityName={communityName} communityType={communityType} />,
14+
);
15+
const chunks: Uint8Array[] = [];
16+
for await (const chunk of stream) {
17+
// chunk can be string | Uint8Array
18+
const bufferChunk = typeof chunk === 'string' ? Buffer.from(chunk) : new Uint8Array(chunk);
19+
chunks.push(bufferChunk);
20+
}
21+
22+
const pdfBuffer = Buffer.concat(
23+
chunks.map((c) => Buffer.from(c)), // ensure all are Buffer
24+
);
25+
return new NextResponse(pdfBuffer, {
26+
headers: {
27+
'Content-Type': 'application/pdf',
28+
'Content-Disposition': 'attachment; filename="courrier-interpellation.pdf"',
29+
},
30+
});
31+
}

0 commit comments

Comments
 (0)