@@ -44,9 +62,15 @@
{{ item.name }}
- {% if item.dossier.document_type %}
- {{ item.dossier.document_type }}
- {% endif %}
+
+ {% if item.dossier.document_type %}
+ {{ item.dossier.document_type }}
+ {% endif %}
+ {% if item.dossier.source_files %}fusionné{% endif %}
+ {% if item.dossier.diagnostics_associes %}{{ item.dossier.diagnostics_associes|length }} DAS{% endif %}
+ {% if item.dossier.actes_ccam %}{{ item.dossier.actes_ccam|length }} actes{% endif %}
+ {% if item.dossier.alertes_codage %}{{ item.dossier.alertes_codage|length }} alertes{% endif %}
+
{% if item.dossier.diagnostic_principal %}
DP : {{ item.dossier.diagnostic_principal.texte[:80] }}{% if item.dossier.diagnostic_principal.texte|length > 80 %}…{% endif %}
diff --git a/tests/test_ccam_noncumul.py b/tests/test_ccam_noncumul.py
new file mode 100644
index 0000000..1f5bb1e
--- /dev/null
+++ b/tests/test_ccam_noncumul.py
@@ -0,0 +1,88 @@
+"""Tests pour le module de détection de non-cumul CCAM."""
+
+import pytest
+
+from src.config import ActeCCAM
+from src.medical.ccam_noncumul import check_noncumul
+
+
+class TestCheckNoncumul:
+ def test_no_actes_no_alerts(self):
+ assert check_noncumul([]) == []
+
+ def test_single_acte_no_alert(self):
+ actes = [ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004")]
+ assert check_noncumul(actes) == []
+
+ def test_same_base_code_different_activity(self):
+ """Deux codes avec les 7 premiers caractères identiques déclenchent une alerte."""
+ actes = [
+ ActeCCAM(texte="Acte 1", code_ccam_suggestion="HMFC004"),
+ ActeCCAM(texte="Acte 2", code_ccam_suggestion="HMFC005"),
+ ]
+ alertes = check_noncumul(actes)
+ assert any("NON-CUMUL" in a and "HMFC0" in a for a in alertes)
+
+ def test_different_base_codes_no_alert(self):
+ """Codes de bases différentes : pas d'alerte de base identique."""
+ actes = [
+ ActeCCAM(texte="Acte 1", code_ccam_suggestion="HMFC004"),
+ ActeCCAM(texte="Acte 2", code_ccam_suggestion="ZCQK002"),
+ ]
+ alertes = check_noncumul(actes)
+ # Pas d'alerte sur la règle 1 (même base)
+ assert not any("même base" in a for a in alertes)
+
+ def test_same_regroupement_same_day(self, monkeypatch):
+ """Même regroupement chirurgical le même jour déclenche une alerte."""
+ # Monkeypatch pour simuler le regroupement
+ def mock_get_regroup(acte):
+ return "ADC"
+
+ monkeypatch.setattr(
+ "src.medical.ccam_noncumul._get_regroupement", mock_get_regroup
+ )
+
+ actes = [
+ ActeCCAM(texte="Acte 1", code_ccam_suggestion="ABCD001", date="01/03/2023"),
+ ActeCCAM(texte="Acte 2", code_ccam_suggestion="EFGH002", date="01/03/2023"),
+ ]
+ alertes = check_noncumul(actes)
+ assert any("NON-CUMUL" in a and "ADC" in a for a in alertes)
+
+ def test_different_regroupement_no_alert(self, monkeypatch):
+ """Regroupements différents non incompatibles : pas d'alerte."""
+ regroup_map = {"ABCD001": "ATM", "EFGH002": "ACI"}
+
+ def mock_get_regroup(acte):
+ return regroup_map.get(acte.code_ccam_suggestion)
+
+ monkeypatch.setattr(
+ "src.medical.ccam_noncumul._get_regroupement", mock_get_regroup
+ )
+
+ actes = [
+ ActeCCAM(texte="Acte 1", code_ccam_suggestion="ABCD001", date="01/03/2023"),
+ ActeCCAM(texte="Acte 2", code_ccam_suggestion="EFGH002", date="01/03/2023"),
+ ]
+ alertes = check_noncumul(actes)
+ # Pas d'alerte de regroupement unique ni d'incompatibilité
+ assert not any("regroupement" in a.lower() for a in alertes)
+
+ def test_incompatible_regroupement_pairs(self, monkeypatch):
+ """Paire de regroupements incompatibles déclenche une alerte."""
+ regroup_map = {"ABCD001": "ADC", "EFGH002": "ADE"}
+
+ def mock_get_regroup(acte):
+ return regroup_map.get(acte.code_ccam_suggestion)
+
+ monkeypatch.setattr(
+ "src.medical.ccam_noncumul._get_regroupement", mock_get_regroup
+ )
+
+ actes = [
+ ActeCCAM(texte="Acte 1", code_ccam_suggestion="ABCD001"),
+ ActeCCAM(texte="Acte 2", code_ccam_suggestion="EFGH002"),
+ ]
+ alertes = check_noncumul(actes)
+ assert any("incompatibles" in a and "ADC" in a and "ADE" in a for a in alertes)
diff --git a/tests/test_fusion.py b/tests/test_fusion.py
new file mode 100644
index 0000000..44ef07d
--- /dev/null
+++ b/tests/test_fusion.py
@@ -0,0 +1,239 @@
+"""Tests pour le module de fusion multi-PDFs."""
+
+import pytest
+
+from src.config import (
+ ActeCCAM,
+ Diagnostic,
+ DossierMedical,
+ Sejour,
+ Traitement,
+ BiologieCle,
+ Imagerie,
+)
+from src.medical.fusion import (
+ merge_dossiers,
+ _cim10_specificity,
+ _prefer_most_specific_dp,
+ _merge_sejour,
+ _dedup_diagnostics,
+ _dedup_actes,
+)
+
+
+class TestCIM10Specificity:
+ def test_none(self):
+ assert _cim10_specificity(None) == 0
+
+ def test_short_code(self):
+ assert _cim10_specificity("I10") == 3
+
+ def test_long_code(self):
+ assert _cim10_specificity("K85.1") == 4
+
+ def test_longer_code(self):
+ assert _cim10_specificity("K80.50") == 5
+
+
+class TestSpecificityLongerCodeWins:
+ def test_specificity_longer_code_wins(self):
+ d1 = DossierMedical(
+ diagnostic_principal=Diagnostic(texte="Calcul biliaire", cim10_suggestion="K80"),
+ )
+ d2 = DossierMedical(
+ diagnostic_principal=Diagnostic(texte="Calcul cholédoque", cim10_suggestion="K80.5"),
+ )
+ dp = _prefer_most_specific_dp([d1, d2])
+ assert dp is not None
+ assert dp.cim10_suggestion == "K80.5"
+
+
+class TestMergeSejourTrackarePriority:
+ def test_merge_sejour_trackare_priority(self):
+ d1 = DossierMedical(
+ document_type="trackare",
+ sejour=Sejour(sexe="F", age=43, date_entree="25/02/2023"),
+ )
+ d2 = DossierMedical(
+ document_type="crh",
+ sejour=Sejour(sexe="M", age=45, date_entree="24/02/2023", mode_sortie="domicile"),
+ )
+ merged = _merge_sejour([d1, d2])
+ assert merged.sexe == "F" # Trackare prioritaire
+ assert merged.age == 43
+ assert merged.date_entree == "25/02/2023"
+ assert merged.mode_sortie == "domicile" # Complété depuis CRH
+
+ def test_merge_sejour_fills_missing(self):
+ d1 = DossierMedical(
+ document_type="trackare",
+ sejour=Sejour(sexe="F"),
+ )
+ d2 = DossierMedical(
+ document_type="crh",
+ sejour=Sejour(age=50, poids=75.0),
+ )
+ merged = _merge_sejour([d1, d2])
+ assert merged.sexe == "F"
+ assert merged.age == 50
+ assert merged.poids == 75.0
+
+
+class TestDedupDiagnostics:
+ def test_dedup_diagnostics_by_code(self):
+ das = [
+ Diagnostic(texte="HTA", cim10_suggestion="I10", cim10_confidence="medium"),
+ Diagnostic(texte="Hypertension", cim10_suggestion="I10", cim10_confidence="high"),
+ ]
+ result = _dedup_diagnostics(das)
+ assert len(result) == 1
+ assert result[0].cim10_confidence == "high"
+
+ def test_dedup_keeps_distinct_codes(self):
+ das = [
+ Diagnostic(texte="HTA", cim10_suggestion="I10"),
+ Diagnostic(texte="Diabète", cim10_suggestion="E11.9"),
+ ]
+ result = _dedup_diagnostics(das)
+ assert len(result) == 2
+
+
+class TestDedupActes:
+ def test_dedup_actes_by_code(self):
+ actes = [
+ ActeCCAM(texte="Cholé", code_ccam_suggestion="HMFC004"),
+ ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004", date="01/03"),
+ ]
+ result = _dedup_actes(actes)
+ assert len(result) == 1
+ assert result[0].date == "01/03" # Celui avec la date est préféré
+
+
+class TestSingleDossierPassthrough:
+ def test_single_dossier_passthrough(self):
+ d = DossierMedical(
+ source_file="test.pdf",
+ document_type="crh",
+ diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"),
+ )
+ result = merge_dossiers([d])
+ assert result.diagnostic_principal.cim10_suggestion == "I10"
+ assert result.source_files == ["test.pdf"]
+
+
+class TestDpNonRetainedBecomesDas:
+ def test_dp_non_retained_becomes_das(self):
+ d1 = DossierMedical(
+ diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"),
+ )
+ d2 = DossierMedical(
+ diagnostic_principal=Diagnostic(texte="Calcul cholédoque", cim10_suggestion="K80.5"),
+ )
+ result = merge_dossiers([d1, d2])
+ # K80.5 est plus spécifique, donc DP
+ assert result.diagnostic_principal.cim10_suggestion == "K80.5"
+ # I10 (ancien DP de d1) doit être dans les DAS
+ das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
+ assert "I10" in das_codes
+
+
+class TestFusionAlertAdded:
+ def test_fusion_alert_added(self):
+ d1 = DossierMedical(source_file="a.pdf", alertes_codage=["Alerte 1"])
+ d2 = DossierMedical(source_file="b.pdf", alertes_codage=["Alerte 2"])
+ result = merge_dossiers([d1, d2])
+ assert result.alertes_codage[0] == "FUSION: 2 documents fusionnés"
+ assert "Alerte 1" in result.alertes_codage
+ assert "Alerte 2" in result.alertes_codage
+
+
+class TestSourceFilesPopulated:
+ def test_source_files_populated(self):
+ d1 = DossierMedical(source_file="a.pdf")
+ d2 = DossierMedical(source_file="b.pdf")
+ result = merge_dossiers([d1, d2])
+ assert result.source_files == ["a.pdf", "b.pdf"]
+
+
+class TestFullMergeCROTrackare:
+ def test_full_merge_cro_trackare(self):
+ """Cas réel : fusion Trackare + CRO."""
+ trackare = DossierMedical(
+ source_file="trackare.pdf",
+ document_type="trackare",
+ sejour=Sejour(sexe="F", age=43, date_entree="25/02/2023", date_sortie="03/03/2023"),
+ diagnostic_principal=Diagnostic(
+ texte="Calcul des canaux biliaires",
+ cim10_suggestion="K80.5",
+ ),
+ diagnostics_associes=[
+ Diagnostic(texte="HTA", cim10_suggestion="I10"),
+ ],
+ actes_ccam=[
+ ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004", date="01/03"),
+ ],
+ traitements_sortie=[
+ Traitement(medicament="Paracétamol"),
+ ],
+ alertes_codage=["Alerte trackare"],
+ )
+
+ cro = DossierMedical(
+ source_file="cro.pdf",
+ document_type="cro",
+ sejour=Sejour(sexe="F"),
+ diagnostic_principal=Diagnostic(
+ texte="Pancréatite aiguë lithiasique",
+ cim10_suggestion="K85.1",
+ cim10_confidence="high",
+ ),
+ diagnostics_associes=[
+ Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
+ Diagnostic(texte="HTA", cim10_suggestion="I10"), # doublon
+ ],
+ actes_ccam=[
+ ActeCCAM(texte="TDM", code_ccam_suggestion="ZCQK002"),
+ ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004"), # doublon
+ ],
+ traitements_sortie=[
+ Traitement(medicament="Paracétamol"), # doublon
+ Traitement(medicament="Cétirizine"),
+ ],
+ alertes_codage=["Alerte CRO"],
+ )
+
+ result = merge_dossiers([trackare, cro])
+
+ # DP : K85.1 est plus spécifique que K80.5
+ assert result.diagnostic_principal.cim10_suggestion == "K85.1"
+
+ # K80.5 (ancien DP trackare) doit être dans les DAS
+ das_codes = {d.cim10_suggestion for d in result.diagnostics_associes}
+ assert "K80.5" in das_codes
+ assert "I10" in das_codes
+ assert "E66.0" in das_codes
+
+ # DAS dédupliqués : I10 ne doit pas être en double
+ i10_count = sum(1 for d in result.diagnostics_associes if d.cim10_suggestion == "I10")
+ assert i10_count == 1
+
+ # Actes dédupliqués
+ acte_codes = [a.code_ccam_suggestion for a in result.actes_ccam]
+ assert acte_codes.count("HMFC004") == 1
+ assert "ZCQK002" in acte_codes
+
+ # Traitements dédupliqués
+ meds = [t.medicament for t in result.traitements_sortie]
+ assert meds.count("Paracétamol") == 1
+ assert "Cétirizine" in meds
+
+ # Source files
+ assert result.source_files == ["trackare.pdf", "cro.pdf"]
+
+ # Alertes
+ assert result.alertes_codage[0].startswith("FUSION:")
+ assert "Alerte trackare" in result.alertes_codage
+ assert "Alerte CRO" in result.alertes_codage
+
+ # Type prioritaire : trackare
+ assert result.document_type == "trackare"
diff --git a/tests/test_viewer.py b/tests/test_viewer.py
new file mode 100644
index 0000000..21e2a7f
--- /dev/null
+++ b/tests/test_viewer.py
@@ -0,0 +1,94 @@
+"""Tests pour le viewer Flask."""
+
+import pytest
+
+from src.viewer.app import create_app, compute_group_stats, severity_badge
+from src.config import DossierMedical, Diagnostic, ActeCCAM
+
+
+@pytest.fixture
+def app():
+ app = create_app()
+ app.config["TESTING"] = True
+ return app
+
+
+@pytest.fixture
+def client(app):
+ return app.test_client()
+
+
+class TestGroupStats:
+ def test_group_stats(self):
+ items = [
+ {
+ "dossier": DossierMedical(
+ diagnostics_associes=[
+ Diagnostic(texte="HTA", cim10_suggestion="I10"),
+ Diagnostic(texte="Diabète", cim10_suggestion="E11.9", est_cma=True),
+ ],
+ actes_ccam=[
+ ActeCCAM(texte="Cholé", code_ccam_suggestion="HMFC004"),
+ ],
+ alertes_codage=["Alerte 1", "Alerte 2"],
+ ),
+ },
+ {
+ "dossier": DossierMedical(
+ diagnostics_associes=[
+ Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
+ ],
+ actes_ccam=[
+ ActeCCAM(texte="TDM", code_ccam_suggestion="ZCQK002"),
+ ],
+ alertes_codage=[],
+ ),
+ },
+ ]
+ stats = compute_group_stats(items)
+ assert stats["das_count"] == 3
+ assert stats["actes_count"] == 2
+ assert stats["alertes_count"] == 2
+ assert stats["cma_count"] == 1
+
+ def test_group_stats_empty(self):
+ stats = compute_group_stats([])
+ assert stats["das_count"] == 0
+ assert stats["alertes_count"] == 0
+
+
+class TestSeverityBadgeFilter:
+ def test_severe(self):
+ result = severity_badge("severe")
+ assert "Sévère" in result
+ assert "#dc2626" in result
+
+ def test_modere(self):
+ result = severity_badge("modere")
+ assert "Modéré" in result
+
+ def test_leger(self):
+ result = severity_badge("leger")
+ assert "Léger" in result
+
+ def test_none(self):
+ result = severity_badge(None)
+ assert result == ""
+
+ def test_unknown(self):
+ result = severity_badge("inconnu")
+ assert result == ""
+
+
+class TestIndexPageLoads:
+ def test_index_page_loads(self, client):
+ response = client.get("/")
+ assert response.status_code == 200
+ assert b"Dossiers" in response.data
+
+
+class TestDetailPageLoads:
+ def test_detail_page_404(self, client):
+ """Un fichier inexistant retourne 404."""
+ response = client.get("/dossier/nonexistent.json")
+ assert response.status_code == 404