Réorganisation dynamic, tickets et autres

This commit is contained in:
Fabrication du Numérique 2025-05-10 09:10:03 +02:00
parent d57cb1f0e2
commit e9d129f616
22 changed files with 1785 additions and 988 deletions

4
.env
View File

@ -8,3 +8,7 @@ DEPOT_FICHES = "fiches"
DEPOT_CODE = "code"
ID_PROJET = "3"
INSTRUCTIONS = "Instructions.md"
FICHE_IHH = "Fiches/Criticités/Fiche technique IHH.md"
FICHE_ICS = "Fiches/Criticités/Fiche technique ICS.md"
FICHE_ISG = "Fiches/Criticités/Fiche technique ISG.md"
FICHE_IVC = "Fiches/Criticités/Fiche technique IVC.md"

View File

@ -16,7 +16,8 @@
</tr>
</tbody>
</table>
<p>Les imprimantes représentent un segment mature mais toujours essentiel du marché des périphériques informatiques, avec environ 80 millions d'unités produites annuellement. Ce marché englobe diverses technologies (jet d'encre, laser, thermique, 3D) destinées aux usages personnels, professionnels et industriels. L'assemblage des imprimantes présente des défis spécifiques liés à la précision mécanique, à l'intégration de systèmes électromécaniques complexes et à la nécessité d'une fiabilité élevée. Le processus comprend généralement le montage d'un châssis mécanique, l'installation des moteurs et systèmes d'entraînement, l'intégration de la tête d'impression ou du système laser, le montage de la carte mère et des composants électroniques, puis l'assemblage du boîtier extérieur. La production est répartie entre quelques acteurs majeurs, avec une concentration en Asie pour les modèles grand public et une fabrication plus distribuée pour les équipements professionnels et industriels.</p>
<details><summary>Présentation synthétique</summary><h2>Présentation synthétique</h2>
<p>Les imprimantes représentent un segment mature mais toujours essentiel du marché des périphériques informatiques, avec environ 80 millions d'unités produites annuellement. Ce marché englobe diverses technologies (jet d'encre, laser, thermique, 3D) destinées aux usages personnels, professionnels et industriels. L'assemblage des imprimantes présente des défis spécifiques liés à la précision mécanique, à l'intégration de systèmes électromécaniques complexes et à la nécessité d'une fiabilité élevée. Le processus comprend généralement le montage d'un châssis mécanique, l'installation des moteurs et systèmes d'entraînement, l'intégration de la tête d'impression ou du système laser, le montage de la carte mère et des composants électroniques, puis l'assemblage du boîtier extérieur. La production est répartie entre quelques acteurs majeurs, avec une concentration en Asie pour les modèles grand public et une fabrication plus distribuée pour les équipements professionnels et industriels.</p></details>
<details><summary>Composants assemblés</summary><h2>Composants assemblés</h2>
<table role="table" summary="Composants assemblés">
<thead>

View File

@ -1,7 +1,22 @@
<section role="region" aria-labelledby="fiche-d-assemblage-matériels-de-photolithographie-duv">
<h1 id="fiche-d-assemblage-matériels-de-photolithographie-duv">Fiche dassemblage : Matériels de photolithographie DUV</h1>
<details><summary>Description générale</summary><h2>Description générale</h2>
<section role="region" aria-labelledby="fiche-assemblage-procédé-deep-ultraviolet">
<h1 id="fiche-assemblage-procédé-deep-ultraviolet">Fiche assemblage Procédé Deep Ultraviolet</h1>
<table role="table" summary>
<thead>
<tr>
<th scope="col" style="text-align: left;">Version</th>
<th scope="col" style="text-align: left;">Date</th>
<th scope="col" style="text-align: left;">Commentaire</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">1.0</td>
<td style="text-align: left;">2025-04-22</td>
<td style="text-align: left;">Version initiale</td>
</tr>
</tbody>
</table>
<details><summary>Présentation synthétique</summary><h2>Présentation synthétique</h2>
<p>Les scanners <strong>DUV</strong> (Deep Ultraviolet  193 nm ArF immersion / 193 nm ArF sec / 248 nm KrF) couvrent les nœuds <strong>28 nm à 7 nm</strong> (couches critiques) et les niveaux moins exigeants.
Un ArF immersion de dernière génération (<strong>TWINSCANNXT:2100i</strong>) compte environ <strong>55000pièces</strong>, pèse 115t et coûte <strong>90140M€</strong>.
Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <strong>45M€</strong>.</p>
@ -12,7 +27,7 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<li><strong>Démontage logistique</strong> (≈1518conteneurs)</li>
<li><strong>assemblage &amp; qualification</strong> chez le fondeur (36 mois)</li>
</ol>
<table role="table" summary="Description générale">
<table role="table" summary="Présentation synthétique">
<thead>
<tr>
<th scope="col" style="text-align: left;">Plateforme</th>
@ -45,8 +60,7 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<td style="text-align: left;">2019</td>
</tr>
</tbody>
<caption>Description générale</caption></table>
<hr/></details>
<caption>Présentation synthétique</caption></table></details>
<details><summary>Composants assemblés</summary><h2>Composants assemblés</h2>
<table role="table" summary="Composants assemblés">
<thead>
@ -103,9 +117,31 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
</tbody>
<caption>Composants assemblés</caption></table>
<p><em>Coûts indicatifs pour NXT:2100i (2024).</em></p>
<hr/></details>
<details><summary>Principaux assembleurs (livraisons 2024)</summary><h2>Principaux assembleurs (livraisons 2024)</h2>
<table role="table" summary="Principaux assembleurs (livraisons 2024)">
<p><code>yaml
Assemblage_ProcedeDUV:
PaysBas_Assemblage_ProcedeDUV:
nom_du_pays: Pays-Bas
part_de_marche: 84%
acteurs:
AMSL_PaysBas_Assemblage_ProcedeDUV:
nom_de_l_acteur: ASML
part_de_marche: 84%
pays_d_origine: Pays-Bas
Japon_Assemblage_ProcedeDUV:
nom_du_pays: Japon
part_de_marche: 16%
acteurs:
Nikon_Japon_Assemblage_ProcedeDUV:
nom_de_l_acteur: Nikon
part_de_marche: 12%
pays_d_origine: Japon
Canon_Japon_Assemblage_ProcedeDUV:
nom_de_l_acteur: Canon
part_de_marche: 4%
pays_d_origine: Japon</code></p></details>
<details><summary>Principaux assembleurs</summary><h2>Principaux assembleurs</h2>
<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->
<table role="table" summary="Principaux assembleurs">
<thead>
<tr>
<th scope="col" style="text-align: left;"><strong>Pays d'implantation</strong></th>
@ -116,9 +152,9 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
</thead>
<tbody>
<tr>
<td style="text-align: left;">PaysBas</td>
<td style="text-align: left;">Pays-Bas</td>
<td style="text-align: left;">ASML</td>
<td style="text-align: left;">PaysBas</td>
<td style="text-align: left;">Pays-Bas</td>
<td style="text-align: left;">84 %</td>
</tr>
<tr>
@ -146,9 +182,9 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<td style="text-align: left;"><strong>16 %</strong></td>
</tr>
</tbody>
<caption>Principaux assembleurs (livraisons 2024)</caption></table>
<p><em>Total 2024: ~240DUV scanners (toutes longueurs donde) livrés, dont 90% destinés à la Chine.</em></p>
<hr/></details>
<caption>Principaux assembleurs</caption></table>
<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->
<p><em>Total 2024: ~240DUV scanners (toutes longueurs donde) livrés, dont 90% destinés à la Chine.</em></p></details>
<details><summary>Contraintes spécifiques</summary><h2>Contraintes spécifiques</h2>
<table role="table" summary="Contraintes spécifiques">
<thead>
@ -185,16 +221,14 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<td style="text-align: left;">OPEX source important</td>
</tr>
</tbody>
<caption>Contraintes spécifiques</caption></table>
<hr/></details>
<caption>Contraintes spécifiques</caption></table></details>
<details><summary>Logistique et transport</summary><h2>Logistique et transport</h2>
<ul>
<li><strong>1518caisses</strong> (air + mer) ; modules ≤12t</li>
<li>Transport aérien Boeing7478F / 777F, conteneurs maritimes 40HC</li>
<li>Délai porteàporte: <strong>45jours</strong> (Europe →ÉtatsUnis ou Japon →Corée)</li>
<li>Assurance cargo typique <strong>100M$</strong> par scanner</li>
</ul>
<hr/></details>
</ul></details>
<details><summary>Durabilité et cycle de vie</summary><h2>Durabilité et cycle de vie</h2>
<table role="table" summary="Durabilité et cycle de vie">
<thead>
@ -221,8 +255,7 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<td style="text-align: left;">75% masse métallique, CaF₂ recyclage dédié</td>
</tr>
</tbody>
<caption>Durabilité et cycle de vie</caption></table>
<hr/></details>
<caption>Durabilité et cycle de vie</caption></table></details>
<details><summary>Matrice des risques</summary><h2>Matrice des risques</h2>
<table role="table" summary="Matrice des risques">
<thead>
@ -260,9 +293,9 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
- <strong>R3</strong> : Qualité eau immersion impacte rendement et overlay.
- <strong>R4</strong> : Retards fret aérien / maritime ; 18caisses horsgabarit.
- <strong>R5</strong> : 84% des livraisons assurées par un seul acteur (ASML).</p>
<hr/></details>
<details><summary>Indice de Herfindahl-Hirschmann (HHI)</summary><h2>Indice de Herfindahl-Hirschmann (HHI)</h2>
<table role="table" summary="Indice de Herfindahl-Hirschmann (HHI)">
<!---- AUTO-BEGIN:SECTION-IHH -->
<h3>Indice de Herfindahl-Hirschmann (HHI)</h3>
<table role="table" summary="Matrice des risques">
<thead>
<tr>
<th scope="col" style="text-align: left;"><strong>IHH</strong></th>
@ -281,22 +314,21 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<tr>
<td style="text-align: left;"><strong>Pays</strong></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"><strong>73</strong></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"><strong>73</strong></td>
</tr>
</tbody>
<caption>Indice de Herfindahl-Hirschmann (HHI)</caption></table>
<p><em>Acteurs</em>: ASML84%, Nikon12%, Canon4%
<em>Pays</em>: PaysBas + Japon dominants.</p>
<hr/></details>
<details><summary>En résumé</summary><h2>En résumé</h2>
<caption>Matrice des risques</caption></table>
<h4>IHH par entreprise (acteurs)</h4>
<p>LIHH pour les assembleurs est de <strong>72</strong>, ce qui indique une <strong>concentration extrêmement élevée</strong>. Seules trois entreprises produisent les machines de photolithographie Deep UV, dont ASML avec 84% de part de marché. La dépendance à ce seul acteur est un risque critique.</p>
<h4>IHH par pays</h4>
<p>LIHH par pays atteint <strong>73</strong>, révélant une <strong>concentration géographique extrêmement élevée</strong>. La répartition est dominée par les <strong>Pays-bas (84 %)</strong> et le <strong>Japon (16 %)</strong> représentant 100 % des capacités. Cette configuration expose la chaîne à des <strong>risques géopolitiques ou logistiques localisés</strong>.</p>
<h4>En résumé</h4>
<ul>
<li><strong>Trois assembleurs</strong> (ASML, Nikon, Canon) mais <strong>ASML domine</strong> le segment ArF immersion.</li>
<li>Le <strong>laser excimère</strong> (Cymer, Gigaphoton) constitue la plus forte dépendance.</li>
<li>Les scanners DUV restent <strong>vendables à la Chine</strong>, ce qui oriente une grande partie de la production.</li>
<li>Principaux risques: capacité optiques CaF₂, disponibilité lasers, logistique transPacifique.</li>
<li>Le secteur présente une <strong>structure dacteurs extrêmement concentrée</strong> (IHH 72)</li>
<li>La <strong>concentration géographique est extrêmement élevée</strong> (IHH 73)</li>
</ul>
<hr/></details>
<!---- AUTO-END:SECTION-IHH --></details>
<details><summary>Autres informations</summary><h2>Autres informations</h2>
<table role="table" summary="Autres informations">
<thead>
@ -343,8 +375,7 @@ Les KrF modernes (<strong>NSRS635E</strong>, Nikon) se vendent autour de <str
<td style="text-align: left;">Supervision constructeur</td>
</tr>
</tbody>
<caption>Autres informations</caption></table>
<hr/></details>
<caption>Autres informations</caption></table></details>
<details><summary>Sources techniques</summary><h2>Sources techniques</h2>
<ol>
<li>ASML Brochure «TWINSCANNXT:2100i» (2024)</li>

View File

