Spaces:
Running
Running
| """ | |
| 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 "β", | |
| } | |