Spaces:
Sleeping
Sleeping
| """ | |
| Unit tests for `cms_generator.py` — the module that turns a Verdict into a | |
| filled CMS IMMO 9 BANBOU xlsx. | |
| Covers every pure derivation function (Type Site, Détection, Pré-équipé, | |
| AU-type detection, DLPI adjustment, address parsing, name splitting, PF | |
| extraction) plus one end-to-end `fill_cms` call that loads the actual | |
| template and verifies the expected cells are written. | |
| """ | |
| from __future__ import annotations | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| import pytest | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Type Site (S/C) — slide 7 | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_compute_type_site(cms_mod, nb_res, nb_pro, expected): | |
| assert cms_mod.compute_type_site(nb_res, nb_pro) == expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Project type — heuristic that drives Pré-équipé + syndic-sheet trigger | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_compute_project_type(cms_mod, nb_res, nb_pro, expected): | |
| assert cms_mod.compute_project_type(nb_res, nb_pro) == expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # AU prefix detection — must NOT match French words like "rue", "Parcelle" | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_detect_au_type(cms_mod, ref, expected): | |
| assert cms_mod.detect_au_type(ref) == expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Pré-équipé — slide 14 table | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_compute_pre_equipe(cms_mod, type_au, proj, expected): | |
| assert cms_mod.compute_pre_equipe(type_au, proj) == expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Détection — slide 13 table (the most complex derivation) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_compute_detection(cms_mod, nb_res, nb_pro, type_au, proj, expected): | |
| assert cms_mod.compute_detection(nb_res, nb_pro, type_au, proj) == expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # DLPI adjustment — slide 12 | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_adjust_dlpi_past_date_pushed_to_six_months(cms_mod): | |
| soon = (datetime.now() + timedelta(days=30)).strftime("%d/%m/%Y") | |
| adjusted = cms_mod.adjust_dlpi(soon) | |
| # Should be pushed to ≥ today + 6 months | |
| target = datetime.now() + timedelta(days=180) | |
| parsed = datetime.strptime(adjusted, "%d/%m/%Y") | |
| assert parsed.date() >= (target - timedelta(days=1)).date() | |
| def test_adjust_dlpi_far_future_unchanged(cms_mod): | |
| far = (datetime.now() + timedelta(days=400)).strftime("%d/%m/%Y") | |
| assert cms_mod.adjust_dlpi(far) == far | |
| def test_adjust_dlpi_empty_returns_empty(cms_mod): | |
| assert cms_mod.adjust_dlpi("") == "" | |
| assert cms_mod.adjust_dlpi(None) == "" | |
| def test_adjust_dlpi_unparseable_passed_through(cms_mod): | |
| # If we can't parse it, leave it for the consultant to inspect | |
| assert cms_mod.adjust_dlpi("janvier 2027") == "janvier 2027" | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Address parsing | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_parse_address_full(cms_mod): | |
| a = cms_mod.parse_french_address("10 rue de Cotalard, 44240 La Chapelle-sur-Erdre.") | |
| assert a["numero"] == "10" | |
| assert a["voie"] == "rue de Cotalard" | |
| assert a["cp_ville"] == "44240 La Chapelle-sur-Erdre" | |
| def test_parse_address_with_complement(cms_mod): | |
| a = cms_mod.parse_french_address("350 BIS AVENUE J R G GAUTIER, 13290 AIX EN PROVENCE") | |
| assert a["numero"] == "350" | |
| assert a["complement"] == "BIS" | |
| assert "13290" in a["cp_ville"] | |
| def test_parse_address_voie_only(cms_mod): | |
| """Some certificats only have the street name with no number / no CP.""" | |
| a = cms_mod.parse_french_address("rue du Saint Blaise") | |
| assert "voie" in a | |
| def test_parse_address_empty(cms_mod): | |
| assert cms_mod.parse_french_address("") == {} | |
| assert cms_mod.parse_french_address(None) == {} | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Name splitting — "FAURE Mael" → ("FAURE", "Mael") | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_split_name(cms_mod, full, expected): | |
| assert cms_mod._split_name(full) == expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # PF code extraction from filenames | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_extract_pf_code_from_documents(cms_mod): | |
| docs = [ | |
| {"file": "Random_doc.pdf"}, | |
| {"file": "PF0442402600168_Fiche-de-renseignement_1.pdf"}, | |
| ] | |
| assert cms_mod._extract_pf_code(docs) == "PF0442402600168" | |
| def test_extract_pf_code_missing(cms_mod): | |
| docs = [{"file": "no_pf_here.pdf"}, {"file": "still_nothing.jpg"}] | |
| assert cms_mod._extract_pf_code(docs) == "" | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # _pick_address — Certificat > fiche > any doc fallback chain | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def _make_verdict_with_address(certif_addr=None, fiche_addr=None, autorisation_addr=None): | |
| docs = [] | |
| if certif_addr is not None: | |
| docs.append({"file": "cert.pdf", "doc_class": "Certificat", "doc_confidence": 0.9, | |
| "fields": {"Batiment_Adresse": {"value": certif_addr, "confidence": 0.95}}}) | |
| if autorisation_addr is not None: | |
| docs.append({"file": "auto.pdf", "doc_class": "Autorisation", "doc_confidence": 0.9, | |
| "fields": {"Batiment_Adresse": {"value": autorisation_addr, "confidence": 0.7}}}) | |
| fiche_fields = {} | |
| if fiche_addr is not None: | |
| fiche_fields["Batiment_Adresse"] = {"value": fiche_addr, "confidence": 0.8} | |
| docs.append({"file": "fiche.pdf", "doc_class": "fiche", "doc_confidence": 0.95, | |
| "fields": fiche_fields}) | |
| return {"documents": docs, "fiche_summary": fiche_fields} | |
| def test_pick_address_prefers_certificat(cms_mod): | |
| v = _make_verdict_with_address( | |
| certif_addr="10 rue du Certif", | |
| fiche_addr="20 rue de la Fiche", | |
| ) | |
| assert cms_mod._pick_address(v) == "10 rue du Certif" | |
| def test_pick_address_falls_back_to_fiche(cms_mod): | |
| v = _make_verdict_with_address(fiche_addr="20 rue de la Fiche") | |
| assert cms_mod._pick_address(v) == "20 rue de la Fiche" | |
| def test_pick_address_falls_back_to_any_doc(cms_mod): | |
| """When neither Certificat nor fiche has Batiment_Adresse, fall back | |
| to any document that does (regression: previously returned empty).""" | |
| v = _make_verdict_with_address(autorisation_addr="5 rue de l'Auto") | |
| assert cms_mod._pick_address(v) == "5 rue de l'Auto" | |
| def test_pick_address_empty_when_nothing(cms_mod): | |
| v = _make_verdict_with_address() | |
| assert cms_mod._pick_address(v) == "" | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Eligibility check | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def test_is_cms_eligible(cms_mod, status, expected): | |
| assert cms_mod.is_cms_eligible({"status": status}) is expected | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # End-to-end: fill the actual CMS template from a synthetic verdict | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def _make_verdict_pim_complete() -> dict: | |
| """PF0442402600168-style verdict: 1 logement, full extraction.""" | |
| return { | |
| "status": "complète", | |
| "documents": [ | |
| { | |
| "file": "PF0442402600168_Fiche-de-renseignement_1.pdf", | |
| "doc_class": "fiche", "doc_confidence": 0.98, | |
| "fields": { | |
| "Reference_Urbanisme": {"value": "Pc0440352500035", "confidence": 0.99}, | |
| "DLPI": {"value": "20/10/2026", "confidence": 0.97}, | |
| "cabinet_conseil": {"value": "ORANGE BEIN PPIN","confidence": 0.96}, | |
| "nb_log_totale": {"value": "1", "confidence": 0.70}, | |
| }, | |
| }, | |
| { | |
| "file": "PF0442402600168_Certificat-d-adressage_1.pdf", | |
| "doc_class": "Certificat", "doc_confidence": 0.89, | |
| "fields": { | |
| "Batiment_Adresse": { | |
| "value": "10 rue de Cotalard, 44240 La Chapelle-sur-Erdre.", | |
| "confidence": 0.99, | |
| }, | |
| }, | |
| }, | |
| ], | |
| "fiche_summary": { | |
| "Reference_Urbanisme": {"value": "Pc0440352500035", "confidence": 0.99}, | |
| "DLPI": {"value": "20/10/2026", "confidence": 0.97}, | |
| "cabinet_conseil": {"value": "ORANGE BEIN PPIN","confidence": 0.96}, | |
| "nb_log_totale": {"value": "1", "confidence": 0.70}, | |
| }, | |
| "missing_documents": [], | |
| "incomplete_documents": [], | |
| "manual_review_documents": [], | |
| "ar_mail_body": "", | |
| } | |
| def test_fill_cms_pim_writes_creation_row(cms_mod, tmp_path): | |
| out = tmp_path / "cms_pim.xlsx" | |
| result = cms_mod.fill_cms(_make_verdict_pim_complete(), out) | |
| # Result-shape contract | |
| assert result["project_type"] == "PIM" | |
| assert "missing_extractions" in result | |
| assert "manual_lookup" in result | |
| assert Path(result["output_path"]).exists() | |
| # Inspect the written sheet | |
| from openpyxl import load_workbook | |
| wb = load_workbook(out) | |
| creation_sheet = next(n for n in wb.sheetnames if "creation imb" in n.lower().replace("é", "e")) | |
| ws = wb[creation_sheet] | |
| # Row 4 is the first data row | |
| assert ws.cell(row=4, column=1).value == "S" # Type Site | |
| assert ws.cell(row=4, column=5).value == "10" # Numero | |
| assert ws.cell(row=4, column=7).value == "rue de Cotalard" # Voie | |
| assert ws.cell(row=4, column=9).value == "Guichet Accueil OI" # Zone Nouvelle | |
| assert "44240" in ws.cell(row=4, column=10).value # CP/Ville | |
| assert ws.cell(row=4, column=11).value == 1 # Nb log R | |
| assert ws.cell(row=4, column=13).value == "Pc0440352500035" # Ref AU | |
| assert ws.cell(row=4, column=14).value == "PF0442402600168" # PF Agilis | |
| assert ws.cell(row=4, column=16).value == 9 # Detection = RAMI Fibre code | |
| assert ws.cell(row=4, column=17).value == "N" # Pré-équipé = N (PIM) | |
| assert ws.cell(row=4, column=21).value == 13 # Typologie = OSA | |
| def test_fill_cms_pim_clears_syndic_row(cms_mod, tmp_path): | |
| """For PIM projects the création-syndic sample row in the template | |
| must be wiped (otherwise the consultant inherits SCCV xxxxx / CLAVIER | |
| YOHANN from the template).""" | |
| out = tmp_path / "cms_pim_syndic_clear.xlsx" | |
| cms_mod.fill_cms(_make_verdict_pim_complete(), out) | |
| from openpyxl import load_workbook | |
| wb = load_workbook(out) | |
| syndic = next(n for n in wb.sheetnames if "syndic" in n.lower()) | |
| ws = wb[syndic] | |
| # All columns of row 4 should be empty/None | |
| for col in range(1, ws.max_column + 1): | |
| assert ws.cell(row=4, column=col).value in (None, ""), \ | |
| f"col {col} not cleared: {ws.cell(row=4, column=col).value!r}" | |
| def test_fill_cms_collectif_populates_syndic(cms_mod, tmp_path): | |
| """COLLECTIF + Mandat: syndic sheet is filled from Mandat + cabinet.""" | |
| verdict = { | |
| "status": "complète", | |
| "documents": [ | |
| { | |
| "file": "PF0335202600876_Fiche-de-renseignement_1.pdf", | |
| "doc_class": "fiche", "doc_confidence": 0.96, | |
| "fields": { | |
| "Reference_Urbanisme": {"value": "PC0330752500012", "confidence": 0.99}, | |
| "DLPI": {"value": "03/07/2028", "confidence": 0.97}, | |
| "cabinet_conseil": {"value": "ORANGE BEIN SO", "confidence": 0.96}, | |
| "nb_log_totale": {"value": "14", "confidence": 0.70}, | |
| }, | |
| }, | |
| { | |
| "file": "PF0335202600876_Mandat.pdf", | |
| "doc_class": "Mandat", "doc_confidence": 0.90, | |
| "fields": { | |
| "Representant_Nom_Complet": {"value": "PASCALIN Marine", "confidence": 0.72}, | |
| "Representant_Email": {"value": "marine.pascalin@orange.com", "confidence": 0.77}, | |
| "Representant_Telephone": {"value": "06 70495507", "confidence": 0.81}, | |
| }, | |
| }, | |
| ], | |
| "fiche_summary": { | |
| "Reference_Urbanisme": {"value": "PC0330752500012", "confidence": 0.99}, | |
| "DLPI": {"value": "03/07/2028", "confidence": 0.97}, | |
| "cabinet_conseil": {"value": "ORANGE BEIN SO", "confidence": 0.96}, | |
| "nb_log_totale": {"value": "14", "confidence": 0.70}, | |
| }, | |
| "missing_documents": [], "incomplete_documents": [], | |
| "manual_review_documents": [], "ar_mail_body": "", | |
| } | |
| out = tmp_path / "cms_collectif.xlsx" | |
| result = cms_mod.fill_cms(verdict, out) | |
| assert result["project_type"] == "COLLECTIF" | |
| from openpyxl import load_workbook | |
| wb = load_workbook(out) | |
| creation = next(n for n in wb.sheetnames if "creation imb" in n.lower().replace("é", "e")) | |
| syndic = next(n for n in wb.sheetnames if "syndic" in n.lower()) | |
| # creation IMB: type site C, 14 logements R, detection = Zlin 0% cuivre (code 2) | |
| assert wb[creation].cell(row=4, column=1).value == "C" | |
| assert wb[creation].cell(row=4, column=11).value == 14 | |
| assert wb[creation].cell(row=4, column=16).value == 2 | |
| assert wb[creation].cell(row=4, column=17).value == "O" # PC + Collectif | |
| # création syndic: filled from cabinet + Mandat | |
| ws_s = wb[syndic] | |
| assert ws_s.cell(row=4, column=1).value == "ORANGE BEIN SO" | |
| assert ws_s.cell(row=4, column=7).value == "PASCALIN" | |
| assert ws_s.cell(row=4, column=8).value == "Marine" | |
| assert ws_s.cell(row=4, column=10).value == "marine.pascalin@orange.com" | |
| assert ws_s.cell(row=4, column=11).value == 18 # 18 = Promoteur | |
| def test_fill_cms_reports_missing_fields_when_extraction_incomplete(cms_mod, tmp_path): | |
| """Verdict with no address → numero/voie/cp_ville should appear in missing_extractions.""" | |
| verdict = { | |
| "status": "incomplète", | |
| "documents": [ | |
| { | |
| "file": "PF0562502601177_Fiche-de-renseignement_1.pdf", | |
| "doc_class": "fiche", "doc_confidence": 0.98, | |
| "fields": { | |
| "Reference_Urbanisme": {"value": "PC0562552500009", "confidence": 0.99}, | |
| "DLPI": {"value": "14/09/2026", "confidence": 0.97}, | |
| }, | |
| }, | |
| ], | |
| "fiche_summary": { | |
| "Reference_Urbanisme": {"value": "PC0562552500009", "confidence": 0.99}, | |
| "DLPI": {"value": "14/09/2026", "confidence": 0.97}, | |
| }, | |
| "missing_documents": [], "incomplete_documents": [], | |
| "manual_review_documents": [], "ar_mail_body": "", | |
| } | |
| out = tmp_path / "cms_partial.xlsx" | |
| result = cms_mod.fill_cms(verdict, out) | |
| missing = " ".join(result["missing_extractions"]) | |
| assert "logements" in missing # no R/P count | |
| assert "voie" in missing.lower() # no address | |
| assert "Code postal" in missing # no CP/ville | |
| # always-manual always present | |
| assert any("Géoréso" in s for s in result["manual_lookup"]) | |