@ -1,7 +1,22 @@
<section role="region" aria-labelledby="fiche-d-assemblage-matériels-de-photolithographie-euv">
<h1 id="fiche-d-assemblage-matériels-de-photolithographie-euv">Fiche dassemblage : Matériels de photolithographie EUV</h1>
<details><summary>Description générale</summary><h2>Description générale</h2>
<section role="region" aria-labelledby="fiche-assemblage-procédé-extreme-ultraviolet">
<h1 id="fiche-assemblage-procédé-extreme-ultraviolet">Fiche assemblage Procédé Extreme Ultraviolet</h1>
<table role="table" summary>
<thead>
<tr>
<th scope="col" style="text-align: left;">Version</th>
<th scope="col" style="text-align: left;">Date</th>
<th scope="col" style="text-align: left;">Commentaire</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">1.0</td>
<td style="text-align: left;">2025-04-22</td>
<td style="text-align: left;">Version initiale</td>
</tr>
</tbody>
</table>
<details><summary>Présentation synthétique</summary><h2>Présentation synthétique</h2>
<p>Les scanners <strong>EUV</strong> (Extreme Ultra Violet λ ≈ 13,5 nm) sont les équipements clés qui permettent de graver les nœuds &lt; 7 nm.
Une machine de dernière génération (NXE:3800E) compte plus de <strong>100 000 pièces</strong>, pèse 180 t et coûte 220260 M€ (EXE &gt; 350 M€ en High-NA) (<a href="https://www.digitimes.com/news/a20250417VL200/asml-euv-2025-earnings-demand.html">ASML to pass tariff costs to US customers, gain three High NA EUV customers</a>, <a href="https://www.barrons.com/articles/asml-stock-chip-equipment-cb5b6b40?utm_source=chatgpt.com">ASML Is the Chip-Equipment Leader. Its Stock Is Poised to Bounce Back.</a>).
Le flux dassemblage se déroule en 4 grandes phases :</p>
@ -11,12 +26,31 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
<li><strong>Démontage logistique</strong> (≈ 35 conteneurs + 3 avions cargo)</li>
<li><strong>Ré-assemblage &amp; qualification</strong> chez le fondeur (69 mois)</li>
</ol>
<p>Les générations :
| Plateforme | NA | Débit wafers/h | Commercialisation |
| :-- | :--: | :--: | :-- |
| <strong>NXE</strong> | 0,33 | 220 | 2019 |
| <strong>EXE (High-NA)</strong> | 0,55 | 185<em> | 2024 </em>(phase R&amp;D)* |</p>
<hr/></details>
<p>Les générations :</p>
<table role="table" summary="Présentation synthétique">
<thead>
<tr>
<th scope="col" style="text-align: left;">Plateforme</th>
<th scope="col" style="text-align: center;">NA</th>
<th scope="col" style="text-align: center;">Débit wafers/h</th>
<th scope="col" style="text-align: left;">Commercialisation</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>NXE</strong></td>
<td style="text-align: center;">0,33</td>
<td style="text-align: center;">220</td>
<td style="text-align: left;">2019</td>
</tr>
<tr>
<td style="text-align: left;"><strong>EXE (High-NA)</strong></td>
<td style="text-align: center;">0,55</td>
<td style="text-align: center;">185*</td>
<td style="text-align: left;">2024 <em>(phase R&amp;D)</em></td>
</tr>
</tbody>
<caption>Présentation synthétique</caption></table></details>
<details><summary>Composants assemblés</summary><h2>Composants assemblés</h2>
<table role="table" summary="Composants assemblés">
<thead>
@ -73,9 +107,19 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
</tbody>
<caption>Composants assemblés</caption></table>
<p><em>Coûts indicatifs pour NXE :3800E (2024).</em></p>
<hr/></details>
<details><summary>Principaux assembleurs (livraisons par an, 2024)</summary><h2>Principaux assembleurs (livraisons par an, 2024)</h2>
<table role="table" summary="Principaux assembleurs (livraisons par an, 2024)">
<p><code>yaml
Assemblage_ProcedeEUV:
PaysBas_Assemblage_ProcedeEUV:
nom_du_pays: Pays-Bas
part_de_marche: 100%
acteurs:
AMSL_PaysBas_Assemblage_ProcedeEUV:
nom_de_l_acteur: ASML
part_de_marche: 100%
pays_d_origine: Pays-Bas</code></p></details>
<details><summary>Principaux assembleurs</summary><h2>Principaux assembleurs</h2>
<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->
<table role="table" summary="Principaux assembleurs">
<thead>
<tr>
<th scope="col" style="text-align: left;"><strong>Pays d'implantation</strong></th>
@ -98,9 +142,9 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
<td style="text-align: left;"><strong>100 %</strong></td>
</tr>
</tbody>
<caption>Principaux assembleurs (livraisons par an, 2024)</caption></table>
<p><em>Total 2024 : 55 NXE livrées, 5 EXE High-NA déjà en R&amp;D chez Intel, TSMC, Samsung</em> (<a href="https://www.digitimes.com/news/a20250417VL200/asml-euv-2025-earnings-demand.html">ASML to pass tariff costs to US customers, gain three High NA EUV customers</a>, <a href="https://www.reuters.com/technology/belgiums-imec-reports-breakthroughs-with-new-asml-chip-printing-machine-2024-08-07/?utm_source=chatgpt.com">Belgium's imec reports breakthroughs with new ASML chip printing machine</a>).</p>
<hr/></details>
<caption>Principaux assembleurs</caption></table>
<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->
<p><em>Total 2024 : 55 NXE livrées, 5 EXE High-NA déjà en R&amp;D chez Intel, TSMC, Samsung</em> (<a href="https://www.digitimes.com/news/a20250417VL200/asml-euv-2025-earnings-demand.html">ASML to pass tariff costs to US customers, gain three High NA EUV customers</a>, <a href="https://www.reuters.com/technology/belgiums-imec-reports-breakthroughs-with-new-asml-chip-printing-machine-2024-08-07/?utm_source=chatgpt.com">Belgium's imec reports breakthroughs with new ASML chip printing machine</a>).</p></details>
<details><summary>Contraintes spécifiques</summary><h2>Contraintes spécifiques</h2>
<table role="table" summary="Contraintes spécifiques">
<thead>
@ -137,16 +181,14 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
<td style="text-align: left;">Limite débit &amp; rendement</td>
</tr>
</tbody>
<caption>Contraintes spécifiques</caption></table>
<hr/></details>
<caption>Contraintes spécifiques</caption></table></details>
<details><summary>Logistique et transport</summary><h2>Logistique et transport</h2>
<ul>
<li><strong>35 caisses</strong> (mer + air) ; modules &gt; 10 t chacun</li>
<li>Démontage en « kits » (&lt; 22 t) pour Boeing 747-8F</li>
<li>Délai porte-à-porte : <strong>100 jours</strong> (Europe → Taïwan)</li>
<li>Assurance cargo spécifique (valeur déclarée ≥ 250 M$)</li>
</ul>
<hr/></details>
</ul></details>
<details><summary>Durabilité et cycle de vie</summary><h2>Durabilité et cycle de vie</h2>
<table role="table" summary="Durabilité et cycle de vie">
<thead>
@ -173,8 +215,7 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
<td style="text-align: left;">80 % masse métallique récupérable</td>
</tr>
</tbody>
<caption>Durabilité et cycle de vie</caption></table>
<hr/></details>
<caption>Durabilité et cycle de vie</caption></table></details>
<details><summary>Matrice des risques</summary><h2>Matrice des risques</h2>
<table role="table" summary="Matrice des risques">
<thead>
@ -213,9 +254,9 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
- <strong>R4</strong> : Goulot Zeiss pour miroirs 0,55 NA
- <strong>R5</strong> : Dégâts transport, douanes hors gabarit
- <strong>R6</strong> : Retard pellicle haute-NA réduit le yield</p>
<hr/></details>
<details><summary>Indice de Herfindahl-Hirschmann (HHI)</summary><h2>Indice de Herfindahl-Hirschmann (HHI)</h2>
<table role="table" summary="Indice de Herfindahl-Hirschmann (HHI)">
<!---- AUTO-BEGIN:SECTION-IHH -->
<h3>Indice de Herfindahl-Hirschmann (HHI)</h3>
<table role="table" summary="Matrice des risques">
<thead>
<tr>
<th scope="col" style="text-align: left;"><strong>IHH</strong></th>
@ -238,18 +279,17 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
<td style="text-align: left;"><strong>100</strong></td>
</tr>
</tbody>
<caption>Indice de Herfindahl-Hirschmann (HHI)</caption></table>
<p><em>Acteurs : ASML ≈ 100 %</em> → monopole absolu.
*Pays : chaîne dominée par les Pays-Bas.</p>
<hr/></details>
<details><summary>En résumé</summary><h2>En résumé</h2>
<caption>Matrice des risques</caption></table>
<h4>IHH par entreprise (acteurs)</h4>
<p>LIHH pour les assembleurs est de <strong>100</strong>, ce qui indique un <strong>monopole</strong>. ASML est le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.</p>
<h4>IHH par pays</h4>
<p>LIHH par pays atteint <strong>100</strong>, ce qui indique un <strong>monopole</strong>. Les Pays-Bas sont le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.</p>
<h4>En résumé</h4>
<ul>
<li><strong>EUV = chaînon le plus concentré</strong> de toute la filière semi dépendance critique à ASML/Zeiss.</li>
<li>La <strong>montée en High-NA (EXE)</strong> réduit le nombre dexpositions, mais renforce la dépendance.</li>
<li><strong>Risque géopolitique majeur</strong> (export-control) et <strong>logistique complexe</strong> (35 conteneurs).</li>
<li>Les alternatives (NIL Canon, projets chinois) restent <strong>non qualifiées</strong> pour la production logic &amp; memory &lt; 3 nm.</li>
<li>Le secteur présente une <strong>structure monopolistique</strong> (IHH 100)</li>
<li>La <strong>concentration géographique est monopolistique</strong> (IHH 100)</li>
</ul>
<hr/></details>
<!---- AUTO-END:SECTION-IHH --></details>
<details><summary>Autres informations</summary><h2>Autres informations</h2>
<table role="table" summary="Autres informations">
<thead>
@ -286,10 +326,7 @@ Le flux dassemblage se déroule en 4 grandes phases :</p>
<td style="text-align: left;">Les modules sont remontés in-situ ; Intel a été le premier à assembler lui-même un EXE:5000 sous supervision ASML (<a href="https://www.reuters.com/technology/seeking-edge-over-rivals-intel-first-assemble-asmls-next-gen-chip-tool-2024-04-18/?utm_source=chatgpt.com">Seeking edge over rivals, Intel first to assemble ASML's next-gen ...</a>)</td>
</tr>
</tbody>
<caption>Autres informations</caption></table>
<blockquote>
<p>ASML ne possède <strong>pas</strong> dusine secondaire pour lassemblage final ; il expédie des « kits » depuis Veldhoven et supervise la reconstruction dans la salle blanche du client.</p>
</blockquote></details>
<caption>Autres informations</caption></table></details>
<details><summary>Sources techniques</summary><h2>Sources techniques</h2>
<ol>
<li>ASML Fiches produits EUV (NXE/EXE) (<a href="https://www.asml.com/products/euv-lithography-systems?utm_source=chatgpt.com">EUV lithography systems Products - ASML</a>, <a href="https://www.asml.com/en/news/stories/2024/5-things-high-na-euv?utm_source=chatgpt.com">5 things you should know about High NA EUV lithography - ASML</a>)</li>

View File

@ -465,6 +465,76 @@
<li>Le secteur présente une <strong>structure dacteurs plutôt diversifiée</strong> (IHH 13)</li>
<li>La <strong>concentration géographique est élevée</strong> (IHH 30)</li>
</ul></details>
<details><summary>Assemblage - ProcedeDUV</summary><h2>Assemblage - ProcedeDUV</h2>
<h3>Indice de Herfindahl-Hirschmann (HHI)</h3>
<table role="table" summary="Assemblage - ProcedeDUV">
<thead>
<tr>
<th scope="col" style="text-align: left;"><strong>IHH</strong></th>
<th scope="col" style="text-align: left;"><strong>Faible</strong></th>
<th scope="col" style="text-align: left;"><strong>Modéré</strong></th>
<th scope="col" style="text-align: left;"><strong>Élevé</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>Acteurs</strong></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"><strong>72</strong></td>
</tr>
<tr>
<td style="text-align: left;"><strong>Pays</strong></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"><strong>73</strong></td>
</tr>
</tbody>
<caption>Assemblage - ProcedeDUV</caption></table>
<h4>IHH par entreprise (acteurs)</h4>
<p>LIHH pour les assembleurs est de <strong>72</strong>, ce qui indique une <strong>concentration extrêmement élevée</strong>. Seules trois entreprises produisent les machines de photolithographie Deep UV, dont ASML avec 84% de part de marché. La dépendance à ce seul acteur est un risque critique.</p>
<h4>IHH par pays</h4>
<p>LIHH par pays atteint <strong>73</strong>, révélant une <strong>concentration géographique extrêmement élevée</strong>. La répartition est dominée par les <strong>Pays-bas (84 %)</strong> et le <strong>Japon (16 %)</strong> représentant 100 % des capacités. Cette configuration expose la chaîne à des <strong>risques géopolitiques ou logistiques localisés</strong>.</p>
<h4>En résumé</h4>
<ul>
<li>Le secteur présente une <strong>structure dacteurs extrêmement concentrée</strong> (IHH 72)</li>
<li>La <strong>concentration géographique est extrêmement élevée</strong> (IHH 73)</li>
</ul></details>
<details><summary>Assemblage - ProcedeEUV</summary><h2>Assemblage - ProcedeEUV</h2>
<h3>Indice de Herfindahl-Hirschmann (HHI)</h3>
<table role="table" summary="Assemblage - ProcedeEUV">
<thead>
<tr>
<th scope="col" style="text-align: left;"><strong>IHH</strong></th>
<th scope="col" style="text-align: left;"><strong>Faible</strong></th>
<th scope="col" style="text-align: left;"><strong>Modéré</strong></th>
<th scope="col" style="text-align: left;"><strong>Élevé</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>Acteurs</strong></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"><strong>100</strong></td>
</tr>
<tr>
<td style="text-align: left;"><strong>Pays</strong></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"></td>
<td style="text-align: left;"><strong>100</strong></td>
</tr>
</tbody>
<caption>Assemblage - ProcedeEUV</caption></table>
<h4>IHH par entreprise (acteurs)</h4>
<p>LIHH pour les assembleurs est de <strong>100</strong>, ce qui indique un <strong>monopole</strong>. ASML est le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.</p>
<h4>IHH par pays</h4>
<p>LIHH par pays atteint <strong>100</strong>, ce qui indique un <strong>monopole</strong>. Les Pays-Bas sont le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.</p>
<h4>En résumé</h4>
<ul>
<li>Le secteur présente une <strong>structure monopolistique</strong> (IHH 100)</li>
<li>La <strong>concentration géographique est monopolistique</strong> (IHH 100)</li>
</ul></details>
<details><summary>Fabrication - Audio</summary><h2>Fabrication - Audio</h2>
<h3>Indice de Herfindahl-Hirschmann</h3>
<table role="table" summary="Fabrication - Audio">
@ -4680,6 +4750,16 @@
<td style="text-align: center;">✅ 14</td>
</tr>
<tr>
<td style="text-align: left;">ProcedeDUV</td>
<td style="text-align: center;">🔴 73</td>
<td style="text-align: center;">🔴 72</td>
</tr>
<tr>
<td style="text-align: left;">ProcedeEUV</td>
<td style="text-align: center;">🔴 100</td>
<td style="text-align: center;">🔴 100</td>
</tr>
<tr>
<td style="text-align: left;">Serveur</td>
<td style="text-align: center;">🔴 34</td>
<td style="text-align: center;">✅ 12</td>

View File

@ -1,21 +1,61 @@
# === Constantes et imports ===
import streamlit as st
import requests
import re
from config import GITEA_TOKEN
from utils.gitea import charger_arborescence_fiches
from utils.tickets_fiche import gerer_tickets_fiche
import os
import yaml
import markdown
from bs4 import BeautifulSoup
from latex2mathml.converter import convert as latex_to_mathml
from utils.fiche_utils import load_seuils, render_fiche_markdown
from utils.fiche_dynamic import build_dynamic_sections, build_ivc_sections, build_ihh_sections, build_isg_sections, build_assemblage_sections
import os
from utils.gitea import recuperer_date_dernier_commit
from datetime import datetime, timezone
import yaml
from latex2mathml.converter import convert as latex_to_mathml
from utils.tickets.display import afficher_tickets_par_fiche
from utils.tickets.creation import formulaire_creation_ticket_dynamique
from utils.tickets.core import rechercher_tickets_gitea
from config import GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV
from config import GITEA_TOKEN, GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV, FICHES_CRITICITE
from utils.gitea import charger_arborescence_fiches, recuperer_date_dernier_commit
from utils.fiche_utils import load_seuils, render_fiche_markdown
from utils.dynamic import (
build_dynamic_sections,
build_ivc_sections,
build_ihh_sections,
build_isg_sections,
build_production_sections,
build_minerai_sections
)
# === Logique métier ===
def fichier_plus_recent(chemin_fichier, reference):
try:
modif = datetime.fromtimestamp(os.path.getmtime(chemin_fichier), tz=timezone.utc)
return modif > reference
except Exception:
return False
def doit_regenerer_fiche(html_path, fiche_type, fiche_choisie, commit_url, fichiers_criticite):
if not os.path.exists(html_path):
return True
local_mtime = datetime.fromtimestamp(os.path.getmtime(html_path), tz=timezone.utc)
remote_mtime = recuperer_date_dernier_commit(commit_url)
if remote_mtime is None or remote_mtime > local_mtime:
return True
if fichier_plus_recent(fichiers_criticite.get("IHH"), local_mtime):
return True
if fiche_type == "minerai" or "minerai" in fiche_choisie.lower():
if fichier_plus_recent(fichiers_criticite.get("IVC"), local_mtime):
return True
if fichier_plus_recent(fichiers_criticite.get("ICS"), local_mtime):
return True
return False
# === Fonctions de transformation ===
def remplacer_latex_par_mathml(markdown_text):
def remplacer_bloc_display(match):
formule_latex = match.group(1).strip()
@ -33,7 +73,6 @@ def remplacer_latex_par_mathml(markdown_text):
except Exception as e:
return f"<code>Erreur LaTeX inline: {e}</code>"
# Important : d'abord les $$...$$, ensuite les $...$
markdown_text = re.sub(r"\$\$(.*?)\$\$", remplacer_bloc_display, markdown_text, flags=re.DOTALL)
markdown_text = re.sub(r"(?<!\$)\$(.+?)\$(?!\$)", remplacer_bloc_inline, markdown_text, flags=re.DOTALL)
return markdown_text
@ -52,99 +91,74 @@ def markdown_to_html_rgaa(markdown_text, caption_text=None):
th["scope"] = "col"
return str(soup)
def creer_fiche(md_source: str, dossier: str, nom_fichier: str, seuils: dict) -> str:
# Extraire bloc YAML d'en-tête
# === Fonctions principales ===
def creer_fiche(md_source, dossier, nom_fichier, seuils):
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md_source)
context = {}
if front_match:
context = yaml.safe_load(front_match.group(1))
#md_source = md_source[front_match.end():] # retirer le front-matter du corps
context = yaml.safe_load(front_match.group(1)) if front_match else {}
# Traitement conditionnel selon type
type_fiche = context.get("type_fiche")
if type_fiche == "indice":
if context.get("indice_court") == "ICS":
indice = context.get("indice_court")
if indice == "ICS":
md_source = build_dynamic_sections(md_source)
elif context.get("indice_court") == "IVC":
elif indice == "IVC":
md_source = build_ivc_sections(md_source)
elif context.get("indice_court") == "IHH":
elif indice == "IHH":
md_source = build_ihh_sections(md_source)
elif context.get("indice_court") == "ISG":
elif indice == "ISG":
md_source = build_isg_sections(md_source)
elif type_fiche == "assemblage" or type_fiche == "fabrication":
md_source = build_assemblage_sections(md_source)
elif type_fiche in ["assemblage", "fabrication"]:
md_source = build_production_sections(md_source)
elif type_fiche == "minerai":
md_source = build_minerai_sections(md_source)
# Rendu markdown principal
contenu_md = render_fiche_markdown(md_source, seuils)
#st.code(md_source)
#md_pairs = build_dynamic_sections(md_source)
#contenu_md = render_fiche_markdown(md_pairs, seuils)
# Sauvegarde .md
md_path = os.path.join("Fiches", dossier, nom_fichier)
os.makedirs(os.path.dirname(md_path), exist_ok=True)
with open(md_path, "w", encoding="utf-8") as f:
f.write(contenu_md)
# Traitement en sections
lignes = contenu_md.split('\n')
sections_n1 = []
section_n1_actuelle = {"titre": None, "intro": [], "sections_n2": {}}
dans_section_n1 = False
section_n2_actuelle = None
for ligne in lignes:
if re.match(r'^#[^#]', ligne):
if section_n1_actuelle["titre"] or section_n1_actuelle["intro"] or section_n1_actuelle["sections_n2"]:
sections_n1.append(section_n1_actuelle)
section_n1_actuelle = {
"titre": ligne.strip('# ').strip(),
"intro": [],
"sections_n2": {}
}
section_n1_actuelle = {"titre": ligne.strip('# ').strip(), "intro": [], "sections_n2": {}}
section_n2_actuelle = None
dans_section_n1 = True
elif re.match(r'^##[^#]', ligne):
section_n2_actuelle = ligne.strip('# ').strip()
section_n1_actuelle["sections_n2"][section_n2_actuelle] = [f"## {section_n2_actuelle}"]
elif section_n2_actuelle:
section_n1_actuelle["sections_n2"][section_n2_actuelle].append(ligne)
elif dans_section_n1:
else:
section_n1_actuelle["intro"].append(ligne)
if section_n1_actuelle["titre"] or section_n1_actuelle["intro"] or section_n1_actuelle["sections_n2"]:
sections_n1.append(section_n1_actuelle)
# Génération HTML
bloc_titre = sections_n1[0]["titre"] if sections_n1 and sections_n1[0]["titre"] else "fiche"
titre_id = re.sub(r'\W+', '-', bloc_titre.lower()).strip('-')
html_output = [f'<section role="region" aria-labelledby="{titre_id}">',
f'<h1 id="{titre_id}">{sections_n1[0]["titre"]}</h1>']
html_output = [f'<section role="region" aria-labelledby="{titre_id}">', f'<h1 id="{titre_id}">{bloc_titre}</h1>']
for bloc in sections_n1:
if bloc["titre"] and bloc["titre"] != sections_n1[0]["titre"]:
if bloc["titre"] and bloc["titre"] != bloc_titre:
html_output.append(f"<h2>{bloc['titre']}</h2>")
if bloc["intro"]:
intro_md = remplacer_latex_par_mathml("\n".join(bloc["intro"]))
html_intro = markdown_to_html_rgaa(intro_md, caption_text=None)
html_intro = markdown_to_html_rgaa(intro_md)
html_output.append(html_intro)
for sous_titre, contenu in bloc["sections_n2"].items():
contenu_md = remplacer_latex_par_mathml("\n".join(contenu))
contenu_html = markdown_to_html_rgaa(contenu_md, caption_text=sous_titre)
html_output.append(f"<details><summary>{sous_titre}</summary>{contenu_html}</details>")
html_output.append("</section>")
html_dir = os.path.join("HTML", dossier)
os.makedirs(html_dir, exist_ok=True)
html_path = os.path.join(html_dir, os.path.splitext(nom_fichier)[0] + ".html")
with open(html_path, "w", encoding="utf-8") as f:
f.write("\n".join(html_output))
@ -165,7 +179,6 @@ def afficher_fiches():
if dossier_choisi and dossier_choisi != "-- Sélectionner un dossier --":
fiches = arbo.get(dossier_choisi, [])
noms_fiches = [f['nom'] for f in fiches]
fiche_choisie = st.selectbox("Choisissez une fiche", ["-- Sélectionner une fiche --"] + noms_fiches)
if fiche_choisie and fiche_choisie != "-- Sélectionner une fiche --":
@ -180,18 +193,20 @@ def afficher_fiches():
if "seuils" not in st.session_state:
SEUILS = load_seuils("assets/config.yaml")
st.session_state["seuils"] = SEUILS
elif "seuils" in st.session_state:
else:
SEUILS = st.session_state["seuils"]
html_path = os.path.join("HTML", dossier_choisi, os.path.splitext(fiche_choisie)[0] + ".html")
path_relative = f"Documents/{dossier_choisi}/{fiche_choisie}"
commits_url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={path_relative}&sha={ENV}"
regenerate = True
if os.path.exists(html_path):
remote_last_modified = recuperer_date_dernier_commit(commits_url)
local_last_modified = datetime.fromtimestamp(os.path.getmtime(html_path), tz=timezone.utc)
regenerate = not remote_last_modified or remote_last_modified > local_last_modified
regenerate = doit_regenerer_fiche(
html_path=html_path,
fiche_type=fiche_info.get("type", ""),
fiche_choisie=fiche_choisie,
commit_url=commits_url,
fichiers_criticite=FICHES_CRITICITE
)
if regenerate:
st.info("DEBUG : Régénération de la fiche")
@ -202,7 +217,10 @@ def afficher_fiches():
with open(html_path, "r", encoding="utf-8") as f:
st.markdown(f.read(), unsafe_allow_html=True)
gerer_tickets_fiche(fiche_choisie)
st.markdown("<hr style='border: 1px solid #ccc; margin: 2rem 0;' />", unsafe_allow_html=True)
st.markdown("## Gestion des tickets pour cette fiche")
afficher_tickets_par_fiche(rechercher_tickets_gitea(fiche_choisie))
formulaire_creation_ticket_dynamique(fiche_choisie)
except Exception as e:
st.error(f"Erreur lors du chargement de la fiche : {e}")

View File

@ -13,3 +13,20 @@ ENV = os.getenv("ENV")
ENV_CODE = os.getenv("ENV_CODE")
DOT_FILE = os.getenv("DOT_FILE")
INSTRUCTIONS = os.getenv("INSTRUCTIONS", "Instructions.md")
FICHE_IHH = os.getenv("FICHE_IHH")
FICHE_ICS = os.getenv("FICHE_ICS")
FICHE_IVC = os.getenv("FICHE_IVC")
FICHE_ISG = os.getenv("FICHE_ISG")
# Optionnel : vérification + fallback
for key, value in [("FICHE_IHH", FICHE_IHH), ("FICHE_ICS", FICHE_ICS), ("FICHE_IVC", FICHE_IVC), ("FICHE_ISG", FICHE_ISG)]:
if not value:
raise EnvironmentError(f"Variable d'environnement '{key}' non définie.")
FICHES_CRITICITE = {
"IHH": FICHE_IHH,
"IVC": FICHE_IVC,
"ICS": FICHE_ICS,
"ISG": FICHE_ISG
}

5
utils/dynamic/README.md Normal file
View File

@ -0,0 +1,5 @@
Ce répertoire contient le code nécessaire à la création d'une fiche, quel que soit son type.
Les sous-répertoires correspondent aux différents types de fiche.
Le sous-répertoire utils contient les parties communes du code.

14
utils/dynamic/__init__.py Normal file
View File

@ -0,0 +1,14 @@
# __init__.py
from .indice.ics import build_dynamic_sections
from .indice.ivc import build_ivc_sections
from .indice.ihh import build_ihh_sections
from .indice.isg import build_isg_sections
from .assemblage_fabrication.production import build_production_sections
from .minerai.minerai import (
build_minerai_sections,
build_minerai_ics_section,
build_minerai_ivc_section,
build_minerai_ics_composant_section
)
from .utils.pastille import pastille

View File

@ -0,0 +1,134 @@
# production.py
# Ce module gère à la fois les fiches d'assemblage ET de fabrication.
import re
import yaml
import streamlit as st
from config import FICHES_CRITICITE
def build_production_sections(md: str) -> str:
schema = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
front_matter = yaml.safe_load(front_match.group(1))
schema = front_matter.get("schema")
type_fiche = front_matter.get("type_fiche")
if type_fiche not in ["assemblage", "fabrication"] or not schema:
return md
except Exception as e:
st.error(f"Erreur lors du chargement du front matter: {e}")
return md
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
if not yaml_block:
return md
try:
yaml_data = yaml.safe_load(yaml_block.group(1))
except Exception as e:
st.error(f"Erreur lors du chargement du YAML: {e}")
return md
if not isinstance(yaml_data, dict) or len(yaml_data) == 0:
return md
produit_key = list(yaml_data.keys())[0]
produit_data = yaml_data[produit_key]
pays_data = []
for pays_key, pays_info in produit_data.items():
nom_pays = pays_info.get('nom_du_pays', '')
part_marche_pays = pays_info.get('part_de_marche', '0%')
part_marche_num = float(part_marche_pays.strip('%'))
acteurs = []
for acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
nom_acteur = acteur_info.get('nom_de_l_acteur', '')
part_marche_acteur = acteur_info.get('part_de_marche', '0%')
pays_origine = acteur_info.get('pays_d_origine', '')
part_marche_acteur_num = float(part_marche_acteur.strip('%'))
acteurs.append({
'nom': nom_acteur,
'part_marche': part_marche_acteur,
'part_marche_num': part_marche_acteur_num,
'pays_origine': pays_origine
})
acteurs_tries = sorted(acteurs, key=lambda x: x['part_marche_num'], reverse=True)
pays_data.append({
'nom': nom_pays,
'part_marche': part_marche_pays,
'part_marche_num': part_marche_num,
'acteurs': acteurs_tries
})
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
lignes_tableau = [
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
"| :-- | :-- | :-- | :-- |"
]
for pays in pays_tries:
for acteur in pays['acteurs']:
part_marche_formattee = acteur['part_marche'].strip('%') + ' %'
lignes_tableau.append(
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
)
part_marche_pays_formattee = pays['part_marche'].strip('%') + ' %'
lignes_tableau.append(
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
)
tableau_final = "\n".join(lignes_tableau)
if type_fiche == "fabrication":
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-FABRICANTS -->.*?<!---- AUTO-END:TABLEAU-FABRICANTS -->",
f"<!---- AUTO-BEGIN:TABLEAU-FABRICANTS -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-FABRICANTS -->",
md,
flags=re.DOTALL
)
else:
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->.*?<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->",
f"<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->",
md,
flags=re.DOTALL
)
# Chercher et remplacer la section IHH si un schéma a été identifié
if schema:
# Charger le contenu de la fiche technique IHH
try:
# Essayer de lire le fichier depuis le système de fichiers
with open(FICHES_CRITICITE["IHH"], "r", encoding="utf-8") as f:
ihh_content = f.read()
# Chercher la section IHH correspondant au schéma et au type de fiche
# Format de la section : ## Assemblage/Fabrication - [Schema]
if type_fiche == "fabrication":
ihh_section_pattern = rf"## Fabrication - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
else: # type_fiche == "assemblage"
ihh_section_pattern = rf"## Assemblage - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
ihh_section_match = re.search(ihh_section_pattern, ihh_content)
if ihh_section_match:
# Extraire la section complète sans le titre principal
ihh_section = ihh_section_match.group(0).split("\n", 2)[2].strip()
# Remplacer la section IHH dans la fiche d'assemblage
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:SECTION-IHH -->.*?<!---- AUTO-END:SECTION-IHH -->",
f"<!---- AUTO-BEGIN:SECTION-IHH -->\n{ihh_section}\n<!---- AUTO-END:SECTION-IHH -->",
md_modifie,
flags=re.DOTALL
)
else:
# Si aucune section IHH n'est trouvée pour ce schéma, laisser la section existante
st.warning(f"Aucune section IHH trouvée pour le schéma {schema} dans la fiche technique IHH.")
except Exception as e:
st.error(f"Erreur lors de la lecture/traitement de la fiche IHH: {e}")
return md_modifie

