File size: 22,575 Bytes
33ddb61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""
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
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("nb_res, nb_pro, expected", [
    (1, 0, "S"),  # single house, 1 res
    (2, 0, "S"),  # single house, 2 res
    (3, 0, "C"),  # β‰₯ 3 res β†’ collectif
    (5, 0, "C"),
    (0, 1, "C"),  # any P el β†’ collectif
    (1, 1, "C"),
    (5, 3, "C"),
    (0, 0, "S"),  # nothing extracted β†’ conservative default
])
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
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("nb_res, nb_pro, expected", [
    (1, 0, "PIM"),
    (2, 0, "PIM"),
    (3, 0, "COLLECTIF"),
    (14, 0, "COLLECTIF"),
    (0, 1, "COLLECTIF"),
    (5, 3, "COLLECTIF"),
])
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"
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("ref, expected", [
    ("PC 044 035 25 00035",  "PC"),
    ("PC0440352500035",      "PC"),
    ("Pc0440352500035",      "PC"),
    ("PA 022 360 22 00027",  "PA"),
    ("DP 044 035",           "DP"),
    ("CU 12345",             "CU"),
    ("rue AbbΓ© Guinard",     ""),   # must reject β€” "ru" is NOT a valid prefix
    ("Parcelle",             ""),   # must reject β€” "PA" only counts before digits
    ("",                     ""),
    (None,                   ""),
])
def test_detect_au_type(cms_mod, ref, expected):
    assert cms_mod.detect_au_type(ref) == expected


# ──────────────────────────────────────────────────────────────────────────
# PrΓ©-Γ©quipΓ© β€” slide 14 table
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("type_au, proj, expected", [
    ("PC", "COLLECTIF", "O"),
    ("PA", "COLLECTIF", "N"),
    ("DP", "COLLECTIF", "O"),
    ("PC", "PIM",       "N"),
    ("PA", "PIM",       "N"),
    ("DP", "PIM",       "N"),
    ("",   "COLLECTIF", ""),
])
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)
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("nb_res, nb_pro, type_au, proj, expected", [
    # ≀ 3 els, 1-2 R, no P β†’ RAMI Fibre
    (1, 0, "PC", "PIM",       "RAMI Fibre"),
    (2, 0, "PC", "PIM",       "RAMI Fibre"),
    # ≀ 3 els, mix or 3 R β†’ MixteProL fibre
    (3, 0, "PC", "PIM",       "MixteProL fibre"),
    (1, 1, "PC", "COLLECTIF", "MixteProL fibre"),
    # > 3 els, 100 % rΓ©sidentiel β†’ Zlin 0% cuivre
    (14, 0, "PC", "COLLECTIF", "Zlin 0% cuivre"),
    (73, 0, "PC", "COLLECTIF", "Zlin 0% cuivre"),
    # > 3 els, RES >= PRO β†’ Zlin 0% cuivre (residential-dominated)
    (21, 1, "PC", "COLLECTIF", "Zlin 0% cuivre"),
    (10, 10, "PC", "COLLECTIF", "Zlin 0% cuivre"),  # tie β†’ res
    # > 3 els, PRO > RES β†’ ZLIN ProPur
    (1, 5, "PC", "COLLECTIF", "ZLIN ProPur"),
    (0, 4, "PC", "COLLECTIF", "ZLIN ProPur"),
    # DP + PIM-sized = "lot individuel adduction sur rue" β†’ MixteProL fibre
    (1, 0, "DP", "PIM", "MixteProL fibre"),
])
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")
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("full, expected", [
    ("FAURE Mael",            ("FAURE", "Mael")),
    ("PASCALIN Marine",       ("PASCALIN", "Marine")),
    ("Mr. BRECHBIEHL Vivien", ("BRECHBIEHL", "Vivien")),
    ("CLAVIER YOHANN",        ("CLAVIER YOHANN", "")),   # both UPPER β†’ all go to nom
    ("Florence",              ("Florence", "")),
    ("",                      ("", "")),
])
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
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("status, expected", [
    ("complète",         True),
    ("complète sous réserve", True),
    ("incomplète",       False),
    ("hors-périmètre",   False),
    ("",                 False),
])
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"])