remporte 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- remporte/__init__.py +8 -0
- remporte/__main__.py +10 -0
- remporte/cadre.py +238 -0
- remporte/candidature.py +302 -0
- remporte/cerfa/DC1-2019.docx +0 -0
- remporte/cerfa/DC2-2023.docx +0 -0
- remporte/cerfa/DC4-2023.docx +0 -0
- remporte/cerfa/cartes.json +99 -0
- remporte/charte.py +79 -0
- remporte/cli.py +807 -0
- remporte/espace.py +281 -0
- remporte/export.py +631 -0
- remporte/formats.py +154 -0
- remporte/gabarits/02-analyse.md +49 -0
- remporte/gabarits/03-go-no-go.md +20 -0
- remporte/gabarits/04-plan.md +21 -0
- remporte/gabarits/05-relecture.md +25 -0
- remporte/gabarits/AGENTS-espace.md +18 -0
- remporte/gabarits/AGENTS.md +27 -0
- remporte/gabarits/CLAUDE.md +1 -0
- remporte/gabarits/fiche-entreprise.md +51 -0
- remporte/guides/.gitkeep +0 -0
- remporte/guides/analyse.md +53 -0
- remporte/guides/candidature.md +20 -0
- remporte/guides/excel.md +29 -0
- remporte/guides/export.md +20 -0
- remporte/guides/formats.md +18 -0
- remporte/guides/go-no-go.md +22 -0
- remporte/guides/modeles.md +38 -0
- remporte/guides/offre.md +19 -0
- remporte/guides/pieces.md +14 -0
- remporte/guides/plan.md +42 -0
- remporte/guides/redaction.md +46 -0
- remporte/guides/relecture.md +28 -0
- remporte/guides/soutenance.md +25 -0
- remporte/inventaire.py +153 -0
- remporte/lecture.py +558 -0
- remporte/lien.py +15 -0
- remporte/recherche.py +190 -0
- remporte/travail.py +57 -0
- remporte-0.4.2.dist-info/METADATA +242 -0
- remporte-0.4.2.dist-info/RECORD +45 -0
- remporte-0.4.2.dist-info/WHEEL +4 -0
- remporte-0.4.2.dist-info/entry_points.txt +2 -0
- remporte-0.4.2.dist-info/licenses/LICENSE +21 -0
remporte/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""CLI remporte — répondre à un appel d'offres publics avec votre agent IA."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("remporte")
|
|
7
|
+
except PackageNotFoundError: # exécuté depuis les sources sans installation
|
|
8
|
+
__version__ = "0.0.0"
|
remporte/__main__.py
ADDED
remporte/cadre.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Extraction déterministe d'un cadre de réponse depuis la pièce de l'acheteur.
|
|
2
|
+
|
|
3
|
+
Port de `remporte-pi/apps/pipe/src/remporte_ai/ingestion/response_frame.py`
|
|
4
|
+
(lu sur origin/main). Deux voies : les paragraphes d'un style dédié du DOCX
|
|
5
|
+
(trame bien faite), puis les phrases « le candidat … » dans le texte converti.
|
|
6
|
+
En dessous de trois points, le cadre est jugé inexploitable : origine « aucune ».
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter
|
|
13
|
+
import unicodedata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from remporte.lecture import convertir
|
|
17
|
+
|
|
18
|
+
_DEMANDE_CANDIDAT = re.compile(
|
|
19
|
+
r"\b(?:le candidat|le soumissionnaire|l[' ]entreprise)\s+"
|
|
20
|
+
r"(?:precise|decrit|detaille|fournit|indique|justifie"
|
|
21
|
+
r"|precisera|decrira|detaillera|fournira|indiquera|justifiera)\b"
|
|
22
|
+
)
|
|
23
|
+
_STYLE_TITRE = re.compile(r"^(?:heading|titre|title)(?:\s+\d+)?(?:\s|$)")
|
|
24
|
+
|
|
25
|
+
_SEUIL_POINTS = 3
|
|
26
|
+
_LIMITE_TITRE = 140
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normaliser(texte: str) -> str:
|
|
30
|
+
"""Normalise un texte pour les comparaisons et empreintes stables."""
|
|
31
|
+
sans_accents = "".join(
|
|
32
|
+
caractere
|
|
33
|
+
for caractere in unicodedata.normalize("NFKD", texte)
|
|
34
|
+
if not unicodedata.combining(caractere)
|
|
35
|
+
)
|
|
36
|
+
return " ".join(sans_accents.lower().replace("’", "'").split())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _est_demande_candidat(texte: str) -> bool:
|
|
40
|
+
return bool(_DEMANDE_CANDIDAT.search(_normaliser(texte)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _est_titre(nom_style: str) -> bool:
|
|
44
|
+
return bool(_STYLE_TITRE.match(_normaliser(nom_style)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _est_style_dedie(style) -> bool:
|
|
48
|
+
"""Écarte les styles Word intégrés, notamment le style Normal."""
|
|
49
|
+
return not style.builtin
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _finalise(origine: str, points_bruts: list[tuple[str, str]]) -> dict:
|
|
53
|
+
"""Applique le seuil minimal d'un cadre exploitable."""
|
|
54
|
+
if len(points_bruts) < _SEUIL_POINTS:
|
|
55
|
+
return {"origine": "aucune", "points": []}
|
|
56
|
+
points = [
|
|
57
|
+
{
|
|
58
|
+
"numero": index,
|
|
59
|
+
"titre": _tronquer(texte),
|
|
60
|
+
"demande": texte,
|
|
61
|
+
"niveau": chapitre,
|
|
62
|
+
}
|
|
63
|
+
for index, (texte, chapitre) in enumerate(points_bruts, 1)
|
|
64
|
+
]
|
|
65
|
+
return {"origine": origine, "points": points}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tronquer(texte: str, limite: int = _LIMITE_TITRE) -> str:
|
|
69
|
+
return texte if len(texte) <= limite else texte[: limite - 1].rstrip() + "…"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _points_par_style(chemin: Path) -> list[tuple[str, str]]:
|
|
73
|
+
"""Extrait les paragraphes du style dédié le plus représentatif."""
|
|
74
|
+
from docx import Document
|
|
75
|
+
|
|
76
|
+
document = Document(str(chemin))
|
|
77
|
+
paragraphes = list(document.paragraphs)
|
|
78
|
+
candidats: list[tuple[int, int, str]] = []
|
|
79
|
+
|
|
80
|
+
for style in document.styles:
|
|
81
|
+
try:
|
|
82
|
+
if not _est_style_dedie(style):
|
|
83
|
+
continue
|
|
84
|
+
except Exception: # noqa: BLE001 — style exotique : ignoré
|
|
85
|
+
continue
|
|
86
|
+
indices = [
|
|
87
|
+
index
|
|
88
|
+
for index, paragraphe in enumerate(paragraphes)
|
|
89
|
+
if (
|
|
90
|
+
paragraphe.text.strip()
|
|
91
|
+
and paragraphe.style
|
|
92
|
+
and paragraphe.style.name == style.name
|
|
93
|
+
and _est_demande_candidat(paragraphe.text)
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
if indices:
|
|
97
|
+
candidats.append((len(indices), indices[0], style.name))
|
|
98
|
+
|
|
99
|
+
if not candidats:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
_, _, nom_style = min(
|
|
103
|
+
candidats,
|
|
104
|
+
key=lambda candidat: (-candidat[0], candidat[1], candidat[2]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
chapitre = ""
|
|
108
|
+
points: list[tuple[str, str]] = []
|
|
109
|
+
for paragraphe in paragraphes:
|
|
110
|
+
texte = " ".join(paragraphe.text.split())
|
|
111
|
+
if not texte:
|
|
112
|
+
continue
|
|
113
|
+
nom = paragraphe.style.name if paragraphe.style else ""
|
|
114
|
+
if _est_titre(nom):
|
|
115
|
+
chapitre = texte
|
|
116
|
+
continue
|
|
117
|
+
if nom == nom_style:
|
|
118
|
+
points.append((texte, chapitre))
|
|
119
|
+
return points
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _points_par_motif(texte: str) -> list[tuple[str, str]]:
|
|
123
|
+
"""Extrait les demandes explicites depuis un texte déjà converti."""
|
|
124
|
+
chapitre = ""
|
|
125
|
+
points: list[tuple[str, str]] = []
|
|
126
|
+
for ligne in texte.splitlines():
|
|
127
|
+
contenu = " ".join(ligne.split())
|
|
128
|
+
titre = re.match(r"^#{1,6}\s+(.+?)\s*$", contenu)
|
|
129
|
+
if titre:
|
|
130
|
+
chapitre = titre.group(1)
|
|
131
|
+
continue
|
|
132
|
+
if contenu and _est_demande_candidat(contenu):
|
|
133
|
+
points.append((contenu, chapitre))
|
|
134
|
+
return points
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# « {PP 001.}\t2.3.1 : Qualité des équipes : Le candidat précise …\t8 » :
|
|
138
|
+
# liste d'exigences numérotées, fréquente en fin de CRT (la dernière colonne
|
|
139
|
+
# est la page). Les styles n'en voient qu'une partie ; quand elle existe,
|
|
140
|
+
# c'est elle qui fait foi, car l'acheteur note point par point.
|
|
141
|
+
_LIGNE_EXIGENCE = re.compile(
|
|
142
|
+
r"^\{?\s*([A-Z]{1,6}[ _-]?\d{1,4})\s*\.?\s*\}?\s+(.+?)(?:\s+\d{1,4})?\s*$",
|
|
143
|
+
re.MULTILINE,
|
|
144
|
+
)
|
|
145
|
+
_SEUIL_LISTE = 5
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _points_par_liste(texte: str) -> list[tuple[str, str]]:
|
|
149
|
+
"""Exigences numérotées (identifiant + chapitre + demande), sans doublon.
|
|
150
|
+
|
|
151
|
+
On garde le préfixe d'identifiant le plus fréquent (« PP ») : c'est lui qui
|
|
152
|
+
signe la liste de l'acheteur, quelle que soit la tournure de la demande.
|
|
153
|
+
"""
|
|
154
|
+
lignes: list[tuple[str, str, str]] = []
|
|
155
|
+
for trouve in _LIGNE_EXIGENCE.finditer(texte):
|
|
156
|
+
identifiant = " ".join(trouve.group(1).replace("_", " ").replace("-", " ").split())
|
|
157
|
+
prefixe = re.match(r"[A-Z]+", identifiant).group(0)
|
|
158
|
+
lignes.append((prefixe, identifiant, trouve.group(2).replace("\xa0", " ").strip()))
|
|
159
|
+
if not lignes:
|
|
160
|
+
return []
|
|
161
|
+
prefixes = Counter(prefixe for prefixe, _, _ in lignes)
|
|
162
|
+
dominant, nombre = prefixes.most_common(1)[0]
|
|
163
|
+
if nombre < _SEUIL_LISTE:
|
|
164
|
+
return []
|
|
165
|
+
points: list[tuple[str, str]] = []
|
|
166
|
+
vus: set[str] = set()
|
|
167
|
+
for prefixe, identifiant, corps in lignes:
|
|
168
|
+
if prefixe != dominant or identifiant in vus:
|
|
169
|
+
continue
|
|
170
|
+
vus.add(identifiant)
|
|
171
|
+
chapitre, _, demande = corps.rpartition(" : ")
|
|
172
|
+
if not chapitre:
|
|
173
|
+
demande = corps
|
|
174
|
+
points.append((f"{identifiant} — {' '.join(demande.split())}", " ".join(chapitre.split())))
|
|
175
|
+
return points
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def identifiant_du_point(point: dict) -> str | None:
|
|
179
|
+
"""« PP 009 » d'un point issu d'une liste numérotée, sinon None."""
|
|
180
|
+
trouve = re.match(r"([A-Z]+) ?(\d+) — ", point["titre"])
|
|
181
|
+
return f"{trouve.group(1)} {trouve.group(2)}" if trouve else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def couverture(dossier: Path, resultat: dict) -> list[dict] | None:
|
|
185
|
+
"""Pour chaque exigence numérotée du CRT : les sections qui la citent.
|
|
186
|
+
|
|
187
|
+
Une section « cite » l'exigence quand son texte contient l'identifiant
|
|
188
|
+
(« PP 009 », tolérant sur séparateurs et zéros de tête). Rend une entrée
|
|
189
|
+
{point, identifiant, sections (noms de fichiers), couverte} par point, ou
|
|
190
|
+
None si le cadre n'est pas une liste numérotée (origine != « liste ») : le
|
|
191
|
+
contrôle point par point n'a alors pas de sens.
|
|
192
|
+
"""
|
|
193
|
+
if resultat.get("origine") != "liste":
|
|
194
|
+
return None
|
|
195
|
+
sections = {
|
|
196
|
+
fichier.name: fichier.read_text(encoding="utf-8", errors="replace")
|
|
197
|
+
for fichier in sorted((Path(dossier) / "sections").glob("*.md"), key=lambda p: p.as_posix())
|
|
198
|
+
}
|
|
199
|
+
entrees: list[dict] = []
|
|
200
|
+
for point in resultat["points"]:
|
|
201
|
+
identifiant = identifiant_du_point(point)
|
|
202
|
+
citantes: list[str] = []
|
|
203
|
+
if identifiant:
|
|
204
|
+
prefixe, numero = identifiant.split()
|
|
205
|
+
motif = rf"(?<![A-Za-z]){prefixe}[ _-]?0*{int(numero)}(?!\d)"
|
|
206
|
+
citantes = [
|
|
207
|
+
nom for nom, texte in sections.items() if re.search(motif, texte)
|
|
208
|
+
]
|
|
209
|
+
entrees.append({
|
|
210
|
+
"point": point,
|
|
211
|
+
"identifiant": identifiant,
|
|
212
|
+
"sections": citantes,
|
|
213
|
+
"couverte": bool(citantes),
|
|
214
|
+
})
|
|
215
|
+
return entrees
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def extraire_cadre(piece: Path) -> dict:
|
|
219
|
+
"""Trame imposée par l'acheteur (CRT Word).
|
|
220
|
+
|
|
221
|
+
Rend {"origine": "liste"|"styles"|"motifs"|"aucune", "points": [{"numero", "titre",
|
|
222
|
+
"demande", "niveau"}]} où `titre` est la demande (tronquée pour l'affichage),
|
|
223
|
+
`demande` le texte intégral du paragraphe et `niveau` le chapitre (titre de
|
|
224
|
+
section) sous lequel la demande figure.
|
|
225
|
+
"""
|
|
226
|
+
piece = Path(piece)
|
|
227
|
+
conversion = convertir(piece)
|
|
228
|
+
liste = _points_par_liste(conversion.texte) if conversion.statut == "ok" else []
|
|
229
|
+
if liste:
|
|
230
|
+
return _finalise("liste", liste)
|
|
231
|
+
if piece.suffix.lower() == ".docx":
|
|
232
|
+
points = _points_par_style(piece)
|
|
233
|
+
if points:
|
|
234
|
+
return _finalise("styles", points)
|
|
235
|
+
return _finalise("motifs", _points_par_motif(conversion.texte))
|
|
236
|
+
if conversion.statut != "ok":
|
|
237
|
+
return {"origine": "aucune", "points": []}
|
|
238
|
+
return _finalise("motifs", _points_par_motif(conversion.texte))
|
remporte/candidature.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Formulaires de candidature DC1/DC2/DC4 : préparation des valeurs et remplissage.
|
|
2
|
+
|
|
3
|
+
`preparer` crée `candidature/valeurs.json` (une entrée par formulaire, chaque
|
|
4
|
+
champ à null) et ne remplace jamais un fichier existant. `remplir` lit ces
|
|
5
|
+
valeurs et écrit `candidature/DC1.docx`, `DC2.docx` et `DC4.docx` (ce dernier
|
|
6
|
+
seulement si une sous-traitance est déclarée), en écrivant dans le XML des
|
|
7
|
+
formulaires officiels vierges de `cerfa/` :
|
|
8
|
+
|
|
9
|
+
- texte : la valeur est ajoutée à la fin du paragraphe repéré par l'ancre de
|
|
10
|
+
la carte (les libellés Cerfa sont coupés entre plusieurs runs : on ne
|
|
11
|
+
remplace jamais dans un run, on concatène le paragraphe) ;
|
|
12
|
+
- case à cocher : cochée par index 1-based dans l'ordre du document. DC1 et
|
|
13
|
+
DC2 portent des champs de formulaire `<w:checkBox>` ; les cases du DC4 sont
|
|
14
|
+
des formes dessinées, cochées en les noircissant (choix DrawingML et repli
|
|
15
|
+
VML modifiés ensemble).
|
|
16
|
+
|
|
17
|
+
Un champ sans valeur reste vide et est listé ; le fichier produit est un zip
|
|
18
|
+
reconstruit à l'identique, contrôlé comme XML bien formé.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib.resources
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import zipfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from xml.etree import ElementTree
|
|
30
|
+
from xml.sax.saxutils import escape
|
|
31
|
+
|
|
32
|
+
from remporte import espace
|
|
33
|
+
|
|
34
|
+
_RE_PARAGRAPHE = re.compile(r"<w:p[ >].*?</w:p>", re.S)
|
|
35
|
+
_RE_TEXTE = re.compile(r"(?P<ouvrant><w:t[^>]*>)(?P<texte>[^<]*)(?P<fermant></w:t>)")
|
|
36
|
+
_RE_TEXTE_PLAIN = re.compile(r"<w:t[^>]*>([^<]*)</w:t>")
|
|
37
|
+
_RE_CASE = re.compile(r"<w:checkBox>.*?</w:checkBox>", re.S)
|
|
38
|
+
_RE_BLOC_DESSINE = re.compile(r"<mc:AlternateContent>.*?</mc:AlternateContent>", re.S)
|
|
39
|
+
|
|
40
|
+
# Les cases du DC4 sont les seules formes dessinées de cette géométrie
|
|
41
|
+
# (carré de 11,7 pt, `filled="f"`) ; les autres blocs dessinés (traits,
|
|
42
|
+
# encadrés) en diffèrent.
|
|
43
|
+
_GEOMETRIE_CASE_DC4 = 'coordsize="147955,147955"'
|
|
44
|
+
_SEPARATEUR = " " # double espace entre le libellé et la valeur ajoutée
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cartes() -> dict:
|
|
48
|
+
"""Contenu de `cerfa/cartes.json` : formulaires, champs, ancres et cases."""
|
|
49
|
+
ressource = importlib.resources.files("remporte") / "cerfa" / "cartes.json"
|
|
50
|
+
return json.loads(ressource.read_text(encoding="utf-8"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def preparer(dossier: Path) -> Path:
|
|
54
|
+
"""Crée `candidature/valeurs.json` avec chaque champ à null. Jamais de
|
|
55
|
+
remplacement d'un fichier existant."""
|
|
56
|
+
dossier = Path(dossier)
|
|
57
|
+
if not (dossier / ".remporte").is_dir():
|
|
58
|
+
raise espace.Erreur(
|
|
59
|
+
f"{dossier} n'est pas un dossier de réponse (pas de .remporte/). "
|
|
60
|
+
"Lancez `remporte init <DCE>` d'abord."
|
|
61
|
+
)
|
|
62
|
+
cible = dossier / "candidature" / "valeurs.json"
|
|
63
|
+
if cible.exists():
|
|
64
|
+
raise espace.Erreur(
|
|
65
|
+
f"{cible} existe déjà. Complétez-le tel quel ; il n'est jamais "
|
|
66
|
+
"remplacé par l'outil."
|
|
67
|
+
)
|
|
68
|
+
valeurs = {
|
|
69
|
+
formulaire: {
|
|
70
|
+
"fichier": info["fichier"],
|
|
71
|
+
"label": info["label"],
|
|
72
|
+
"champs": {
|
|
73
|
+
champ["var"]: {
|
|
74
|
+
"rubrique": champ["rubrique"],
|
|
75
|
+
"label": champ["label"],
|
|
76
|
+
"type": champ["type"],
|
|
77
|
+
"options": champ.get("options"),
|
|
78
|
+
"valeur": None,
|
|
79
|
+
}
|
|
80
|
+
for champ in info["champs"]
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
for formulaire, info in cartes()["formulaires"].items()
|
|
84
|
+
}
|
|
85
|
+
cible.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
cible.write_text(
|
|
87
|
+
json.dumps(valeurs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
88
|
+
)
|
|
89
|
+
return cible
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def remplir(dossier: Path) -> dict:
|
|
93
|
+
"""Écrit les formulaires renseignés dans `candidature/`. Rend les chemins
|
|
94
|
+
produits, les champs non remplis, les optionnels vides, les champs à porter
|
|
95
|
+
à la main et les anomalies (ancre ou case introuvable)."""
|
|
96
|
+
dossier = Path(dossier)
|
|
97
|
+
chemin_valeurs = dossier / "candidature" / "valeurs.json"
|
|
98
|
+
if not chemin_valeurs.exists():
|
|
99
|
+
raise espace.Erreur(
|
|
100
|
+
f"{chemin_valeurs} est absent : lancez `remporte candidature "
|
|
101
|
+
"preparer` d'abord, complétez valeurs.json, puis relancez."
|
|
102
|
+
)
|
|
103
|
+
valeurs = json.loads(chemin_valeurs.read_text(encoding="utf-8"))
|
|
104
|
+
resultat: dict = {
|
|
105
|
+
"produits": [],
|
|
106
|
+
"non_produits": [],
|
|
107
|
+
"non_remplis": {},
|
|
108
|
+
"optionnels_vides": {},
|
|
109
|
+
"manuels": {},
|
|
110
|
+
"anomalies": {},
|
|
111
|
+
}
|
|
112
|
+
for formulaire, info in cartes()["formulaires"].items():
|
|
113
|
+
renseigne = valeurs.get(formulaire, {}).get("champs", {})
|
|
114
|
+
if formulaire == "DC4" and renseigne.get("sous_traitance", {}).get(
|
|
115
|
+
"valeur"
|
|
116
|
+
) is not True:
|
|
117
|
+
resultat["non_produits"].append({
|
|
118
|
+
"formulaire": formulaire,
|
|
119
|
+
"motif": "pas de sous-traitance déclarée "
|
|
120
|
+
"(champ sous_traitance de valeurs.json)",
|
|
121
|
+
})
|
|
122
|
+
continue
|
|
123
|
+
_remplir_formulaire(dossier, formulaire, info, renseigne, resultat)
|
|
124
|
+
return resultat
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Remplissage d'un formulaire
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _remplir_formulaire(dossier: Path, formulaire: str, info: dict,
|
|
132
|
+
renseigne: dict, resultat: dict) -> None:
|
|
133
|
+
non_remplis: list[str] = []
|
|
134
|
+
optionnels_vides: list[str] = []
|
|
135
|
+
manuels: list[str] = []
|
|
136
|
+
anomalies: dict[str, str] = {}
|
|
137
|
+
xml = _xml_vierge(info["fichier"])
|
|
138
|
+
for champ in info["champs"]:
|
|
139
|
+
var = champ["var"]
|
|
140
|
+
valeur = renseigne.get(var, {}).get("valeur")
|
|
141
|
+
if champ["type"] == "manuelle":
|
|
142
|
+
manuels.append(var)
|
|
143
|
+
continue
|
|
144
|
+
if valeur is None or valeur == "":
|
|
145
|
+
(optionnels_vides if champ.get("optionnel") else non_remplis).append(var)
|
|
146
|
+
continue
|
|
147
|
+
erreur = None
|
|
148
|
+
if champ["type"] == "texte":
|
|
149
|
+
xml, erreur = _ecrire_texte(xml, champ, str(valeur))
|
|
150
|
+
elif champ["type"] == "pointilles":
|
|
151
|
+
xml, erreur = _ecrire_pointilles(xml, champ, str(valeur))
|
|
152
|
+
elif champ["type"] == "choix":
|
|
153
|
+
options = champ.get("options") or {}
|
|
154
|
+
if valeur not in options:
|
|
155
|
+
erreur = f"valeur « {valeur} » hors choix : {', '.join(options)}"
|
|
156
|
+
else:
|
|
157
|
+
xml, erreur = (_noircir_case_dessinee(xml, options[valeur])
|
|
158
|
+
if formulaire == "DC4"
|
|
159
|
+
else _cocher_case(xml, options[valeur]))
|
|
160
|
+
elif champ["type"] == "drapeau":
|
|
161
|
+
continue # déjà traité (déclencheur de production du DC4)
|
|
162
|
+
if erreur:
|
|
163
|
+
anomalies[var] = erreur
|
|
164
|
+
if anomalies:
|
|
165
|
+
resultat["anomalies"][formulaire] = anomalies
|
|
166
|
+
if non_remplis:
|
|
167
|
+
resultat["non_remplis"][formulaire] = non_remplis
|
|
168
|
+
if optionnels_vides:
|
|
169
|
+
resultat["optionnels_vides"][formulaire] = optionnels_vides
|
|
170
|
+
if manuels:
|
|
171
|
+
resultat["manuels"][formulaire] = manuels
|
|
172
|
+
|
|
173
|
+
_controler_xml(xml, formulaire, info["fichier"])
|
|
174
|
+
cible = dossier / "candidature" / f"{formulaire}.docx"
|
|
175
|
+
_ecrire_docx(info["fichier"], xml, cible)
|
|
176
|
+
resultat["produits"].append({
|
|
177
|
+
"formulaire": formulaire,
|
|
178
|
+
"chemin": cible.relative_to(dossier).as_posix(),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _xml_vierge(nom_fichier: str) -> str:
|
|
183
|
+
ressource = importlib.resources.files("remporte") / "cerfa" / nom_fichier
|
|
184
|
+
with zipfile.ZipFile(io.BytesIO(ressource.read_bytes())) as archive:
|
|
185
|
+
return archive.read("word/document.xml").decode("utf-8")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _controler_xml(xml: str, formulaire: str, nom_fichier: str) -> None:
|
|
189
|
+
try:
|
|
190
|
+
ElementTree.fromstring(xml)
|
|
191
|
+
except ElementTree.ParseError as erreur:
|
|
192
|
+
raise espace.Erreur(
|
|
193
|
+
f"{formulaire} ({nom_fichier}) : XML invalide après remplissage "
|
|
194
|
+
f"({erreur}). Aucun fichier n'a été écrit."
|
|
195
|
+
) from erreur
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ecrire_docx(nom_fichier: str, xml: str, cible: Path) -> None:
|
|
199
|
+
"""Reconstruit le docx du formulaire avec le document.xml modifié."""
|
|
200
|
+
ressource = importlib.resources.files("remporte") / "cerfa" / nom_fichier
|
|
201
|
+
with zipfile.ZipFile(io.BytesIO(ressource.read_bytes())) as archive:
|
|
202
|
+
contenu = {nom: archive.read(nom) for nom in archive.namelist()}
|
|
203
|
+
contenu["word/document.xml"] = xml.encode("utf-8")
|
|
204
|
+
cible.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
with zipfile.ZipFile(cible, "w", zipfile.ZIP_DEFLATED) as sortie:
|
|
206
|
+
for nom, donnees in contenu.items():
|
|
207
|
+
sortie.writestr(nom, donnees)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Écritures dans le XML
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def _paragraphe_cible(xml: str, ancre: str, occurrence: int
|
|
215
|
+
) -> re.Match | None:
|
|
216
|
+
"""N-ième paragraphe dont le texte concaténé contient l'ancre."""
|
|
217
|
+
vues = 0
|
|
218
|
+
for trouve in _RE_PARAGRAPHE.finditer(xml):
|
|
219
|
+
texte = "".join(_RE_TEXTE_PLAIN.findall(trouve.group(0)))
|
|
220
|
+
if ancre in texte:
|
|
221
|
+
vues += 1
|
|
222
|
+
if vues == occurrence:
|
|
223
|
+
return trouve
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ecrire_texte(xml: str, champ: dict, valeur: str) -> tuple[str, str | None]:
|
|
228
|
+
"""Ajoute la valeur à la fin du paragraphe repéré par l'ancre (dans le
|
|
229
|
+
dernier run texte, jamais au milieu d'un libellé coupé entre runs)."""
|
|
230
|
+
ancre = champ["ancre"]
|
|
231
|
+
occurrence = champ.get("occurrence", 1)
|
|
232
|
+
trouve = _paragraphe_cible(xml, ancre, occurrence)
|
|
233
|
+
if trouve is None:
|
|
234
|
+
return xml, f"ancre introuvable : « {ancre} » (occurrence {occurrence})"
|
|
235
|
+
paragraphe = trouve.group(0)
|
|
236
|
+
runs = list(_RE_TEXTE.finditer(paragraphe))
|
|
237
|
+
if not runs:
|
|
238
|
+
return xml, f"ancre « {ancre} » : paragraphe sans texte modifiable"
|
|
239
|
+
dernier = runs[-1]
|
|
240
|
+
ouvrant = dernier.group("ouvrant")
|
|
241
|
+
if "xml:space" not in ouvrant:
|
|
242
|
+
ouvrant = ouvrant[:-1] + ' xml:space="preserve">'
|
|
243
|
+
remplace = (ouvrant + dernier.group("texte")
|
|
244
|
+
+ escape(_SEPARATEUR + valeur) + dernier.group("fermant"))
|
|
245
|
+
nouveau = paragraphe[:dernier.start()] + remplace + paragraphe[dernier.end():]
|
|
246
|
+
return xml[:trouve.start()] + nouveau + xml[trouve.end():], None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _ecrire_pointilles(xml: str, champ: dict, valeur: str) -> tuple[str, str | None]:
|
|
250
|
+
"""Écrit la valeur dans les pointillés qui suivent l'ancre (DC1 : lot n°)."""
|
|
251
|
+
ancre = champ["ancre"]
|
|
252
|
+
occurrence = champ.get("occurrence", 1)
|
|
253
|
+
trouve = _paragraphe_cible(xml, ancre, occurrence)
|
|
254
|
+
if trouve is None:
|
|
255
|
+
return xml, f"ancre introuvable : « {ancre} » (occurrence {occurrence})"
|
|
256
|
+
paragraphe = trouve.group(0)
|
|
257
|
+
motif = re.compile(re.escape(ancre) + r"[…. \s]+")
|
|
258
|
+
nouveau, nombre = motif.subn(
|
|
259
|
+
lambda m: ancre + " " + escape(valeur) + " ", paragraphe, count=1
|
|
260
|
+
)
|
|
261
|
+
if nombre == 0:
|
|
262
|
+
return xml, f"pointillés introuvables après « {ancre} »"
|
|
263
|
+
return xml[:trouve.start()] + nouveau + xml[trouve.end():], None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _cocher_case(xml: str, index: int) -> tuple[str, str | None]:
|
|
267
|
+
"""Coche le n-ième champ `<w:checkBox>` (ordre du document) en insérant
|
|
268
|
+
`<w:checked w:val="1"/>` juste après l'ouverture (ordre du schéma)."""
|
|
269
|
+
cases = list(_RE_CASE.finditer(xml))
|
|
270
|
+
if not 1 <= index <= len(cases):
|
|
271
|
+
return xml, f"case {index} absente (le formulaire en compte {len(cases)})"
|
|
272
|
+
bloc = cases[index - 1]
|
|
273
|
+
contenu = bloc.group(0)
|
|
274
|
+
if "<w:checked" in contenu:
|
|
275
|
+
contenu = re.sub(r"<w:checked[^>]*/>", '<w:checked w:val="1"/>', contenu)
|
|
276
|
+
else:
|
|
277
|
+
contenu = contenu.replace(
|
|
278
|
+
"<w:checkBox>", '<w:checkBox><w:checked w:val="1"/>', 1
|
|
279
|
+
)
|
|
280
|
+
return xml[:bloc.start()] + contenu + xml[bloc.end():], None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _noircir_case_dessinee(xml: str, index: int) -> tuple[str, str | None]:
|
|
284
|
+
"""Noircit la n-ième case dessinée du DC4 (ordre du document) : les cases
|
|
285
|
+
sont des formes présentes en double (choix DrawingML + repli VML), les
|
|
286
|
+
deux représentations sont modifiées pour rester cohérentes. Le filtre ne
|
|
287
|
+
regarde que la géométrie : une case déjà noircie doit rester à son rang,
|
|
288
|
+
sinon les index des cases suivantes décaleraient."""
|
|
289
|
+
cases = [m for m in _RE_BLOC_DESSINE.finditer(xml)
|
|
290
|
+
if _GEOMETRIE_CASE_DC4 in m.group(0)]
|
|
291
|
+
if not 1 <= index <= len(cases):
|
|
292
|
+
return xml, f"case {index} absente (le formulaire en compte {len(cases)})"
|
|
293
|
+
bloc = cases[index - 1]
|
|
294
|
+
contenu = bloc.group(0)
|
|
295
|
+
contenu = contenu.replace(
|
|
296
|
+
'filled="f"', 'filled="t" fillcolor="#000000"', 1
|
|
297
|
+
)
|
|
298
|
+
contenu = contenu.replace(
|
|
299
|
+
"</a:custGeom>",
|
|
300
|
+
'</a:custGeom><a:solidFill><a:srgbClr val="000000"/></a:solidFill>', 1
|
|
301
|
+
)
|
|
302
|
+
return xml[:bloc.start()] + contenu + xml[bloc.end():], None
|
|
Binary file
|
|
Binary file
|
|
Binary file
|