View File

@ -0,0 +1,90 @@
# ics.py
import re
import yaml
import pandas as pd
import unicodedata
import textwrap
PAIR_RE = re.compile(r"```yaml[^\n]*\n(.*?)```", re.S | re.I)
def _normalize_unicode(text: str) -> str:
return unicodedata.normalize("NFKC", text)
def _pairs_dataframe(md: str) -> pd.DataFrame:
rows = []
for raw in PAIR_RE.findall(md):
bloc = yaml.safe_load(raw)
if isinstance(bloc, dict) and "pair" in bloc:
rows.append(bloc["pair"])
return pd.DataFrame(rows)
def _fill(segment: str, pair: dict) -> str:
segment = _normalize_unicode(segment)
for k, v in pair.items():
val = f"{v:.2f}" if isinstance(v, (int, float)) else str(v)
segment = re.sub(
rf"{{{{\s*{re.escape(k)}\s*}}}}",
val,
segment,
flags=re.I,
)
segment = re.sub(
r"ICS\s*=\s*[-+]?\d+(?:\.\d+)?",
f"ICS = {pair['ics']:.2f}",
segment,
count=1,
)
return segment
def _segments(md: str):
blocs = list(PAIR_RE.finditer(md))
for i, match in enumerate(blocs):
pair = yaml.safe_load(match.group(1))["pair"]
start = match.end()
end = blocs[i + 1].start() if i + 1 < len(blocs) else len(md)
segment = md[start:end]
yield pair, segment
def _pivot(df: pd.DataFrame) -> str:
out = []
for min_, g in df.groupby("minerai"):
out += [f"## {min_}",
"| Composant | ICS | Faisabilité technique | Délai d'implémentation | Impact économique |",
"| :-- | :--: | :--: | :--: | :--: |"]
for _, r in g.sort_values("ics", ascending=False).iterrows():
out += [f"| {r.composant} | {r.ics:.2f} | {r.f_tech:.2f} | "
f"{r.delai:.2f} | {r.cout:.2f} |"]
out.append("")
return "\n".join(out)
def _synth(df: pd.DataFrame) -> str:
lignes = ["| Composant | Minerai | ICS |", "| :-- | :-- | :--: |"]
for _, r in df.sort_values("ics", ascending=False).iterrows():
lignes.append(f"| {r.composant} | {r.minerai} | {r.ics:.2f} |")
return "\n".join(lignes)
def build_dynamic_sections(md_raw: str) -> str:
md_raw = _normalize_unicode(md_raw)
df = _pairs_dataframe(md_raw)
if df.empty:
return md_raw
couples = ["# Criticité par couple Composant -> Minerai"]
for pair, seg in _segments(md_raw):
if pair:
couples.append(_fill(seg, pair))
couples_md = "\n".join(couples)
pivot_md = _pivot(df)
synth_md = _synth(df)
md = re.sub(r"#\s+Criticité par couple.*", couples_md, md_raw, flags=re.S | re.I)
md = re.sub(r"<!---- AUTO-BEGIN:PIVOT -->.*?<!---- AUTO-END:PIVOT -->",
f"<!---- AUTO-BEGIN:PIVOT -->\n{pivot_md}\n<!---- AUTO-END:PIVOT -->",
md, flags=re.S)
md = re.sub(r"<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_md}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md, flags=re.S)
return textwrap.dedent(md)

140
utils/dynamic/indice/ihh.py Normal file
View File

@ -0,0 +1,140 @@
# ihh.py
import re
import yaml
from jinja2 import Template
from ..utils.pastille import pastille
IHH_RE = re.compile(r"```yaml\s+opération:(.*?)```", re.S | re.I)
def _synth_ihh(operations: list[dict]) -> str:
data_by_item = {}
for op in operations:
# nom = op.get('nom', '')
item_id = op.get('minerai', op.get('produit', op.get('composant', '')))
if not item_id:
continue
if item_id not in data_by_item:
data_by_item[item_id] = {
'type': 'minerai' if 'extraction' in op or 'reserves' in op or 'traitement' in op else
'produit' if 'assemblage' in op else 'composant',
'extraction_ihh_pays': '-',
'extraction_ihh_acteurs': '-',
'reserves_ihh_pays': '-',
'traitement_ihh_pays': '-',
'traitement_ihh_acteurs': '-',
'assemblage_ihh_pays': '-',
'assemblage_ihh_acteurs': '-',
'fabrication_ihh_pays': '-',
'fabrication_ihh_acteurs': '-'
}
if 'extraction' in op:
data_by_item[item_id]['extraction_ihh_pays'] = op['extraction'].get('ihh_pays', '-')
data_by_item[item_id]['extraction_ihh_acteurs'] = op['extraction'].get('ihh_acteurs', '-')
data_by_item[item_id]['reserves_ihh_pays'] = op['reserves'].get('ihh_pays', '-')
data_by_item[item_id]['traitement_ihh_pays'] = op['traitement'].get('ihh_pays', '-')
data_by_item[item_id]['traitement_ihh_acteurs'] = op['traitement'].get('ihh_acteurs', '-')
elif 'assemblage' in op:
data_by_item[item_id]['assemblage_ihh_pays'] = op['assemblage'].get('ihh_pays', '-')
data_by_item[item_id]['assemblage_ihh_acteurs'] = op['assemblage'].get('ihh_acteurs', '-')
elif 'fabrication' in op:
data_by_item[item_id]['fabrication_ihh_pays'] = op['fabrication'].get('ihh_pays', '-')
data_by_item[item_id]['fabrication_ihh_acteurs'] = op['fabrication'].get('ihh_acteurs', '-')
result = []
produits = {k: v for k, v in data_by_item.items() if v['type'] == 'produit'}
if produits:
result.append("\n\n## Assemblage des produits\n")
produit_lignes = [
"| Produit | Assemblage IHH Pays | Assemblage IHH Acteurs |",
"| :-- | :--: | :--: |"
]
for produit, data in sorted(produits.items()):
pastille_1 = pastille("IHH", data['assemblage_ihh_pays'])
pastille_2 = pastille("IHH", data['assemblage_ihh_acteurs'])
produit_lignes.append(
f"| {produit} | {pastille_1} {data['assemblage_ihh_pays']} | {pastille_2} {data['assemblage_ihh_acteurs']} |"
)
result.append("\n".join(produit_lignes))
composants = {k: v for k, v in data_by_item.items() if v['type'] == 'composant'}
if composants:
result.append("\n\n## Fabrication des composants\n")
composant_lignes = [
"| Composant | Fabrication IHH Pays | Fabrication IHH Acteurs |",
"| :-- | :--: | :--: |"
]
for composant, data in sorted(composants.items()):
pastille_1 = pastille("IHH", data['fabrication_ihh_pays'])
pastille_2 = pastille("IHH", data['fabrication_ihh_acteurs'])
composant_lignes.append(
f"| {composant} | {pastille_1} {data['fabrication_ihh_pays']} | {pastille_2} {data['fabrication_ihh_acteurs']} |"
)
result.append("\n".join(composant_lignes))
minerais = {k: v for k, v in data_by_item.items() if v['type'] == 'minerai'}
if minerais:
result.append("\n\n## Opérations sur les minerais\n")
minerai_lignes = [
"| Minerai | Extraction IHH Pays | Extraction IHH Acteurs | Réserves IHH Pays | Traitement IHH Pays | Traitement IHH Acteurs |",
"| :-- | :--: | :--: | :--: | :--: | :--: |"
]
for minerai, data in sorted(minerais.items()):
pastille_1 = pastille("IHH", data['extraction_ihh_pays'])
pastille_2 = pastille("IHH", data['extraction_ihh_acteurs'])
pastille_3 = pastille("IHH", data['reserves_ihh_pays'])
pastille_4 = pastille("IHH", data['traitement_ihh_pays'])
pastille_5 = pastille("IHH", data['traitement_ihh_acteurs'])
minerai_lignes.append(
f"| {minerai} | {pastille_1} {data['extraction_ihh_pays']} | {pastille_2} {data['extraction_ihh_acteurs']} | "
f"{pastille_3} {data['reserves_ihh_pays']} | {pastille_4} {data['traitement_ihh_pays']} | {pastille_5} {data['traitement_ihh_acteurs']} |"
)
result.append("\n".join(minerai_lignes))
return "\n".join(result)
def build_ihh_sections(md: str) -> str:
segments = []
operations = []
intro = None
matches = list(IHH_RE.finditer(md))
if matches:
first = matches[0]
intro = md[:first.start()].strip()
else:
return md
for m in matches:
bloc_text = m.group(1)
bloc = yaml.safe_load("opération:" + bloc_text)
operations.append(bloc["opération"])
start = m.end()
next_match = IHH_RE.search(md, start)
end = next_match.start() if next_match else len(md)
section_template = md[start:end].strip()
rendered = Template(section_template).render(**bloc["opération"])
segments.append(rendered)
if intro:
segments.insert(0, intro)
if "# Tableaux de synthèse" in md:
synth_table = _synth_ihh(operations)
md_final = "\n\n".join(segments)
md_final = re.sub(
r"(?:##?|#) Tableaux de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"# Tableaux de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md_final,
flags=re.S
)
else:
md_final = "\n\n".join(segments)
return md_final

View File

@ -0,0 +1,50 @@
# isg.py
import re
import yaml
from ..utils.pastille import pastille
def _synth_isg(md: str) -> str:
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
if not yaml_block:
return "*(aucune donnée de pays trouvée)*"
yaml_data = yaml.safe_load(yaml_block.group(1))
lignes = ["| Pays | WGI | FSI | NDGAIN | ISG |", "| :-- | :-- | :-- | :-- | :-- |"]
sorted_pays = sorted(yaml_data.items(), key=lambda x: x[1]['pays'].lower())
for identifiant, data in sorted_pays:
pays = data['pays']
wgi_ps = data['wgi_ps']
fsi = data['fsi']
ndgain = data['ndgain']
isg = data['isg']
pastille_isg = pastille("ISG", isg)
lignes.append(f"| {pays} | {wgi_ps} | {fsi} | {ndgain} | {pastille_isg} {isg} |")
return "\n".join(lignes)
def build_isg_sections(md: str) -> str:
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
front_matter = yaml.safe_load(front_match.group(1))
if front_matter.get("indice_court") != "ISG":
return md
synth_table = _synth_isg(md)
md_final = re.sub(
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md,
flags=re.S
)
md_final = re.sub(
r"# Criticité par pays\s*\n```yaml[\s\S]*?```\s*",
"# Criticité par pays\n\n",
md_final,
flags=re.S
)
return md_final

View File

@ -0,0 +1,70 @@
# ivc.py
import re
import yaml
from jinja2 import Template
IVC_RE = re.compile(r"```yaml\s+minerai:(.*?)```", re.S | re.I)
def _synth_ivc(minerais: list[dict]) -> str:
"""Crée un tableau de synthèse pour les IVC des minerais."""
lignes = [
"| Minerai | IVC | Vulnérabilité |",
"| :-- | :-- | :-- |"
]
for minerai in minerais:
lignes.append(
f"| {minerai['nom']} | {minerai['ivc']} | {minerai['vulnerabilite']} |"
)
return "\n".join(lignes)
def _ivc_segments(md: str):
"""Yield (dict, segment) pour chaque bloc IVC yaml."""
pos = 0
for m in IVC_RE.finditer(md):
bloc = yaml.safe_load("minerai:" + m.group(1))
start = m.end()
next_match = IVC_RE.search(md, start)
end = next_match.start() if next_match else len(md)
yield bloc["minerai"], md[start:end].strip()
pos = end
yield None, md[pos:] # reste éventuel
def build_ivc_sections(md: str) -> str:
"""Remplace les blocs YAML minerai + segment avec rendu Jinja2, conserve l'intro."""
segments = []
minerais = [] # Pour collecter les données de chaque minerai
intro = None
matches = list(IVC_RE.finditer(md))
if matches:
first = matches[0]
intro = md[:first.start()].strip()
else:
return md # pas de blocs à traiter
for m in matches:
bloc = yaml.safe_load("minerai:" + m.group(1))
minerais.append(bloc["minerai"]) # Collecte les données
start = m.end()
next_match = IVC_RE.search(md, start)
end = next_match.start() if next_match else len(md)
rendered = Template(md[start:end].strip()).render(**bloc["minerai"])
segments.append(rendered)
if intro:
segments.insert(0, intro)
# Créer et insérer le tableau de synthèse
synth_table = _synth_ivc(minerais)
md_final = "\n\n".join(segments)
# Remplacer la section du tableau final
md_final = re.sub(
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md_final,
flags=re.S
)
return md_final

View File

@ -0,0 +1,581 @@
import streamlit as st
import re
import yaml
def _build_extraction_tableau(md: str, produit: str) -> str:
"""Génère le tableau d'extraction pour les fiches de minerai."""
# Identifier la section d'extraction
extraction_pattern = rf"Extraction_{re.escape(produit)}"
extraction_match = re.search(f"{extraction_pattern}:", md)
if not extraction_match:
return md # Pas de section d'extraction trouvée
# Récupérer les données structurées
extraction_data = {}
# Rechercher tous les pays et leurs acteurs
pays_pattern = rf"(\w+)_{extraction_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
for pays_match in re.finditer(pays_pattern, md):
code_pays, nom_pays, part_pays = pays_match.groups()
try:
part_marche_num = float(part_pays.strip().rstrip('%'))
except ValueError:
part_marche_num = 0
extraction_data[code_pays] = {
'nom': nom_pays.strip(),
'part_marche': part_pays.strip(),
'part_marche_num': part_marche_num,
'acteurs': []
}
# Rechercher tous les acteurs pour ce pays
acteur_pattern = rf"(\w+)_{code_pays}_{extraction_pattern}:\s+nom_de_l_acteur:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)\s+pays_d_origine:\s+([^\n]+)"
for acteur_match in re.finditer(acteur_pattern, md):
code_acteur, nom_acteur, part_acteur, pays_origine = acteur_match.groups()
try:
part_acteur_num = float(part_acteur.strip().rstrip('%'))
except ValueError:
part_acteur_num = 0
extraction_data[code_pays]['acteurs'].append({
'nom': nom_acteur.strip(),
'part_marche': part_acteur.strip(),
'part_marche_num': part_acteur_num,
'pays_origine': pays_origine.strip()
})
# Préparer les données pour l'affichage
pays_data = []
for code_pays, pays_info in extraction_data.items():
# Trier les acteurs par part de marché décroissante
acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
pays_data.append({
'nom': pays_info['nom'],
'part_marche': pays_info['part_marche'],
'part_marche_num': pays_info['part_marche_num'],
'acteurs': acteurs_tries
})
# Trier les pays par part de marché décroissante
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
# Générer le tableau des producteurs
lignes_tableau = [
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
"| :-- | :-- | :-- | :-- |"
]
for pays in pays_tries:
for acteur in pays['acteurs']:
# Formater la part de marché (ajouter un espace avant %)
part_marche_formattee = acteur['part_marche'].strip().replace('%', ' %')
lignes_tableau.append(
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
)
# Ajouter la ligne de total pour le pays (en gras)
part_marche_pays_formattee = pays['part_marche'].strip().replace('%', ' %')
lignes_tableau.append(
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
)
# Construire le tableau final
tableau_final = "\n".join(lignes_tableau)
# Remplacer la section du tableau dans la fiche
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-EXTRACTION -->.*?<!---- AUTO-END:TABLEAU-EXTRACTION -->",
f"<!---- AUTO-BEGIN:TABLEAU-EXTRACTION -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-EXTRACTION -->",
md,
flags=re.DOTALL
)
return md_modifie
def _build_traitement_tableau(md: str, produit: str) -> str:
"""Génère le tableau de traitement pour les fiches de minerai."""
# Identifier la section de traitement
traitement_pattern = rf"Traitement_{re.escape(produit)}"
traitement_match = re.search(f"{traitement_pattern}:", md)
if not traitement_match:
return md # Pas de section de traitement trouvée
# Récupérer les données structurées
traitement_data = {}
# Rechercher tous les pays et leurs acteurs
pays_pattern = rf"(\w+)_{traitement_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
for pays_match in re.finditer(pays_pattern, md):
code_pays, nom_pays, part_pays = pays_match.groups()
try:
part_marche_num = float(part_pays.strip().rstrip('%'))
except ValueError:
part_marche_num = 0
traitement_data[code_pays] = {
'nom': nom_pays.strip(),
'part_marche': part_pays.strip(),
'part_marche_num': part_marche_num,
'acteurs': []
}
# Rechercher tous les acteurs pour ce pays
acteur_pattern = rf"(\w+)_{code_pays}_{traitement_pattern}:"
for acteur_match in re.finditer(acteur_pattern, md):
code_acteur = acteur_match.group(1)
# Récupérer les informations de l'acteur
nom_acteur_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:\s+nom_de_l_acteur:\s+([^\n]+)", md)
part_acteur_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?part_de_marche:\s+([^\n]+)", md, re.DOTALL)
pays_origine_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?pays_d_origine:\s+([^\n]+)", md, re.DOTALL)
if nom_acteur_match and part_acteur_match and pays_origine_match:
nom_acteur = nom_acteur_match.group(1).strip()
part_acteur = part_acteur_match.group(1).strip()
pays_origine = pays_origine_match.group(1).strip()
try:
part_acteur_num = float(part_acteur.strip().rstrip('%'))
except ValueError:
part_acteur_num = 0
# Récupérer les origines du minerai
origines_minerai = []
for i in range(1, 10): # Vérifier jusqu'à 9 origines possibles
origine_key = "minerai_origine" if i == 1 else f"minerai_origine_{i}"
origine_pattern = rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?{origine_key}:\s+pays:\s+([^\n]+)\s+pourcentage:\s+([^\n]+)"
origine_match = re.search(origine_pattern, md, re.DOTALL)
if origine_match:
pays_origine_minerai = origine_match.group(1).strip()
pourcentage_origine = origine_match.group(2).strip()
origines_minerai.append(f"{pays_origine_minerai} ({pourcentage_origine})")
else:
break
origine_text = ", ".join(origines_minerai) if origines_minerai else ""
traitement_data[code_pays]['acteurs'].append({
'nom': nom_acteur,
'part_marche': part_acteur,
'part_marche_num': part_acteur_num,
'pays_origine': pays_origine,
'origine_minerai': origine_text
})
# Préparer les données pour l'affichage
pays_data = []
for code_pays, pays_info in traitement_data.items():
# Trier les acteurs par part de marché décroissante
acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
pays_data.append({
'nom': pays_info['nom'],
'part_marche': pays_info['part_marche'],
'part_marche_num': pays_info['part_marche_num'],
'acteurs': acteurs_tries
})
# Trier les pays par part de marché décroissante
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
# Générer le tableau des producteurs
lignes_tableau = [
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Origines du minerai** | **Part de marché** |",
"| :-- | :-- | :-- | :-- | :-- |"
]
for pays in pays_tries:
for acteur in pays['acteurs']:
# Formater la part de marché (ajouter un espace avant %)
part_marche_formattee = acteur['part_marche'].strip().replace('%', ' %')
lignes_tableau.append(
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {acteur['origine_minerai']} | {part_marche_formattee} |"
)
# Ajouter la ligne de total pour le pays (en gras)
part_marche_pays_formattee = pays['part_marche'].strip().replace('%', ' %')
lignes_tableau.append(
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **-** | **{part_marche_pays_formattee}** |"
)
# Construire le tableau final
tableau_final = "\n".join(lignes_tableau)
# Remplacer la section du tableau dans la fiche
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-TRAITEMENT -->.*?<!---- AUTO-END:TABLEAU-TRAITEMENT -->",
f"<!---- AUTO-BEGIN:TABLEAU-TRAITEMENT -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-TRAITEMENT -->",
md,
flags=re.DOTALL
)
return md_modifie
def _build_reserves_tableau(md: str, produit: str) -> str:
"""Génère le tableau des réserves pour les fiches de minerai."""
# Identifier la section des réserves
reserves_pattern = rf"Reserves_{re.escape(produit)}"
reserves_match = re.search(f"{reserves_pattern}:", md)
if not reserves_match:
return md # Pas de section de réserves trouvée
# Récupérer les données structurées
reserves_data = []
# Rechercher tous les pays et leurs parts de marché
pays_pattern = rf"(\w+)_{reserves_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
for pays_match in re.finditer(pays_pattern, md):
code_pays, nom_pays, part_pays = pays_match.groups()
try:
part_marche_num = float(part_pays.strip().rstrip('%'))
except ValueError:
part_marche_num = 0
reserves_data.append({
'nom': nom_pays.strip(),
'part_marche': part_pays.strip(),
'part_marche_num': part_marche_num
})
# Trier les pays par part de marché décroissante
reserves_data_triees = sorted(reserves_data, key=lambda x: x['part_marche_num'], reverse=True)
# Générer le tableau des réserves
lignes_tableau = [
"| **Pays d'implantation** | **Part de marché** |",
"| :-- | :-- |"
]
for pays in reserves_data_triees:
# Formater la part de marché (ajouter un espace avant %)
part_marche_formattee = pays['part_marche'].strip().replace('%', ' %')
lignes_tableau.append(f"| {pays['nom']} | {part_marche_formattee} |")
# Construire le tableau final
tableau_final = "\n".join(lignes_tableau)
# Remplacer la section du tableau dans la fiche
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-RESERVES -->.*?<!---- AUTO-END:TABLEAU-RESERVES -->",
f"<!---- AUTO-BEGIN:TABLEAU-RESERVES -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-RESERVES -->",
md,
flags=re.DOTALL
)
return md_modifie
def build_minerai_ivc_section(md: str) -> str:
"""
Ajoute les informations IVC depuis la fiche technique IVC.md pour un minerai spécifique.
"""
# Extraire le type de fiche et le produit depuis l'en-tête YAML
type_fiche = None
produit = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
front_matter = yaml.safe_load(front_match.group(1))
type_fiche = front_matter.get("type_fiche")
produit = front_matter.get("produit")
# Vérifier si c'est bien une fiche de minerai
if type_fiche != "minerai" or not produit:
return md
except Exception as e:
st.error(f"Erreur lors du chargement du front matter: {e}")
return md
# Injecter les informations IVC depuis la fiche technique
try:
# Charger le contenu de la fiche technique IVC
ivc_path = "Fiches/Criticités/Fiche technique IVC.md"
with open(ivc_path, "r", encoding="utf-8") as f:
ivc_content = f.read()
# Chercher la section correspondant au minerai
# Le pattern cherche un titre de niveau 2 commençant par le nom du produit
section_pattern = rf"## {produit} - (.*?)(?=\n###|$)"
section_match = re.search(section_pattern, ivc_content, re.DOTALL)
if section_match:
# Extraire la partie après le nom du minerai (ex: "IVC : 4 - Vulnérabilité: Faible")
ivc_value = section_match.group(1).strip()
# Extraire toutes les sous-sections
# Le pattern cherche depuis le titre du minerai jusqu'à la prochaine section de même niveau ou fin de fichier
full_section_pattern = rf"## {produit} - .*?\n([\s\S]*?)(?=\n## |$)"
full_section_match = re.search(full_section_pattern, ivc_content)
if full_section_match:
section_content = full_section_match.group(1).strip()
# Formater le contenu à insérer
ivc_content_formatted = f"```\n{ivc_value}\n```\n\n{section_content}"
# Remplacer la section IVC dans le markdown
md = re.sub(
r"<!---- AUTO-BEGIN:SECTION-IVC-MINERAI -->.*?<!---- AUTO-END:SECTION-IVC-MINERAI -->",
f"<!---- AUTO-BEGIN:SECTION-IVC-MINERAI -->\n{ivc_content_formatted}\n<!---- AUTO-END:SECTION-IVC-MINERAI -->",
md,
flags=re.DOTALL
)
except Exception as e:
st.error(f"Erreur lors de la génération de la section IVC: {e}")
return md
def build_minerai_ics_section(md: str) -> str:
"""
Ajoute les informations ICS depuis la fiche technique ICS.md pour un minerai spécifique.
"""
# Extraire le type de fiche et le produit depuis l'en-tête YAML
type_fiche = None
produit = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
front_matter = yaml.safe_load(front_match.group(1))
type_fiche = front_matter.get("type_fiche")
produit = front_matter.get("produit")
# Vérifier si c'est bien une fiche de minerai
if type_fiche != "minerai" or not produit:
return md
except Exception as e:
st.error(f"Erreur lors du chargement du front matter: {e}")
return md
# Injecter les informations ICS depuis la fiche technique
try:
# Charger le contenu de la fiche technique ICS
ics_path = "Fiches/Criticités/Fiche technique ICS.md"
with open(ics_path, "r", encoding="utf-8") as f:
ics_content = f.read()
# Extraire la section ICS pour le minerai
# Dans le fichier ICS, on recherche dans la section "# Criticité par minerai"
# puis on cherche la sous-section correspondant au minerai
minerai_section_pattern = r"# Criticité par minerai\s*\n([\s\S]*?)(?=\n# |$)"
minerai_section_match = re.search(minerai_section_pattern, ics_content)
if minerai_section_match:
minerai_section = minerai_section_match.group(1)
# Chercher le minerai spécifique
# Rechercher un titre de niveau 2 correspondant exactement au produit
specific_minerai_pattern = rf"## {produit}\s*\n([\s\S]*?)(?=\n## |$)"
specific_minerai_match = re.search(specific_minerai_pattern, minerai_section)
if specific_minerai_match:
# Extraire le contenu de la section du minerai
minerai_content = specific_minerai_match.group(1).strip()
# Remplacer la section ICS dans le markdown
md = re.sub(
r"<!---- AUTO-BEGIN:SECTION-ICS-MINERAI -->.*?<!---- AUTO-END:SECTION-ICS-MINERAI -->",
f"<!---- AUTO-BEGIN:SECTION-ICS-MINERAI -->\n{minerai_content}\n<!---- AUTO-END:SECTION-ICS-MINERAI -->",
md,
flags=re.DOTALL
)
except Exception as e:
st.error(f"Erreur lors de la génération de la section ICS: {e}")
return md
def build_minerai_ics_composant_section(md: str) -> str:
"""
Ajoute les informations ICS pour tous les composants liés à un minerai spécifique
depuis la fiche technique ICS.md, en augmentant d'un niveau les titres.
"""
# Extraire le type de fiche et le produit depuis l'en-tête YAML
type_fiche = None
produit = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
front_matter = yaml.safe_load(front_match.group(1))
type_fiche = front_matter.get("type_fiche")
produit = front_matter.get("produit")
# Vérifier si c'est bien une fiche de minerai
if type_fiche != "minerai" or not produit:
return md
except Exception as e:
st.error(f"Erreur lors du chargement du front matter: {e}")
return md
# Injecter les informations ICS depuis la fiche technique
try:
# Charger le contenu de la fiche technique ICS
ics_path = "Fiches/Criticités/Fiche technique ICS.md"
with open(ics_path, "r", encoding="utf-8") as f:
ics_content = f.read()
# Rechercher toutes les sections de composants liés au minerai
# Le pattern cherche les titres de niveau 2 de la forme "## * -> Minerai"
composant_sections_pattern = rf"## ([^>]+) -> {produit} - .*?\n([\s\S]*?)(?=\n## |$)"
composant_sections = re.finditer(composant_sections_pattern, ics_content)
all_composant_content = []
for match in composant_sections:
composant = match.group(1).strip()
section_content = match.group(2).strip()
# Augmenter le niveau des titres d'un cran
# Titre de niveau 2 -> niveau 3
section_title = f"### {composant} -> {produit}{match.group(0).split(produit)[1].split('\n')[0]}"
# Augmenter les niveaux des sous-titres (### -> ####)
section_content = re.sub(r"### ", "#### ", section_content)
# Ajouter à la liste des contenus
all_composant_content.append(f"{section_title}\n{section_content}")
# Combiner tous les contenus
if all_composant_content:
combined_content = "\n\n".join(all_composant_content)
# Remplacer la section ICS dans le markdown
md = re.sub(
r"<!---- AUTO-BEGIN:SECTION-ICS-COMPOSANT-MINERAI -->.*?<!---- AUTO-END:SECTION-ICS-COMPOSANT-MINERAI -->",
f"<!---- AUTO-BEGIN:SECTION-ICS-COMPOSANT-MINERAI -->\n{combined_content}\n<!---- AUTO-END:SECTION-ICS-COMPOSANT-MINERAI -->",
md,
flags=re.DOTALL
)
except Exception as e:
st.error(f"Erreur lors de la génération de la section ICS pour les composants: {e}")
return md
def build_minerai_sections(md: str) -> str:
"""Traite les fiches de minerai et génère les tableaux des producteurs."""
# Extraire le type de fiche depuis l'en-tête YAML
type_fiche = None
produit = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
front_matter = yaml.safe_load(front_match.group(1))
type_fiche = front_matter.get("type_fiche")
produit = front_matter.get("produit")
# Vérifier si c'est bien une fiche de minerai
if type_fiche != "minerai" or not produit:
return md
except Exception as e:
st.error(f"Erreur lors du chargement du front matter: {e}")
return md
# Traiter le tableau d'Extraction
md = _build_extraction_tableau(md, produit)
# Traiter le tableau de Traitement
md = _build_traitement_tableau(md, produit)
# Traiter le tableau des Réserves
md = _build_reserves_tableau(md, produit)
# Supprimer les blocs YAML complets, y compris les délimiteurs ```yaml et ```
# Rechercher d'abord les blocs avec délimiteurs YAML
yaml_blocks = [
rf"```yaml\s*\n{re.escape(produit)}.*?```",
rf"```yaml\s*\nExtraction_{re.escape(produit)}.*?```",
rf"```yaml\s*\nReserves_{re.escape(produit)}.*?```",
rf"```yaml\s*\nTraitement_{re.escape(produit)}.*?```"
]
for pattern in yaml_blocks:
md = re.sub(pattern, "", md, flags=re.DOTALL)
# Supprimer également les blocs qui ne seraient pas entourés de délimiteurs
patterns_to_remove = [
rf"Extraction_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)",
rf"Reserves_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)",
rf"Traitement_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)"
]
for pattern in patterns_to_remove:
md = re.sub(pattern, "", md, flags=re.DOTALL)
# Nettoyer les délimiteurs ```yaml et ``` qui pourraient rester
md = re.sub(r"```yaml\s*\n", "", md)
md = re.sub(r"```\s*\n", "", md)
# Injecter les sections IHH depuis la fiche technique
try:
# Charger le contenu de la fiche technique IHH
ihh_path = "Fiches/Criticités/Fiche technique IHH.md"
with open(ihh_path, "r", encoding="utf-8") as f:
ihh_content = f.read()
# D'abord, extraire toute la section concernant le produit
section_produit_pattern = rf"## Opérations - {produit}\s*\n([\s\S]*?)(?=\n## |$)"
section_produit_match = re.search(section_produit_pattern, ihh_content, re.IGNORECASE)
if section_produit_match:
section_produit = section_produit_match.group(1).strip()
# Maintenant, extraire les sous-sections individuelles à partir de la section du produit
# 1. Extraction - incluant le titre de niveau 3
extraction_pattern = r"(### Indice de Herfindahl-Hirschmann - Extraction\s*\n[\s\S]*?)(?=### Indice de Herfindahl-Hirschmann - Réserves|$)"
extraction_match = re.search(extraction_pattern, section_produit, re.IGNORECASE)
if extraction_match:
extraction_ihh = extraction_match.group(1).strip()
md = re.sub(
r"<!---- AUTO-BEGIN:SECTION-IHH-EXTRACTION -->.*?<!---- AUTO-END:SECTION-IHH-EXTRACTION -->",
f"<!---- AUTO-BEGIN:SECTION-IHH-EXTRACTION -->\n{extraction_ihh}\n<!---- AUTO-END:SECTION-IHH-EXTRACTION -->",
md,
flags=re.DOTALL
)
# 2. Réserves - incluant le titre de niveau 3
reserves_pattern = r"(### Indice de Herfindahl-Hirschmann - Réserves\s*\n[\s\S]*?)(?=### Indice de Herfindahl-Hirschmann - Traitement|$)"
reserves_match = re.search(reserves_pattern, section_produit, re.IGNORECASE)
if reserves_match:
reserves_ihh = reserves_match.group(1).strip()
md = re.sub(
r"<!---- AUTO-BEGIN:SECTION-IHH-RESERVES -->.*?<!---- AUTO-END:SECTION-IHH-RESERVES -->",
f"<!---- AUTO-BEGIN:SECTION-IHH-RESERVES -->\n{reserves_ihh}\n<!---- AUTO-END:SECTION-IHH-RESERVES -->",
md,
flags=re.DOTALL
)
# 3. Traitement - incluant le titre de niveau 3
traitement_pattern = r"(### Indice de Herfindahl-Hirschmann - Traitement\s*\n[\s\S]*?)(?=$)"
traitement_match = re.search(traitement_pattern, section_produit, re.IGNORECASE)
if traitement_match:
traitement_ihh = traitement_match.group(1).strip()
md = re.sub(
r"<!---- AUTO-BEGIN:SECTION-IHH-TRAITEMENT -->.*?<!---- AUTO-END:SECTION-IHH-TRAITEMENT -->",
f"<!---- AUTO-BEGIN:SECTION-IHH-TRAITEMENT -->\n{traitement_ihh}\n<!---- AUTO-END:SECTION-IHH-TRAITEMENT -->",
md,
flags=re.DOTALL
)
except Exception as e:
st.error(f"Erreur lors de la génération des sections IHH: {e}")
# Nettoyer les doubles sauts de ligne
md = re.sub(r"\n{3,}", "\n\n", md)
# Ajouter les informations IVC
md = build_minerai_ivc_section(md)
# Ajouter les informations ICS
md = build_minerai_ics_section(md)
# Ajouter les informations ICS pour les composants liés au minerai
md = build_minerai_ics_composant_section(md)
return md

