FiberGate / src /guichetoi /cms.py
AzizMiladi's picture
fix(ci): make ruff + mypy green on the new src/ layout
dc73111
Raw
History Blame
21 kB
"""
guichetoi.cms
=============
Fill the GuichetOI CMS IMMO 9 BANBOU spreadsheet from a `Verdict` produced
by `RecommendationEngine.evaluate_files(...)`.
Follows the consigne deck "Consignes AGILIS PAR de crΓ©ations des IMB immo
neuf" (Marylène Sevre, 14/01/2026):
- Onglet Β« crΓ©ation IMB Β» β†’ one row per IMB to create
- Onglet Β« crΓ©ation syndic Β» β†’ only for COLLECTIF projects (β‰₯3 R els or
β‰₯1 P els)
- DLPI < 6 mois β†’ push to today + 6 months
- PreEquipe table (slide 14): PC=O / PA=N / DP=O for collectif; N for PIM
- DΓ©tection table (slide 13): based on R/P logement counts + AU type
- Zone Nouvelle = "Guichet Accueil OI" (fixed, do not modify)
Fields the engine extracts feed directly; fields that require external
systems (XY coords from GΓ©orΓ©so, Mondofi ref, IMB code, Siret of MOA …)
are intentionally left blank for the consultant to complete.
Returns the path to the saved xlsx.
"""
from __future__ import annotations
import re
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
# ────────────────────────────────────────────────────────────────────────────
# Domain logic β€” derived from the consigne deck
# ────────────────────────────────────────────────────────────────────────────
def _to_int(s: Any) -> int:
if s is None:
return 0
try:
return int(re.sub(r"[^\d]", "", str(s)) or 0)
except (ValueError, TypeError):
return 0
def parse_french_address(addr: str) -> dict:
"""
Split a French postal address into (numero, complement, voie, cp_ville).
Handles patterns like:
"10 rue de Cotalard, 44240 La Chapelle-sur-Erdre."
"350 BIS AVENUE J R G GAUTIER, 13290 AIX EN PROVENCE"
"rue du Saint Blaise" (no number, no postal β€” voie only)
"""
if not addr:
return {}
addr = re.sub(r"\s+", " ", addr).strip().rstrip(".,;")
m = re.match(
r"^\s*(?P<num>\d+)\s*"
r"(?P<comp>BIS|TER|QUATER|QUINQUIES)?\s+"
r"(?P<voie>.+?)"
r"(?:[,\s]+(?P<cp>\d{5})\s+(?P<ville>.+))?$",
addr, re.IGNORECASE,
)
if m:
out = {
"numero": m.group("num"),
"complement": (m.group("comp") or "").upper(),
"voie": m.group("voie").strip().rstrip(",."),
}
if m.group("cp"):
out["cp_ville"] = f"{m.group('cp')} {m.group('ville').strip().rstrip('.')}"
return out
return {"voie": addr}
def adjust_dlpi(dlpi_str: str) -> str:
"""
Per consigne (slide 12): if the DLPI on the fiche is less than 6 months
from today, push it to today + 6 months. Otherwise keep as-is. Output
formatted JJ/MM/AAAA without spaces.
"""
if not dlpi_str:
return ""
cleaned = re.sub(r"\s+", "", dlpi_str)
d = None
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%d-%m-%Y", "%Y-%m-%d"):
try:
d = datetime.strptime(cleaned, fmt)
break
except ValueError:
continue
if d is None:
return dlpi_str # leave untouched if we can't parse
threshold = datetime.now() + timedelta(days=180)
if d < threshold:
d = threshold
return d.strftime("%d/%m/%Y")
def detect_au_type(ref: str) -> str:
"""Extract the AU type prefix (PC / PA / DP / CU) from a urbanism ref."""
if not ref:
return ""
m = re.match(r"^\s*(PC|PA|DP|CU)(?:\s|\d|$)", ref.upper())
return m.group(1) if m else ""
def compute_type_site(nb_res: int, nb_pro: int) -> str:
"""
Slide 7. S = single house (1 or 2 R els). C = collectif (1+ P el, or
3+ R els). Defaults to S for empty inputs.
"""
if nb_pro >= 1:
return "C"
if nb_res >= 3:
return "C"
return "S"
def compute_project_type(nb_res: int, nb_pro: int) -> str:
"""Heuristic: small residential ≀2 R is PIM; everything else COLLECTIF."""
return "PIM" if (nb_pro == 0 and nb_res <= 2) else "COLLECTIF"
def compute_pre_equipe(type_au: str, project_type: str) -> str:
"""
Slide 14 table. O for Collectif PC and DP; N for Collectif PA and any
PIM project.
"""
if project_type == "PIM":
return "N"
if type_au in ("PC", "DP"):
return "O"
if type_au == "PA":
return "N"
return ""
# Detection codes used by the IMMO9 system (column G of Feuil1)
DETECTION_LABEL_TO_CODE: dict[str, int] = {
"RAMI Fibre": 9,
"RAMI Fibre avec extension": 14,
"Zlin 0% cuivre": 2,
"ZLIN ProPur": 5,
"MixteProL fibre": 17,
}
def compute_detection(
nb_res: int, nb_pro: int, type_au: str, project_type: str
) -> str:
"""
Slide 13 table. Returns a detection label whose code can be looked up
in DETECTION_LABEL_TO_CODE.
"""
total = nb_res + nb_pro
# Special case: DP "lot individuel adduction sur rue" β†’ MixteProL
# Heuristic flag: DP + PIM-sized β†’ MixteProL fibre
if type_au == "DP" and project_type == "PIM":
return "MixteProL fibre"
if total <= 3:
# 1 or 2 R, no P β†’ RAMI Fibre
if nb_pro == 0 and nb_res in (1, 2):
return "RAMI Fibre"
return "MixteProL fibre"
# > 3 els
if nb_pro == 0:
return "Zlin 0% cuivre"
if nb_res == 0:
return "ZLIN ProPur"
if nb_res >= nb_pro:
return "Zlin 0% cuivre"
return "ZLIN ProPur"
# ────────────────────────────────────────────────────────────────────────────
# Verdict β†’ CMS mapping
# ────────────────────────────────────────────────────────────────────────────
def _field(d: dict, key: str) -> str:
payload = d.get(key)
if not payload:
return ""
return str(payload.get("value") or "").strip()
def _extract_pf_code(documents: list[dict]) -> str:
"""Pull the PF reference (Dossier ASOEIE) from any document filename."""
for d in documents:
m = re.search(r"PF\d{10,15}", d.get("file", ""), re.IGNORECASE)
if m:
return m.group(0).upper()
return ""
def _pick_address(verdict: dict) -> str:
"""
Per consigne (slide 6/31): prefer the address on the Certificat
d'adressage when present; fall back to the fiche; then to ANY
document that carries one (Autorisation, Mandat sometimes have the
building address in their body and the model picks it up).
"""
docs = verdict.get("documents", []) or []
# 1. Certificat first (the consigne's preferred source)
for d in docs:
if d.get("doc_class") == "Certificat":
v = _field(d.get("fields", {}), "Batiment_Adresse")
if v:
return v
# 2. Fiche summary (rolled-up across all fiche pages)
v = _field(verdict.get("fiche_summary", {}), "Batiment_Adresse")
if v:
return v
# 3. Last resort: any other document carrying a Batiment_Adresse
for d in docs:
v = _field(d.get("fields", {}), "Batiment_Adresse")
if v:
return v
return ""
def _pick_mandat_fields(verdict: dict) -> dict:
"""Find representative info from a Mandat doc, or fall back to fiche."""
out = {"nom": "", "email": "", "tel": ""}
for d in verdict.get("documents", []):
if d.get("doc_class") == "Mandat":
f = d.get("fields", {})
out["nom"] = _field(f, "Representant_Nom_Complet")
out["email"] = _field(f, "Representant_Email")
out["tel"] = _field(f, "Representant_Telephone")
if any(out.values()):
return out
f = verdict.get("fiche_summary", {})
out["nom"] = _field(f, "Representant_Nom_Complet")
out["email"] = _field(f, "Representant_Email")
out["tel"] = _field(f, "Representant_Telephone")
return out
def _split_name(full: str) -> tuple[str, str]:
"""Heuristic: 'FAURE Mael' β†’ ('FAURE', 'Mael'). 'Mr. BRECHBIEHL Vivien' too."""
s = re.sub(r"^\s*(M(?:r|me|lle|onsieur|adame)?\.?\s+)", "", full or "", flags=re.IGNORECASE).strip()
parts = s.split()
if len(parts) >= 2:
# Convention: UPPERCASE part = NOM, others = prΓ©nom
uppers = [w for w in parts if w.isupper()]
if uppers:
nom = " ".join(uppers)
prenom = " ".join(w for w in parts if w not in uppers)
return nom, prenom
return parts[0], " ".join(parts[1:])
return s, ""
# ────────────────────────────────────────────────────────────────────────────
# Sheet writer
# ────────────────────────────────────────────────────────────────────────────
# Row 1: section title (merged), Row 2: column codes, Row 3: descriptions
# Data starts at Row 4.
_DATA_ROW = 4
def _sheet(wb: Any, contains: str) -> Any:
"""Find the sheet whose name contains a substring (case/diacritic-insensitive)."""
def norm(s: str) -> str:
return (s.lower()
.replace("Γ©", "e").replace("Γ¨", "e").replace("Γͺ", "e")
.replace("Γ ", "a").replace("Γ΄", "o").replace("Γ§", "c"))
target = norm(contains)
for n in wb.sheetnames:
if target in norm(n):
return wb[n]
raise KeyError(f"No sheet matching {contains!r} in {wb.sheetnames}")
def fill_cms(
verdict: dict,
output_path: Path,
template_path: Path | None = None,
) -> dict:
"""
Generate a filled CMS xlsx from a verdict dict.
Returns a dict describing what was filled and what still needs the
consultant's attention:
{
"output_path": "<path to the saved xlsx>",
"project_type": "PIM" | "COLLECTIF",
"missing_extractions": [list of human-readable field names that
SHOULD have been auto-filled but couldn't
because the model/OCR didn't extract them],
"manual_lookup": [list of fields that always require a
manual step β€” XY from GΓ©orΓ©so, Siret,
Mondofi ref, etc.],
}
The xlsx is always written. The consultant uses the two lists to know
which cells need a manual pass before submitting the CMS to Banbou.
"""
if template_path is None:
# src/guichetoi/cms.py β†’ parents[2] = repo root β†’ repo_root/assets/
template_path = Path(__file__).resolve().parents[2] / "assets" / "cms_template.xlsx"
if not template_path.exists():
raise FileNotFoundError(f"CMS template not found: {template_path}")
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(template_path, output_path)
# ── Gather inputs from the verdict ────────────────────────────────────
fiche = verdict.get("fiche_summary", {}) or {}
documents = verdict.get("documents", []) or []
ref_au = _field(fiche, "Reference_Urbanisme")
dlpi_raw = _field(fiche, "DLPI")
nb_total = _to_int(_field(fiche, "nb_log_totale"))
nb_pro = _to_int(_field(fiche, "Nb_log_pro"))
nb_res = _to_int(_field(fiche, "Nb_log_res"))
if nb_res == 0 and nb_pro == 0 and nb_total > 0:
# Convention: when only total is known, treat all as residential
nb_res = nb_total
pf_code = _extract_pf_code(documents)
addr_raw = _pick_address(verdict)
addr = parse_french_address(addr_raw)
type_au = detect_au_type(ref_au)
proj_type = compute_project_type(nb_res, nb_pro)
type_site = compute_type_site(nb_res, nb_pro)
pre_eq = compute_pre_equipe(type_au, proj_type)
detection_lbl = compute_detection(nb_res, nb_pro, type_au, proj_type)
detection_code = DETECTION_LABEL_TO_CODE.get(detection_lbl, "")
dlpi_out = adjust_dlpi(dlpi_raw)
# ── Track what's missing or always-manual for the consultant ──────────
missing_extractions: list[str] = []
manual_lookup: list[str] = []
# Things we WANTED to auto-fill but couldn't (extraction gap)
if not ref_au:
missing_extractions.append("RΓ©fΓ©rence d'urbanisme (PermisConstruire) β€” colonne 13")
if not pf_code:
missing_extractions.append("RΓ©fΓ©rence PF Agilis (DossierASOEIE) β€” colonne 14")
if not dlpi_out:
missing_extractions.append("Date de livraison du projet (DLPI) β€” colonne 15")
if (nb_res + nb_pro) == 0:
missing_extractions.append("Nombre de logements rΓ©sidentiels / professionnels β€” colonnes 11-12")
if not addr.get("numero"):
missing_extractions.append("NumΓ©ro de voie β€” colonne 5")
if not addr.get("voie"):
missing_extractions.append("Nom de la voie β€” colonne 7")
if not addr.get("cp_ville"):
missing_extractions.append("Code postal et Commune β€” colonne 10")
# Things that ALWAYS require a manual step (never come from the documents)
manual_lookup.append(
"CoordonnΓ©es XY + Projection (cols 2-4) β€” Γ  rΓ©cupΓ©rer dans GΓ©orΓ©so "
"en fonction du territoire (MΓ©tropole / DOM-TOM)"
)
manual_lookup.append(
"BΓ’timent (col 8) β€” uniquement si plusieurs bΓ’timents sur le projet"
)
manual_lookup.append(
"PrΓ©sence DTA (col 22) β€” Γ  renseigner par le consultant"
)
manual_lookup.append(
"Identifiant Processus Mondofi (cols 18-19) β€” uniquement pour les dossiers OCC"
)
# ── Write to "crΓ©ation IMB" sheet ─────────────────────────────────────
wb = load_workbook(output_path)
ws = _sheet(wb, "creation imb")
r = _DATA_ROW
ws.cell(row=r, column=1, value=type_site)
# CoordX/Y/Projection (2,3,4): blank β€” to be filled from GΓ©orΓ©so manually
if addr.get("numero"): ws.cell(row=r, column=5, value=addr["numero"])
if addr.get("complement"): ws.cell(row=r, column=6, value=addr["complement"])
if addr.get("voie"): ws.cell(row=r, column=7, value=addr["voie"])
# Batiment (8): leave blank unless multi-bldg detected
ws.cell(row=r, column=9, value="Guichet Accueil OI")
if addr.get("cp_ville"): ws.cell(row=r, column=10, value=addr["cp_ville"])
if nb_res: ws.cell(row=r, column=11, value=nb_res)
if nb_pro: ws.cell(row=r, column=12, value=nb_pro)
if ref_au: ws.cell(row=r, column=13, value=ref_au)
if pf_code: ws.cell(row=r, column=14, value=pf_code)
if dlpi_out: ws.cell(row=r, column=15, value=dlpi_out)
if detection_code: ws.cell(row=r, column=16, value=detection_code)
if pre_eq: ws.cell(row=r, column=17, value=pre_eq)
# Type/Identifiant Processus (18-20): RAMI/MPL only, left blank
# Typologie (21) β€” default OSA = 13. If filename hints at RIP, set 57.
ws.cell(row=r, column=21, value=13)
# PresenceDta (22), Commentaire Faisabilite (23-24): blank, manual
comment_bits = [
"PrΓ©-rempli automatiquement (GuichetOI-ML)",
f"Projet {proj_type} Β· Type site {type_site} Β· DΓ©tection {detection_lbl}",
"Γ€ complΓ©ter manuellement : coordonnΓ©es XY (GΓ©orΓ©so), Identifiant Processus (Mondofi pour OCC)",
]
ws.cell(row=r, column=25, value=" β€” ".join(comment_bits))
# ── Onglet "crΓ©ation syndic" β€” clear the template's example row in
# both cases, then fill it for COLLECTIF projects only (slides 16-17).
# openpyxl's `cell(row, col, value=None)` is a no-op (the None default is
# ignored), so we must set `.value = None` on the cell object directly.
wss = _sheet(wb, "creation syndic")
sr = _DATA_ROW
for col in range(1, wss.max_column + 1):
wss.cell(row=sr, column=col).value = None
if proj_type == "COLLECTIF":
cabinet = _field(fiche, "cabinet_conseil")
mandat = _pick_mandat_fields(verdict)
nom, prenom = _split_name(mandat["nom"]) if mandat["nom"] else ("", "")
if cabinet: wss.cell(row=sr, column=1, value=cabinet)
if addr.get("numero"): wss.cell(row=sr, column=2, value=addr["numero"])
if addr.get("complement"):wss.cell(row=sr, column=3, value=addr["complement"])
if addr.get("voie"): wss.cell(row=sr, column=4, value=addr["voie"])
if addr.get("cp_ville"): wss.cell(row=sr, column=5, value=addr["cp_ville"])
# Siret (6): never extracted from the documents
if nom: wss.cell(row=sr, column=7, value=nom)
if prenom: wss.cell(row=sr, column=8, value=prenom)
if mandat["tel"]: wss.cell(row=sr, column=9, value=mandat["tel"])
if mandat["email"]: wss.cell(row=sr, column=10, value=mandat["email"])
wss.cell(row=sr, column=11, value=18) # 18 = Promoteur (default)
# Track syndic-side extraction gaps for the consultant
if not cabinet:
missing_extractions.append(
"Onglet Syndic Β· Raison sociale (Cabinet conseil) β€” colonne 1"
)
if not nom:
missing_extractions.append(
"Onglet Syndic Β· Nom du responsable β€” colonne 7"
)
if not prenom:
missing_extractions.append(
"Onglet Syndic Β· PrΓ©nom du responsable β€” colonne 8"
)
if not mandat["tel"]:
missing_extractions.append(
"Onglet Syndic Β· NΒ° mobile β€” colonne 9"
)
if not mandat["email"]:
missing_extractions.append(
"Onglet Syndic Β· Email β€” colonne 10"
)
manual_lookup.append(
"Onglet Syndic Β· NΒ° SIRET (14 chiffres) β€” colonne 6"
)
wb.save(output_path)
return {
"output_path": str(output_path),
"project_type": proj_type,
"missing_extractions": missing_extractions,
"manual_lookup": manual_lookup,
}
# ────────────────────────────────────────────────────────────────────────────
# Convenience helpers used by the Streamlit demo
# ────────────────────────────────────────────────────────────────────────────
def is_cms_eligible(verdict: dict) -> bool:
"""CMS is generated only when the demande is complète (with or without manual review)."""
return (verdict.get("status") or "").startswith("complèt")
def summarise_cms_fields(verdict: dict) -> dict:
"""
Pre-compute the derived values the Streamlit UI can show as a preview
before the user downloads the xlsx.
"""
fiche = verdict.get("fiche_summary", {}) or {}
nb_total = _to_int(_field(fiche, "nb_log_totale"))
nb_pro = _to_int(_field(fiche, "Nb_log_pro"))
nb_res = _to_int(_field(fiche, "Nb_log_res"))
if nb_res == 0 and nb_pro == 0 and nb_total > 0:
nb_res = nb_total
ref_au = _field(fiche, "Reference_Urbanisme")
type_au = detect_au_type(ref_au)
proj_type = compute_project_type(nb_res, nb_pro)
return {
"Projet": proj_type,
"Type AU": type_au or "?",
"Type Site": compute_type_site(nb_res, nb_pro),
"Nb logements R": nb_res,
"Nb logements P": nb_pro,
"DΓ©tection": compute_detection(nb_res, nb_pro, type_au, proj_type),
"PrΓ©-Γ©quipΓ©": compute_pre_equipe(type_au, proj_type),
"RΓ©fΓ©rence AU": ref_au or "β€”",
"PF Agilis": _extract_pf_code(verdict.get("documents", [])) or "β€”",
"DLPI (ajustΓ©e)": adjust_dlpi(_field(fiche, "DLPI")) or "β€”",
"Adresse": _pick_address(verdict) or "β€”",
}