"""Estimation heuristique du GHM (Groupe Homogène de Malades). L'algorithme officiel (ATIH FG-MCO) est propriétaire. Ce module fournit une estimation approximative utile comme pré-codage / aide au DIM : 1. CMD depuis le DP (table de plages CIM-10) 2. Type de prise en charge depuis les actes CCAM 3. Sévérité depuis les CMA/CMS 4. Construction du code GHM approximatif """ from __future__ import annotations import bisect from typing import Optional from ..config import DossierMedical, FinancialImpact, GHMEstimation # --------------------------------------------------------------------------- # Table CIM-10 → CMD (Catégorie Majeure de Diagnostic) # Triée par borne inférieure pour lookup par bisect. # Format : (debut, fin, cmd, libelle) # --------------------------------------------------------------------------- _CMD_RANGES: list[tuple[str, str, str, str]] = [ ("A00", "A99", "18", "Maladies infectieuses et parasitaires"), ("B00", "B19", "18", "Maladies infectieuses et parasitaires"), ("B20", "B24", "25", "Maladies dues au VIH"), ("B25", "B99", "18", "Maladies infectieuses et parasitaires"), ("C00", "C97", "17", "Tumeurs malignes"), ("D00", "D09", "17", "Tumeurs malignes"), ("D10", "D48", "16", "Tumeurs bénignes, hémopathies"), ("D50", "D89", "16", "Tumeurs bénignes, hémopathies"), ("E00", "E07", "10", "Maladies endocriniennes"), ("E10", "E14", "10", "Maladies endocriniennes"), ("E15", "E46", "10", "Maladies endocriniennes"), ("E47", "E90", "10", "Maladies endocriniennes"), ("F00", "F09", "19", "Maladies mentales"), ("F10", "F19", "20", "Troubles mentaux liés à l'alcool et aux toxiques"), ("F20", "F99", "19", "Maladies mentales"), ("G00", "G99", "01", "Affections du système nerveux"), ("H00", "H59", "02", "Affections de l'oeil"), ("H60", "H95", "03", "Affections ORL"), ("I00", "I99", "05", "Affections de l'appareil circulatoire"), ("J00", "J99", "04", "Affections de l'appareil respiratoire"), ("K00", "K67", "06", "Affections du tube digestif"), ("K70", "K87", "07", "Affections hépatobiliaires et pancréatiques"), ("K90", "K93", "06", "Affections du tube digestif"), ("L00", "L99", "09", "Affections de la peau"), ("M00", "M99", "08", "Affections du système ostéo-articulaire"), ("N00", "N39", "11", "Affections du rein et des voies urinaires"), ("N40", "N51", "12", "Affections de l'appareil génital masculin"), ("N60", "N98", "13", "Affections de l'appareil génital féminin"), ("N99", "N99", "11", "Affections du rein et des voies urinaires"), ("O00", "O99", "14", "Grossesses, accouchements, post-partum"), ("P00", "P96", "15", "Nouveau-nés, période périnatale"), ("Q00", "Q99", "15", "Nouveau-nés, période périnatale"), ("R00", "R99", "23", "Facteurs influençant l'état de santé (symptômes)"), ("S00", "S99", "21", "Traumatismes"), ("T00", "T19", "21", "Traumatismes"), ("T20", "T32", "22", "Brûlures"), ("T33", "T98", "21", "Traumatismes"), ("U00", "U99", "26", "Catégories spéciales"), ("V00", "Y98", "24", "Causes externes"), ("Z00", "Z99", "23", "Facteurs influençant l'état de santé"), ] # Pré-calcul : liste triée des bornes inférieures pour bisect _CMD_STARTS = [r[0] for r in _CMD_RANGES] def find_cmd(code_cim10: str) -> tuple[Optional[str], Optional[str]]: """Trouve la CMD correspondant à un code CIM-10. Returns: (cmd, libelle) ou (None, None) si non trouvé. """ if not code_cim10: return None, None # Normaliser : majuscules, retirer le point code = code_cim10.upper().replace(".", "").strip() if len(code) < 3: return None, None # Prendre les 3 premiers caractères pour le lookup code3 = code[:3] # bisect pour trouver la plage candidate idx = bisect.bisect_right(_CMD_STARTS, code3) - 1 if idx < 0: return None, None debut, fin, cmd, libelle = _CMD_RANGES[idx] if debut <= code3 <= fin: return cmd, libelle return None, None # --------------------------------------------------------------------------- # Préfixes CCAM classants (chirurgicaux) # Les codes CCAM commençant par ces lettres correspondent à des organes # et sont considérés chirurgicaux quand ils désignent un acte opératoire. # --------------------------------------------------------------------------- _CCAM_CHIRURGICAL_PREFIXES = {"H", "J", "K", "L", "N", "P", "Q"} # Préfixes interventionnels (imagerie, endoscopie) _CCAM_INTERVENTIONNEL_PREFIXES = {"Z", "Y"} def _detect_type_ghm(actes_ccam: list) -> str: """Détermine le type de prise en charge depuis les actes CCAM. Returns: "C" (chirurgical), "K" (interventionnel) ou "M" (médical). """ has_chirurgical = False has_interventionnel = False for acte in actes_ccam: code = acte.code_ccam_suggestion if not code or len(code) < 4: continue prefix = code[0].upper() if prefix in _CCAM_CHIRURGICAL_PREFIXES: has_chirurgical = True break if prefix in _CCAM_INTERVENTIONNEL_PREFIXES: has_interventionnel = True if has_chirurgical: return "C" if has_interventionnel: return "K" return "M" def _compute_severity(das_list: list) -> tuple[int, int, int]: """Calcule le niveau de sévérité à partir des DAS. Utilise le max des niveau_cma officiels ATIH quand disponibles, avec fallback sur le comptage CMA/CMS. Returns: (niveau, cma_count, cms_count) """ cma_count = 0 cms_count = 0 max_cma_level = 1 for das in das_list: # Exclure les diagnostics "barrés" / retirés du calcul de sévérité dec = getattr(das, "cim10_decision", None) if getattr(das, "status", None) == "ruled_out": continue if dec is not None and getattr(dec, "action", None) in ("REMOVE", "RULED_OUT"): continue niveau_cma = getattr(das, "niveau_cma", None) if niveau_cma and niveau_cma > 1: max_cma_level = max(max_cma_level, niveau_cma) if getattr(das, "est_cma", False): cma_count += 1 if getattr(das, "est_cms", False): cms_count += 1 # Priorité au niveau CMA officiel ATIH if max_cma_level > 1: niveau = max_cma_level elif cms_count >= 2: niveau = 4 elif cms_count >= 1 or cma_count >= 3: niveau = 3 elif cma_count >= 2: niveau = 2 else: niveau = 1 return niveau, cma_count, cms_count def estimate_ghm(dossier: DossierMedical) -> GHMEstimation: """Estime le GHM d'un dossier médical. Heuristique en 4 étapes : 1. CMD depuis le DP 2. Type de prise en charge depuis les actes CCAM 3. Sévérité depuis les CMA/CMS 4. Construction du code approximatif """ estimation = GHMEstimation() # 1. CMD depuis le DP dp = dossier.diagnostic_principal dp_code = dp.cim10_suggestion if dp else None if not dp: estimation.alertes.append("DP absent — CMD non déterminable") elif not dp_code: estimation.alertes.append("DP sans code CIM-10 — CMD non déterminable") else: cmd, libelle = find_cmd(dp_code) if cmd: estimation.cmd = cmd estimation.cmd_libelle = libelle else: estimation.alertes.append(f"CMD inconnue pour le code {dp_code}") # Alerte DP symptomatique code_letter = dp_code.upper().replace(".", "").strip()[:1] if code_letter in ("R", "Z"): estimation.alertes.append( f"DP symptomatique ({dp_code}) — risque de CMD 23, impact tarif" ) # 2. Type de prise en charge estimation.type_ghm = _detect_type_ghm(dossier.actes_ccam) # 3. Sévérité niveau, cma_count, cms_count = _compute_severity(dossier.diagnostics_associes) estimation.severite = niveau estimation.cma_count = cma_count estimation.cms_count = cms_count # 4. Code approximatif if estimation.cmd and estimation.type_ghm: estimation.ghm_approx = f"{estimation.cmd}{estimation.type_ghm}??{estimation.severite}" return estimation # --------------------------------------------------------------------------- # Tarifs moyens par CMD (source ATIH open data 2024, valeurs arrondies) # Utilisé pour le tri relatif, pas pour la facturation. # Format : cmd -> (tarif_base_euros, supplement_par_niveau_severite) # --------------------------------------------------------------------------- _CMD_TARIFS: dict[str, tuple[int, int]] = { "01": (5500, 1200), # Neurologie "02": (2800, 600), # Ophtalmologie "03": (2500, 550), # ORL "04": (3800, 900), # Pneumologie "05": (4800, 1100), # Cardiologie "06": (3500, 800), # Digestif (tube) "07": (3200, 900), # Hépatobiliaire "08": (4200, 950), # Ostéo-articulaire "09": (2400, 500), # Peau "10": (3000, 700), # Endocrinologie "11": (3300, 800), # Rein/urinaire "12": (2800, 650), # Génital masculin "13": (2600, 600), # Génital féminin "14": (3100, 700), # Obstétrique "15": (4500, 1000), # Néonat/périnat "16": (3400, 800), # Hémato/tumeurs bénignes "17": (5200, 1100), # Tumeurs malignes "18": (3600, 850), # Infectieux "19": (2800, 600), # Psychiatrie "20": (2200, 500), # Alcool/toxiques "21": (3500, 800), # Traumatismes "22": (5800, 1300), # Brûlures "23": (2000, 400), # Symptômes/Z "24": (2500, 500), # Causes externes "25": (4200, 950), # VIH "26": (3000, 700), # Catégories spéciales } _DEFAULT_TARIF = (3000, 800) def estimate_financial_impact( ghm_etab: GHMEstimation | None, ghm_ucr: GHMEstimation | None = None, ) -> FinancialImpact: """Estime l'impact financier entre le GHM établissement et le GHM UCR. Si ghm_ucr est None, on estime l'impact de perdre le codage actuel vers une sévérité 1 (scénario conservateur). """ if not ghm_etab: return FinancialImpact(raison="GHM établissement non estimé") cmd = ghm_etab.cmd or "" base, supplement = _CMD_TARIFS.get(cmd, _DEFAULT_TARIF) sev_etab = ghm_etab.severite or 1 type_etab = ghm_etab.type_ghm or "M" if ghm_ucr: sev_ucr = ghm_ucr.severite or 1 type_ucr = ghm_ucr.type_ghm or "M" else: sev_ucr = 1 type_ucr = type_etab delta_sev = sev_ucr - sev_etab # négatif = perte de sévérité impact = abs(delta_sev) * supplement # Changement de type (C→M = perte importante) changement_type = type_etab != type_ucr if changement_type and type_etab == "C" and type_ucr == "M": impact += base # perte du GHS chirurgical raison = f"Changement C→M + delta sévérité {delta_sev}" elif changement_type: impact += supplement raison = f"Changement type {type_etab}→{type_ucr} + delta sévérité {delta_sev}" elif delta_sev == 0: raison = "Pas de différence de sévérité estimée" else: raison = f"Delta sévérité {delta_sev} (CMD {cmd})" # Classification priorité if impact >= 2000 or (changement_type and type_etab == "C"): priorite = "critique" elif impact >= 1000 or abs(delta_sev) >= 2: priorite = "haute" elif impact > 0: priorite = "normale" else: priorite = "faible" return FinancialImpact( delta_severite=delta_sev, impact_estime_euros=impact, priorite=priorite, raison=raison, )