View File

@ -0,0 +1,30 @@
# pastille.py
from typing import Any
PASTILLE_ICONS = {
"vert": "",
"orange": "🔶",
"rouge": "🔴"
}
def pastille(indice: str, valeur: Any, seuils: dict = None) -> str:
try:
import streamlit as st
seuils = seuils or st.session_state.get("seuils", {})
if indice not in seuils:
return ""
seuil = seuils[indice]
vert_max = seuil["vert"]["max"]
rouge_min = seuil["rouge"]["min"]
val = float(valeur)
if val < vert_max:
return PASTILLE_ICONS["vert"]
elif val > rouge_min:
return PASTILLE_ICONS["rouge"]
else:
return PASTILLE_ICONS["orange"]
except (KeyError, ValueError, TypeError):
return ""

View File

@ -1,552 +0,0 @@
import re, yaml, pandas as pd, textwrap
import unicodedata
from jinja2 import Template
import streamlit as st
def pastille(indice, valeur):
try:
SEUILS = st.session_state['seuils']
VERT = SEUILS[indice]["vert"]["max"]
ROUGE = SEUILS[indice]["rouge"]["min"]
pastille_verte = ""
pastille_orange = "🔶"
pastille_rouge = "🔴"
if float(valeur) < VERT:
return pastille_verte
elif float(valeur) > ROUGE:
return pastille_rouge
else:
return pastille_orange
except:
return ""
# -------- repère chaque bloc ```yaml … ``` -------------
PAIR_RE = re.compile(r"```yaml[^\n]*\n(.*?)```", re.S | re.I)
def _pairs_dataframe(md: str) -> pd.DataFrame:
rows = []
for raw in PAIR_RE.findall(md):
bloc = yaml.safe_load(raw)
if isinstance(bloc, dict) and "pair" in bloc:
rows.append(bloc["pair"])
return pd.DataFrame(rows)
def _normalize_unicode(text: str) -> str:
return unicodedata.normalize("NFKC", text)
def _fill(segment: str, pair: dict) -> str:
segment = _normalize_unicode(segment)
for k, v in pair.items():
val = f"{v:.2f}" if isinstance(v, (int, float)) else str(v)
segment = re.sub(
rf"{{{{\s*{re.escape(k)}\s*}}}}",
val,
segment,
flags=re.I,
)
segment = re.sub(
r"ICS\s*=\s*[-+]?\d+(?:\.\d+)?",
f"ICS = {pair['ics']:.2f}",
segment,
count=1,
)
return segment
def _segments(md: str):
blocs = list(PAIR_RE.finditer(md))
for i, match in enumerate(blocs):
pair = yaml.safe_load(match.group(1))["pair"]
start = match.end()
end = blocs[i + 1].start() if i + 1 < len(blocs) else len(md)
segment = md[start:end]
yield pair, segment
def _pivot(df: pd.DataFrame) -> str:
out = []
for min_, g in df.groupby("minerai"):
out += [f"## {min_}",
"| Composant | ICS | Faisabilité technique | Délai d'implémentation | Impact économique |",
"| :-- | :--: | :--: | :--: | :--: |"]
for _, r in g.sort_values("ics", ascending=False).iterrows():
out += [f"| {r.composant} | {r.ics:.2f} | {r.f_tech:.2f} | "
f"{r.delai:.2f} | {r.cout:.2f} |"]
out.append("")
return "\n".join(out)
def _synth(df: pd.DataFrame) -> str:
lignes = ["| Composant | Minerai | ICS |", "| :-- | :-- | :--: |"]
for _, r in df.sort_values("ics", ascending=False).iterrows():
lignes.append(f"| {r.composant} | {r.minerai} | {r.ics:.2f} |")
return "\n".join(lignes)
def build_dynamic_sections(md_raw: str) -> str:
md_raw = _normalize_unicode(md_raw)
df = _pairs_dataframe(md_raw)
if df.empty:
return md_raw
couples = ["# Criticité par couple Composant -> Minerai"]
for pair, seg in _segments(md_raw):
if pair:
couples.append(_fill(seg, pair))
couples_md = "\n".join(couples)
pivot_md = _pivot(df)
synth_md = _synth(df)
md = re.sub(r"#\s+Criticité par couple.*", couples_md, md_raw, flags=re.S | re.I)
md = re.sub(r"<!---- AUTO-BEGIN:PIVOT -->.*?<!---- AUTO-END:PIVOT -->",
f"<!---- AUTO-BEGIN:PIVOT -->\n{pivot_md}\n<!---- AUTO-END:PIVOT -->",
md, flags=re.S)
md = re.sub(r"<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_md}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md, flags=re.S)
return textwrap.dedent(md)
IVC_RE = re.compile(r"```yaml\s+minerai:(.*?)```", re.S | re.I)
def _synth_ivc(minerais: list[dict]) -> str:
"""Crée un tableau de synthèse pour les IVC des minerais."""
lignes = [
"| Minerai | IVC | Vulnérabilité |",
"| :-- | :-- | :-- |"
]
for minerai in minerais:
lignes.append(
f"| {minerai['nom']} | {minerai['ivc']} | {minerai['vulnerabilite']} |"
)
return "\n".join(lignes)
def _ivc_segments(md: str):
"""Yield (dict, segment) pour chaque bloc IVC yaml."""
pos = 0
for m in IVC_RE.finditer(md):
bloc = yaml.safe_load("minerai:" + m.group(1))
start = m.end()
next_match = IVC_RE.search(md, start)
end = next_match.start() if next_match else len(md)
yield bloc["minerai"], md[start:end].strip()
pos = end
yield None, md[pos:] # reste éventuel
def build_ivc_sections(md: str) -> str:
"""Remplace les blocs YAML minerai + segment avec rendu Jinja2, conserve l'intro."""
segments = []
minerais = [] # Pour collecter les données de chaque minerai
intro = None
matches = list(IVC_RE.finditer(md))
if matches:
first = matches[0]
intro = md[:first.start()].strip()
else:
return md # pas de blocs à traiter
for m in matches:
bloc = yaml.safe_load("minerai:" + m.group(1))
minerais.append(bloc["minerai"]) # Collecte les données
start = m.end()
next_match = IVC_RE.search(md, start)
end = next_match.start() if next_match else len(md)
rendered = Template(md[start:end].strip()).render(**bloc["minerai"])
segments.append(rendered)
if intro:
segments.insert(0, intro)
# Créer et insérer le tableau de synthèse
synth_table = _synth_ivc(minerais)
md_final = "\n\n".join(segments)
# Remplacer la section du tableau final
md_final = re.sub(
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md_final,
flags=re.S
)
return md_final
def build_assemblage_sections(md: str) -> str:
"""Traite les fiches d'assemblage/fabrication et génère le tableau des assembleurs/fabricants à partir du bloc YAML."""
# Extraire le schéma depuis l'en-tête YAML de la fiche
schema = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
front_matter = yaml.safe_load(front_match.group(1))
schema = front_matter.get("schema")
type_fiche = front_matter.get("type_fiche")
# Vérifier si c'est bien une fiche d'assemblage ou de fabrication
if type_fiche not in ["assemblage", "fabrication"] or not schema:
return md
except Exception as e:
st.error(f"Erreur lors du chargement du front matter: {e}")
return md
# Rechercher le bloc YAML dans la fiche (entre ```yaml et ```)
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
if not yaml_block:
return md # Pas de bloc YAML trouvé, retourner la fiche inchangée
# Charger les données YAML
try:
yaml_data = yaml.safe_load(yaml_block.group(1))
except Exception as e:
st.error(f"Erreur lors du chargement du YAML: {e}")
return md
# Vérifier la structure du YAML (clé principale suivie des pays)
if not isinstance(yaml_data, dict) or len(yaml_data) == 0:
return md
# Récupérer la clé principale (nom du produit assemblé)
produit_key = list(yaml_data.keys())[0]
produit_data = yaml_data[produit_key]
# Structure pour stocker les données triées
pays_data = []
# Pour chaque pays, extraire ses données et celles de ses acteurs
for pays_key, pays_info in produit_data.items():
nom_pays = pays_info.get('nom_du_pays', '')
part_marche_pays = pays_info.get('part_de_marche', '0%')
# Convertir la part de marché en nombre pour le tri
part_marche_num = float(part_marche_pays.strip('%'))
acteurs = []
for acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
nom_acteur = acteur_info.get('nom_de_l_acteur', '')
part_marche_acteur = acteur_info.get('part_de_marche', '0%')
pays_origine = acteur_info.get('pays_d_origine', '')
# Convertir la part de marché en nombre pour le tri
part_marche_acteur_num = float(part_marche_acteur.strip('%'))
acteurs.append({
'nom': nom_acteur,
'part_marche': part_marche_acteur,
'part_marche_num': part_marche_acteur_num,
'pays_origine': pays_origine
})
# Trier les acteurs par part de marché décroissante
acteurs_tries = sorted(acteurs, key=lambda x: x['part_marche_num'], reverse=True)
pays_data.append({
'nom': nom_pays,
'part_marche': part_marche_pays,
'part_marche_num': part_marche_num,
'acteurs': acteurs_tries
})
# Trier les pays par part de marché décroissante
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
# Générer le tableau des assembleurs
lignes_tableau = [
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
"| :-- | :-- | :-- | :-- |"
]
for pays in pays_tries:
for acteur in pays['acteurs']:
# Formater la part de marché (retirer le % du texte et ajouter un espace avant %)
part_marche_formattee = acteur['part_marche'].strip('%') + ' %'
lignes_tableau.append(
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
)
# Ajouter la ligne de total pour le pays (en gras)
part_marche_pays_formattee = pays['part_marche'].strip('%') + ' %'
lignes_tableau.append(
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
)
# Construire le tableau final
tableau_final = "\n".join(lignes_tableau)
# Remplacer la section du tableau dans la fiche (assembleurs ou fabricants selon le type)
if type_fiche == "fabrication":
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-FABRICANTS -->.*?<!---- AUTO-END:TABLEAU-FABRICANTS -->",
f"<!---- AUTO-BEGIN:TABLEAU-FABRICANTS -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-FABRICANTS -->",
md,
flags=re.DOTALL
)
else: # type_fiche == "assemblage"
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->.*?<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->",
f"<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->",
md,
flags=re.DOTALL
)
# Chercher et remplacer la section IHH si un schéma a été identifié
if schema:
# Charger le contenu de la fiche technique IHH
try:
# Essayer de lire le fichier depuis le système de fichiers
ihh_path = "Fiches/Criticités/Fiche technique IHH.md"
with open(ihh_path, "r", encoding="utf-8") as f:
ihh_content = f.read()
# Chercher la section IHH correspondant au schéma et au type de fiche
# Format de la section : ## Assemblage/Fabrication - [Schema]
if type_fiche == "fabrication":
ihh_section_pattern = rf"## Fabrication - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
else: # type_fiche == "assemblage"
ihh_section_pattern = rf"## Assemblage - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
ihh_section_match = re.search(ihh_section_pattern, ihh_content)
if ihh_section_match:
# Extraire la section complète sans le titre principal
ihh_section = ihh_section_match.group(0).split("\n", 2)[2].strip()
# Remplacer la section IHH dans la fiche d'assemblage
md_modifie = re.sub(
r"<!---- AUTO-BEGIN:SECTION-IHH -->.*?<!---- AUTO-END:SECTION-IHH -->",
f"<!---- AUTO-BEGIN:SECTION-IHH -->\n{ihh_section}\n<!---- AUTO-END:SECTION-IHH -->",
md_modifie,
flags=re.DOTALL
)
else:
# Si aucune section IHH n'est trouvée pour ce schéma, laisser la section existante
st.warning(f"Aucune section IHH trouvée pour le schéma {schema} dans la fiche technique IHH.")
except Exception as e:
st.error(f"Erreur lors de la lecture/traitement de la fiche IHH: {e}")
return md_modifie
# Regex pour capturer les blocs YAML d'opération dans les fiches IHH
IHH_RE = re.compile(r"```yaml\s+opération:(.*?)```", re.S | re.I)
def _synth_ihh(operations: list[dict]) -> str:
"""Crée un tableau de synthèse pour les IHH."""
# Créer un dictionnaire pour regrouper les données par minerai/produit/composant
data_by_item = {}
for op in operations:
nom = op.get('nom', '')
item_id = op.get('minerai', op.get('produit', op.get('composant', '')))
if not item_id:
continue
# Initialiser l'entrée si elle n'existe pas encore
if item_id not in data_by_item:
data_by_item[item_id] = {
'type': 'minerai' if 'extraction' in op or 'reserves' in op or 'traitement' in op else
'produit' if 'assemblage' in op else 'composant',
'extraction_ihh_pays': '-',
'extraction_ihh_acteurs': '-',
'reserves_ihh_pays': '-',
'traitement_ihh_pays': '-',
'traitement_ihh_acteurs': '-',
'assemblage_ihh_pays': '-',
'assemblage_ihh_acteurs': '-',
'fabrication_ihh_pays': '-',
'fabrication_ihh_acteurs': '-'
}
# Mettre à jour les valeurs selon le type d'opération
if 'extraction' in op:
data_by_item[item_id]['extraction_ihh_pays'] = op['extraction'].get('ihh_pays', '-')
data_by_item[item_id]['extraction_ihh_acteurs'] = op['extraction'].get('ihh_acteurs', '-')
data_by_item[item_id]['reserves_ihh_pays'] = op['reserves'].get('ihh_pays', '-')
data_by_item[item_id]['traitement_ihh_pays'] = op['traitement'].get('ihh_pays', '-')
data_by_item[item_id]['traitement_ihh_acteurs'] = op['traitement'].get('ihh_acteurs', '-')
elif 'assemblage' in op:
data_by_item[item_id]['assemblage_ihh_pays'] = op['assemblage'].get('ihh_pays', '-')
data_by_item[item_id]['assemblage_ihh_acteurs'] = op['assemblage'].get('ihh_acteurs', '-')
elif 'fabrication' in op:
data_by_item[item_id]['fabrication_ihh_pays'] = op['fabrication'].get('ihh_pays', '-')
data_by_item[item_id]['fabrication_ihh_acteurs'] = op['fabrication'].get('ihh_acteurs', '-')
# Compléter avec les autres types si présents
result = []
# Tableau des produits
produits = {k: v for k, v in data_by_item.items() if v['type'] == 'produit'}
if produits:
result.append("\n\n## Assemblage des produits\n")
produit_lignes = [
"| Produit | Assemblage IHH Pays | Assemblage IHH Acteurs |",
"| :-- | :--: | :--: |"
]
for produit, data in sorted(produits.items()):
pastille_1 = pastille("IHH", data['assemblage_ihh_pays'])
pastille_2 = pastille("IHH", data['assemblage_ihh_acteurs'])
produit_lignes.append(
f"| {produit} | {pastille_1} {data['assemblage_ihh_pays']} | {pastille_2} {data['assemblage_ihh_acteurs']} |"
)
result.append("\n".join(produit_lignes))
# Tableau des composants
composants = {k: v for k, v in data_by_item.items() if v['type'] == 'composant'}
if composants:
result.append("\n\n## Fabrication des composants\n")
composant_lignes = [
"| Composant | Fabrication IHH Pays | Fabrication IHH Acteurs |",
"| :-- | :--: | :--: |"
]
for composant, data in sorted(composants.items()):
pastille_1 = pastille("IHH", data['fabrication_ihh_pays'])
pastille_2 = pastille("IHH", data['fabrication_ihh_acteurs'])
composant_lignes.append(
f"| {composant} | {pastille_1} {data['fabrication_ihh_pays']} | {pastille_2} {data['fabrication_ihh_acteurs']} |"
)
result.append("\n".join(composant_lignes))
# Trier et créer le tableau de minerais (celui demandé)
minerais = {k: v for k, v in data_by_item.items() if v['type'] == 'minerai'}
if minerais:
result.append("\n\n## Opérations sur les minerais\n")
minerai_lignes = [
"| Minerai | Extraction IHH Pays | Extraction IHH Acteurs | Réserves IHH Pays | Traitement IHH Pays | Traitement IHH Acteurs |",
"| :-- | :--: | :--: | :--: | :--: | :--: |"
]
for minerai, data in sorted(minerais.items()):
pastille_1 = pastille("IHH", data['extraction_ihh_pays'])
pastille_2 = pastille("IHH", data['extraction_ihh_acteurs'])
pastille_3 = pastille("IHH", data['reserves_ihh_pays'])
pastille_4 = pastille("IHH", data['traitement_ihh_pays'])
pastille_5 = pastille("IHH", data['traitement_ihh_acteurs'])
minerai_lignes.append(
f"| {minerai} | {pastille_1} {data['extraction_ihh_pays']} | {pastille_2} {data['extraction_ihh_acteurs']} | "
f"{pastille_3} {data['reserves_ihh_pays']} | {pastille_4} {data['traitement_ihh_pays']} | {pastille_5} {data['traitement_ihh_acteurs']} |"
)
result.append("\n".join(minerai_lignes))
return "\n".join(result)
def build_ihh_sections(md: str) -> str:
"""Traite les fiches IHH pour les opérations, produits, composants et minerais."""
segments = []
operations = [] # Pour collecter les données de chaque opération
intro = None
matches = list(IHH_RE.finditer(md))
if matches:
first = matches[0]
intro = md[:first.start()].strip()
else:
return md # pas de blocs à traiter
# Traiter chaque bloc YAML et sa section correspondante
for m in matches:
bloc_text = m.group(1)
bloc = yaml.safe_load("opération:" + bloc_text)
operations.append(bloc["opération"]) # Collecte les données
start = m.end()
next_match = IHH_RE.search(md, start)
end = next_match.start() if next_match else len(md)
# Utiliser Jinja2 pour le rendu de la section
section_template = md[start:end].strip()
rendered = Template(section_template).render(**bloc["opération"])
segments.append(rendered)
if intro:
segments.insert(0, intro)
# Créer et insérer le tableau de synthèse si nécessaire
if "# Tableaux de synthèse" in md:
synth_table = _synth_ihh(operations)
md_final = "\n\n".join(segments)
# Remplacer la section du tableau final
md_final = re.sub(
r"(?:##?|#) Tableaux de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"# Tableaux de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md_final,
flags=re.S
)
else:
md_final = "\n\n".join(segments)
return md_final
def _synth_isg(md: str) -> str:
"""Crée un tableau de synthèse pour les ISG à partir du bloc YAML des pays."""
# Regex pour trouver le bloc YAML des pays
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
if not yaml_block:
return "*(aucune donnée de pays trouvée)*"
# Charger les données YAML
yaml_data = yaml.safe_load(yaml_block.group(1))
# Préparer les lignes du tableau
lignes = ["| Pays | WGI | FSI | NDGAIN | ISG |", "| :-- | :-- | :-- | :-- | :-- |"]
# Trier les pays par nom
sorted_pays = sorted(yaml_data.items(), key=lambda x: x[1]['pays'].lower())
# Ajouter chaque pays au tableau
for identifiant, data in sorted_pays:
pays = data['pays']
wgi_ps = data['wgi_ps']
fsi = data['fsi']
ndgain = data['ndgain']
isg = data['isg']
# Ajouter pastilles en fonction des seuils
pastille_isg = pastille("ISG", isg) if 'ISG' in st.session_state.get('seuils', {}) else ""
lignes.append(f"| {pays} | {wgi_ps} | {fsi} | {ndgain} | {pastille_isg} {isg} |")
return "\n".join(lignes)
def build_isg_sections(md: str) -> str:
"""Traite les fiches ISG pour générer le tableau de synthèse des pays."""
# Vérifier si c'est une fiche ISG (via indice_court dans les frontmatter)
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
front_matter = yaml.safe_load(front_match.group(1))
if front_matter.get("indice_court") != "ISG":
return md # Ce n'est pas une fiche ISG
# Générer le tableau de synthèse
synth_table = _synth_isg(md)
# Remplacer la section du tableau final
md_final = re.sub(
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
md,
flags=re.S
)
# Supprimer le bloc YAML des pays (entre # Criticité par pays et le prochain titre ##)
md_final = re.sub(
r"# Criticité par pays\s*\n```yaml[\s\S]*?```\s*",
"# Criticité par pays\n\n",
md_final,
flags=re.S
)
return md_final

22
utils/tickets/__init__.py Normal file
View File

@ -0,0 +1,22 @@
# __init__.py du répertoire tickets
from .core import (
rechercher_tickets_gitea,
charger_fiches_et_labels,
get_labels_existants,
gitea_request,
construire_corps_ticket_markdown,
creer_ticket_gitea,
nettoyer_labels
)
from .display import (
afficher_tickets_par_fiche,
afficher_carte_ticket,
recuperer_commentaires_ticket
)
from .creation import (
formulaire_creation_ticket_dynamique,
charger_modele_ticket
)

118
utils/tickets/core.py Normal file
View File

@ -0,0 +1,118 @@
# core.py
import csv
import json
import requests
import os
import streamlit as st
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES, ENV
def gitea_request(method, url, **kwargs):
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"token {GITEA_TOKEN}"
try:
response = requests.request(method, url, headers=headers, timeout=10, **kwargs)
response.raise_for_status()
return response
except requests.RequestException as e:
st.error(f"Erreur Gitea ({method.upper()}): {e}")
return None
def charger_fiches_et_labels():
chemin_csv = os.path.join("assets", "fiches_labels.csv")
dictionnaire_fiches = {}
try:
with open(chemin_csv, mode="r", encoding="utf-8") as fichier_csv:
lecteur = csv.DictReader(fichier_csv)
for ligne in lecteur:
fiche = ligne.get("Fiche")
operations = ligne.get("Label opération")
item = ligne.get("Label item")
if fiche and operations and item:
dictionnaire_fiches[fiche.strip()] = {
"operations": [op.strip() for op in operations.split("/")],
"item": item.strip()
}
except FileNotFoundError:
st.error(f"❌ Le fichier {chemin_csv} est introuvable.")
except Exception as e:
st.error(f"❌ Erreur lors du chargement des fiches : {str(e)}")
return dictionnaire_fiches
def rechercher_tickets_gitea(fiche_selectionnee):
params = {"state": "open"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
reponse = gitea_request("get", url, params=params)
if not reponse:
return []
try:
issues = reponse.json()
except Exception as e:
st.error(f"Erreur de décodage JSON : {e}")
return []
correspondances = charger_fiches_et_labels()
cible = correspondances.get(fiche_selectionnee)
if not cible:
return []
labels_cibles = set(cible["operations"] + [cible["item"]])
tickets_associes = []
for issue in issues:
if issue.get("ref") != f"refs/heads/{ENV}":
continue
issue_labels = set(label.get("name", "") for label in issue.get("labels", []))
if labels_cibles.issubset(issue_labels):
tickets_associes.append(issue)
return tickets_associes
def get_labels_existants():
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/labels"
reponse = gitea_request("get", url)
if not reponse:
return {}
try:
return {label['name']: label['id'] for label in reponse.json()}
except Exception as e:
st.error(f"Erreur de parsing des labels : {e}")
return {}
def nettoyer_labels(labels):
return sorted(set(l.strip() for l in labels if isinstance(l, str) and l.strip()))
def construire_corps_ticket_markdown(reponses):
return "\n\n".join(f"## {section}\n{texte}" for section, texte in reponses.items())
def creer_ticket_gitea(titre, corps, labels):
data = {
"title": titre,
"body": corps,
"labels": labels,
"ref": f"refs/heads/{ENV}"
}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
reponse = gitea_request("post", url, headers={"Content-Type": "application/json"}, data=json.dumps(data))
if not reponse:
return
issue_url = reponse.json().get("html_url", "")
if issue_url:
st.success(f"Ticket créé ! [Voir le ticket]({issue_url})")
else:
st.success("Ticket créé avec succès.")

100
utils/tickets/creation.py Normal file
View File

@ -0,0 +1,100 @@
# creation.py
import re
import base64
import streamlit as st
from .core import charger_fiches_et_labels, construire_corps_ticket_markdown, creer_ticket_gitea, get_labels_existants, nettoyer_labels
from config import ENV
import requests
def formulaire_creation_ticket_dynamique(fiche_selectionnee):
with st.expander("Créer un nouveau ticket lié à cette fiche", expanded=False):
contenu_modele = charger_modele_ticket()
if not contenu_modele:
st.error("Impossible de charger le modèle de ticket.")
return
# Découpe le modèle en sections
sections, reponses = {}, {}
lignes, titre_courant, contenu = contenu_modele.splitlines(), None, []
for ligne in lignes:
if ligne.startswith("## "):
if titre_courant:
sections[titre_courant] = "\n".join(contenu).strip()
titre_courant, contenu = ligne[3:].strip(), []
elif titre_courant:
contenu.append(ligne)
if titre_courant:
sections[titre_courant] = "\n".join(contenu).strip()
# Labels prédéfinis selon la fiche
labels, selected_ops = [], []
correspondances = charger_fiches_et_labels()
cible = correspondances.get(fiche_selectionnee)
if cible:
if len(cible["operations"]) == 1:
labels.append(cible["operations"][0])
elif len(cible["operations"]) > 1:
selected_ops = st.multiselect("Labels opération à associer", cible["operations"], default=cible["operations"])
# Génération des champs
for section, aide in sections.items():
if "Type de contribution" in section:
options = sorted(set(re.findall(r"- \[.\] (.+)", aide)))
if "Autre" not in options:
options.append("Autre")
choix = st.radio("Type de contribution", options)
reponses[section] = st.text_input("Précisez", "") if choix == "Autre" else choix
elif "Fiche concernée" in section:
url_fiche = f"https://fabnum-git.peccini.fr/FabNum/Fiches/src/branch/{ENV}/Documents/{fiche_selectionnee.replace(' ', '%20')}"
reponses[section] = url_fiche
st.text_input("Fiche concernée", value=url_fiche, disabled=True)
elif "Sujet de la proposition" in section:
reponses[section] = st.text_input(section, help=aide)
else:
reponses[section] = st.text_area(section, help=aide)
col1, col2 = st.columns(2)
if col1.button("Prévisualiser le ticket"):
st.session_state.previsualiser = True
if col2.button("Annuler"):
st.session_state.previsualiser = False
st.rerun()
if st.session_state.get("previsualiser", False):
st.subheader("Prévisualisation du ticket")
for section, texte in reponses.items():
st.markdown(f"#### {section}")
st.code(texte, language="markdown")
titre_ticket = reponses.get("Sujet de la proposition", "").strip() or "Ticket FabNum"
final_labels = nettoyer_labels(labels + selected_ops + ([cible["item"]] if cible else []))
st.markdown(f"**Résumé :**\n- **Titre** : `{titre_ticket}`\n- **Labels** : `{', '.join(final_labels)}`")
if st.button("Confirmer la création du ticket"):
labels_existants = get_labels_existants()
labels_ids = [labels_existants[l] for l in final_labels if l in labels_existants]
if "Backlog" in labels_existants:
labels_ids.append(labels_existants["Backlog"])
corps = construire_corps_ticket_markdown(reponses)
creer_ticket_gitea(titre_ticket, corps, labels_ids)
st.session_state.previsualiser = False
st.success("Ticket créé et formulaire vidé.")
def charger_modele_ticket():
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES
headers = {"Authorization": f"token {GITEA_TOKEN}"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/.gitea/ISSUE_TEMPLATE/Contenu.md"
try:
r = requests.get(url, headers=headers, timeout=10)
r.raise_for_status()
return base64.b64decode(r.json().get("content", "")).decode("utf-8")
except Exception as e:
st.error(f"Erreur chargement modèle : {e}")
return ""

105
utils/tickets/display.py Normal file
View File

@ -0,0 +1,105 @@
# display.py
import streamlit as st
import html
import re
from collections import defaultdict
from dateutil import parser
from .core import rechercher_tickets_gitea
def extraire_statut_par_label(ticket):
labels = [label.get('name', '') for label in ticket.get('labels', [])]
for statut in ["Backlog", "En attente de traitement", "En cours", "Terminés", "Non retenus"]:
if statut in labels:
return statut
return "Autres"
def afficher_tickets_par_fiche(tickets):
if not tickets:
st.info("Aucun ticket lié à cette fiche.")
return
st.markdown("**Tickets associés à cette fiche**")
tickets_groupes = defaultdict(list)
for ticket in tickets:
statut = extraire_statut_par_label(ticket)
tickets_groupes[statut].append(ticket)
nb_backlogs = len(tickets_groupes["Backlog"])
if nb_backlogs:
st.info(f"{nb_backlogs} ticket(s) en attente de modération ne sont pas affichés.")
ordre_statuts = ["En attente de traitement", "En cours", "Terminés", "Non retenus", "Autres"]
for statut in ordre_statuts:
if tickets_groupes[statut]:
with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
for ticket in tickets_groupes[statut]:
afficher_carte_ticket(ticket)
def recuperer_commentaires_ticket(issue_index):
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES
import requests
headers = {"Authorization": f"token {GITEA_TOKEN}"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues/{issue_index}/comments"
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
st.error(f"Erreur lors de la récupération des commentaires : {e}")
return []
def afficher_carte_ticket(ticket):
titre = ticket.get("title", "Sans titre")
url = ticket.get("html_url", "")
user = ticket.get("user", {}).get("login", "inconnu")
created = ticket.get("created_at", "")
updated = ticket.get("updated_at", "")
body = ticket.get("body", "")
labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
sujet = ""
match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
if match:
sujet = match.group(1).strip()
def format_date(iso):
try:
return parser.isoparse(iso).strftime("%d/%m/%Y")
except:
return "?"
date_created_str = format_date(created)
maj_info = f"(MAJ {format_date(updated)})" if updated and updated != created else ""
commentaires = recuperer_commentaires_ticket(ticket.get("number"))
commentaires_html = ""
for commentaire in commentaires:
auteur = html.escape(commentaire.get('user', {}).get('login', 'inconnu'))
contenu = html.escape(commentaire.get('body', ''))
date = format_date(commentaire.get('created_at', ''))
commentaires_html += f"""
<div class=\"conteneur_commentaire\">
<p class=\"commentaire_auteur\"><strong>{auteur}</strong> <small>({date})</small></p>
<p class=\"commentaire_contenu\">{contenu}</p>
</div>
"""
with st.container():
st.markdown(f"""
<div class=\"conteneur_ticket\">
<h4><a href='{url}' target='_blank'>{titre}</a></h4>
<p>Ouvert par <strong>{html.escape(user)}</strong> le {date_created_str} {maj_info}</p>
<p>Sujet : <strong>{html.escape(sujet)}</strong></p>
<p>Labels : {''.join(labels) if labels else 'aucun'}</p>
</div>
""", unsafe_allow_html=True)
st.markdown(body, unsafe_allow_html=False)
st.markdown("---")
st.markdown("**Commentaire(s) :**")
st.markdown(commentaires_html or "Aucun commentaire.", unsafe_allow_html=True)

View File

@ -1,298 +0,0 @@
import streamlit as st
from dateutil import parser
from collections import defaultdict
import os
import csv
import requests
import base64
import re
import json
import html
# Configuration Gitea
GITEA_URL = os.getenv("GITEA_URL", "https://fabnum-git.peccini.fr/api/v1")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
ORGANISATION = os.getenv("ORGANISATION", "fabnum")
DEPOT_FICHES = os.getenv("DEPOT_FICHES", "fiches")
ENV = os.getenv("ENV")
def charger_fiches_et_labels():
chemin_csv = os.path.join("assets", "fiches_labels.csv")
dictionnaire_fiches = {}
try:
with open(chemin_csv, mode="r", encoding="utf-8") as fichier_csv:
lecteur = csv.DictReader(fichier_csv)
for ligne in lecteur:
fiche = ligne.get("Fiche")
operations = ligne.get("Label opération")
item = ligne.get("Label item")
if fiche and operations and item:
dictionnaire_fiches[fiche.strip()] = {
"operations": [op.strip() for op in operations.split("/")],
"item": item.strip()
}
except FileNotFoundError:
st.error(f"❌ Le fichier {chemin_csv} est introuvable.")
except Exception as e:
st.error(f"❌ Erreur lors du chargement des fiches : {str(e)}")
return dictionnaire_fiches
def rechercher_tickets_gitea(fiche_selectionnee):
headers = {"Authorization": f"token {GITEA_TOKEN}"}
params = {"state": "open"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
try:
reponse = requests.get(url, headers=headers, params=params, timeout=10)
reponse.raise_for_status()
issues = reponse.json()
correspondances = charger_fiches_et_labels()
cible = correspondances.get(fiche_selectionnee)
if not cible:
return []
labels_cibles = set(cible["operations"] + [cible["item"]])
tickets_associes = []
for issue in issues:
if issue.get("ref") != f"refs/heads/{ENV}":
continue
issue_labels = set(label.get("name", "") for label in issue.get("labels", []))
if labels_cibles.issubset(issue_labels):
tickets_associes.append(issue)
return tickets_associes
except requests.RequestException as e:
st.error(f"Erreur lors de la récupération des tickets : {e}")
return []
def extraire_statut_par_label(ticket):
labels = [label.get('name', '') for label in ticket.get('labels', [])]
for statut in ["Backlog", "En attente de traitement", "En cours", "Terminés", "Non retenus"]:
if statut in labels:
return statut
return "Autres"
def afficher_tickets_par_fiche(tickets):
if not tickets:
st.info("Aucun ticket lié à cette fiche.")
return
st.markdown("**Tickets associés à cette fiche**")
tickets_groupes = defaultdict(list)
for ticket in tickets:
statut = extraire_statut_par_label(ticket)
tickets_groupes[statut].append(ticket)
nb_backlogs = len(tickets_groupes["Backlog"])
if nb_backlogs:
st.info(f"{nb_backlogs} ticket(s) en attente de modération ne sont pas affichés.")
ordre_statuts = ["En attente de traitement", "En cours", "Terminés", "Non retenus", "Autres"]
for statut in ordre_statuts:
if tickets_groupes[statut]:
with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
for ticket in tickets_groupes[statut]:
afficher_carte_ticket(ticket)
def recuperer_commentaires_ticket(issue_index):
headers = {"Authorization": f"token {GITEA_TOKEN}"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues/{issue_index}/comments"
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
st.error(f"Erreur lors de la récupération des commentaires : {e}")
return []
def get_labels_existants():
headers = {"Authorization": f"token {GITEA_TOKEN}"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/labels"
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return {label['name']: label['id'] for label in response.json()}
except Exception as e:
st.error(f"Erreur lors de la récupération des labels : {e}")
return {}
def nettoyer_labels(labels):
return sorted(set(l.strip() for l in labels if isinstance(l, str) and l.strip()))
def construire_corps_ticket_markdown(reponses):
return "\n\n".join(f"## {section}\n{texte}" for section, texte in reponses.items())
def creer_ticket_gitea(titre, corps, labels):
headers = {
"Authorization": f"token {GITEA_TOKEN}",
"Content-Type": "application/json"
}
data = {
"title": titre,
"body": corps,
"labels": labels,
"ref": f"refs/heads/{ENV}"
}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
try:
response = requests.post(url, headers=headers, data=json.dumps(data), timeout=10)
response.raise_for_status()
issue_url = response.json().get("html_url", "")
if issue_url:
st.success(f"Ticket créé ! [Voir le ticket]({issue_url})")
else:
st.success("Ticket créé avec succès.")
except Exception as e:
st.error(f"❌ Erreur création ticket : {e}")
def afficher_carte_ticket(ticket):
titre = ticket.get("title", "Sans titre")
url = ticket.get("html_url", "")
user = ticket.get("user", {}).get("login", "inconnu")
created = ticket.get("created_at", "")
updated = ticket.get("updated_at", "")
body = ticket.get("body", "")
labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
sujet = ""
match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
if match:
sujet = match.group(1).strip()
def format_date(iso):
try:
return parser.isoparse(iso).strftime("%d/%m/%Y")
except:
return "?"
date_created_str = format_date(created)
maj_info = f"(MAJ {format_date(updated)})" if updated and updated != created else ""
commentaires = recuperer_commentaires_ticket(ticket.get("number"))
commentaires_html = ""
for commentaire in commentaires:
auteur = html.escape(commentaire.get('user', {}).get('login', 'inconnu'))
contenu = html.escape(commentaire.get('body', ''))
date = format_date(commentaire.get('created_at', ''))
commentaires_html += f"""
<div class="conteneur_commentaire">
<p class="commentaire_auteur"><strong>{auteur}</strong> <small>({date})</small></p>
<p class="commentaire_contenu">{contenu}</p>
</div>
"""
with st.container():
st.markdown(f"""
<div class="conteneur_ticket">
<h4><a href='{url}' target='_blank'>{titre}</a></h4>
<p>Ouvert par <strong>{html.escape(user)}</strong> le {date_created_str} {maj_info}</p>
<p>Sujet : <strong>{html.escape(sujet)}</strong></p>
<p>Labels : {''.join(labels) if labels else 'aucun'}</p>
</div>
""", unsafe_allow_html=True)
st.markdown(body, unsafe_allow_html=False)
st.markdown("---")
st.markdown("**Commentaire(s) :**")
st.markdown(commentaires_html or "Aucun commentaire.", unsafe_allow_html=True)
def charger_modele_ticket():
headers = {"Authorization": f"token {GITEA_TOKEN}"}
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/.gitea/ISSUE_TEMPLATE/Contenu.md"
try:
r = requests.get(url, headers=headers, timeout=10)
r.raise_for_status()
return base64.b64decode(r.json().get("content", "")).decode("utf-8")
except Exception as e:
st.error(f"Erreur chargement modèle : {e}")
return ""
def gerer_tickets_fiche(fiche_selectionnee):
st.markdown("<hr style='border: 1px solid #ccc; margin: 2rem 0;' />", unsafe_allow_html=True)
st.markdown("## Gestion des tickets pour cette fiche")
afficher_tickets_par_fiche(rechercher_tickets_gitea(fiche_selectionnee))
formulaire_creation_ticket_dynamique(fiche_selectionnee)
def formulaire_creation_ticket_dynamique(fiche_selectionnee):
with st.expander("Créer un nouveau ticket lié à cette fiche", expanded=False):
contenu_modele = charger_modele_ticket()
if not contenu_modele:
st.error("Impossible de charger le modèle de ticket.")
return
# Découpe le modèle en sections
sections, reponses = {}, {}
lignes, titre_courant, contenu = contenu_modele.splitlines(), None, []
for ligne in lignes:
if ligne.startswith("## "):
if titre_courant:
sections[titre_courant] = "\n".join(contenu).strip()
titre_courant, contenu = ligne[3:].strip(), []
elif titre_courant:
contenu.append(ligne)
if titre_courant:
sections[titre_courant] = "\n".join(contenu).strip()
# Labels prédéfinis selon la fiche
labels, selected_ops = [], []
correspondances = charger_fiches_et_labels()
cible = correspondances.get(fiche_selectionnee)
if cible:
if len(cible["operations"]) == 1:
labels.append(cible["operations"][0])
elif len(cible["operations"]) > 1:
selected_ops = st.multiselect("Labels opération à associer", cible["operations"], default=cible["operations"])
# Génération des champs
for section, aide in sections.items():
if "Type de contribution" in section:
options = sorted(set(re.findall(r"- \[.\] (.+)", aide)))
if "Autre" not in options:
options.append("Autre")
choix = st.radio("Type de contribution", options)
reponses[section] = st.text_input("Précisez", "") if choix == "Autre" else choix
elif "Fiche concernée" in section:
url_fiche = f"https://fabnum-git.peccini.fr/FabNum/Fiches/src/branch/{ENV}/Documents/{fiche_selectionnee.replace(' ', '%20')}"
reponses[section] = url_fiche
st.text_input("Fiche concernée", value=url_fiche, disabled=True)
elif "Sujet de la proposition" in section:
reponses[section] = st.text_input(section, help=aide)
else:
reponses[section] = st.text_area(section, help=aide)
col1, col2 = st.columns(2)
if col1.button("Prévisualiser le ticket"):
st.session_state.previsualiser = True
if col2.button("Annuler"):
st.session_state.previsualiser = False
st.rerun()
if st.session_state.get("previsualiser", False):
st.subheader("Prévisualisation du ticket")
for section, texte in reponses.items():
st.markdown(f"#### {section}")
st.code(texte, language="markdown")
titre_ticket = reponses.get("Sujet de la proposition", "").strip() or "Ticket FabNum"
final_labels = nettoyer_labels(labels + selected_ops + ([cible["item"]] if cible else []))
st.markdown(f"**Résumé :**\n- **Titre** : `{titre_ticket}`\n- **Labels** : `{', '.join(final_labels)}`")
if st.button("Confirmer la création du ticket"):
labels_existants = get_labels_existants()
labels_ids = [labels_existants[l] for l in final_labels if l in labels_existants]
if "Backlog" in labels_existants:
labels_ids.append(labels_existants["Backlog"])
corps = construire_corps_ticket_markdown(reponses)
creer_ticket_gitea(titre_ticket, corps, labels_ids)
st.session_state.previsualiser = False
st.success("Ticket créé et formulaire vidé.")