phield-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ .venv/
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ .eggs/
14
+ *.egg
15
+ .ruff_cache/
16
+ .mypy_cache/
17
+ .pytest_cache/
18
+ htmlcov/
19
+ .coverage
20
+ coverage.xml
21
+
22
+ # Node
23
+ node_modules/
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+ .pnpm-debug.log*
28
+
29
+ # Build
30
+ frontend/dist/
31
+ frontend/build/
32
+
33
+ # Environment
34
+ .env
35
+ .env.local
36
+ .env.production
37
+ .env.staging
38
+
39
+ # IDE
40
+ .vscode/
41
+ .idea/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+ .DS_Store
46
+
47
+ # Docker
48
+ docker-compose.override.yml
49
+
50
+ # Data (raw files tracked separately or too large)
51
+ data/raw/*.csv
52
+ data/raw/*.xlsx
53
+ data/raw/*.vcf
54
+ data/raw/*.parquet
55
+ data/raw/*.pdf
56
+ data/raw/*.json
57
+ !data/raw/.gitkeep
58
+
59
+ # Alembic
60
+ backend/alembic/versions/__pycache__/
61
+
62
+ # Logs
63
+ *.log
64
+ logs/
65
+
66
+ # OS
67
+ Thumbs.db
68
+ Desktop.ini
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: phield-mcp
3
+ Version: 0.1.0
4
+ Summary: Connecteur MCP Phield — Claude Desktop vers CRM Phield
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: mcp[cli]>=1.0.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # phield-mcp
12
+
13
+ Connecteur MCP (Model Context Protocol) pour le CRM Phield. Permet aux commerciaux d'interagir avec le CRM directement depuis Claude Desktop.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ uvx phield-mcp
19
+ ```
20
+
21
+ ## Configuration Claude Desktop
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "phield": {
27
+ "command": "uvx",
28
+ "args": ["phield-mcp"],
29
+ "env": {
30
+ "PHIELD_API_KEY": "<votre clé API>",
31
+ "PHIELD_API_URL": "https://pharos-production-624e.up.railway.app"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Tools disponibles
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `tableau_de_bord` | Dashboard du jour (actions, retards, alertes, fiches) |
43
+ | `mes_actions_en_retard` | Actions en retard uniquement |
44
+ | `rechercher_pharmacie` | Recherche par nom/ville/SIRET + filtres |
45
+ | `fiche_pharmacie` | Fiche complète (coordonnées, financier, produits, historique) |
46
+ | `ehpad_proximite` | EHPADs proches (lits, distance, référencement) |
47
+ | `creer_action` | Planifier une action (appel, visite, relance, demo...) |
48
+ | `completer_action` | Marquer une action faite/annulée/reportée |
49
+ | `enregistrer_interaction` | Logger un CR de visite/appel/email |
50
+ | `changer_statut` | Changer le statut pipeline d'une pharmacie |
51
+ | `alertes_veille` | Alertes BODACC/BOAMP non lues |
@@ -0,0 +1,41 @@
1
+ # phield-mcp
2
+
3
+ Connecteur MCP (Model Context Protocol) pour le CRM Phield. Permet aux commerciaux d'interagir avec le CRM directement depuis Claude Desktop.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ uvx phield-mcp
9
+ ```
10
+
11
+ ## Configuration Claude Desktop
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "phield": {
17
+ "command": "uvx",
18
+ "args": ["phield-mcp"],
19
+ "env": {
20
+ "PHIELD_API_KEY": "<votre clé API>",
21
+ "PHIELD_API_URL": "https://pharos-production-624e.up.railway.app"
22
+ }
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## Tools disponibles
29
+
30
+ | Tool | Description |
31
+ |------|-------------|
32
+ | `tableau_de_bord` | Dashboard du jour (actions, retards, alertes, fiches) |
33
+ | `mes_actions_en_retard` | Actions en retard uniquement |
34
+ | `rechercher_pharmacie` | Recherche par nom/ville/SIRET + filtres |
35
+ | `fiche_pharmacie` | Fiche complète (coordonnées, financier, produits, historique) |
36
+ | `ehpad_proximite` | EHPADs proches (lits, distance, référencement) |
37
+ | `creer_action` | Planifier une action (appel, visite, relance, demo...) |
38
+ | `completer_action` | Marquer une action faite/annulée/reportée |
39
+ | `enregistrer_interaction` | Logger un CR de visite/appel/email |
40
+ | `changer_statut` | Changer le statut pipeline d'une pharmacie |
41
+ | `alertes_veille` | Alertes BODACC/BOAMP non lues |
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "phield-mcp"
3
+ version = "0.1.0"
4
+ description = "Connecteur MCP Phield — Claude Desktop vers CRM Phield"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ dependencies = [
9
+ "mcp[cli]>=1.0.0",
10
+ "httpx>=0.27",
11
+ ]
12
+
13
+ [project.scripts]
14
+ phield-mcp = "phield_mcp.server:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/phield_mcp"]
@@ -0,0 +1 @@
1
+ """Phield MCP — Connecteur Claude Desktop vers CRM Phield."""
@@ -0,0 +1,73 @@
1
+ """Client HTTP vers l'API Phield (Railway)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ API_URL = os.environ.get(
11
+ "PHIELD_API_URL", "https://pharos-production-624e.up.railway.app"
12
+ )
13
+ API_KEY = os.environ.get("PHIELD_API_KEY", "")
14
+
15
+ TIMEOUT = httpx.Timeout(connect=30, read=60, write=30, pool=30)
16
+ RETRY_STATUSES = {502, 503, 504}
17
+
18
+
19
+ class PhieldClient:
20
+ """Async HTTP client wrapping the Phield REST API."""
21
+
22
+ def __init__(self) -> None:
23
+ self._client = httpx.AsyncClient(
24
+ base_url=API_URL,
25
+ headers={"X-API-Key": API_KEY},
26
+ timeout=TIMEOUT,
27
+ )
28
+
29
+ async def _request(
30
+ self,
31
+ method: str,
32
+ path: str,
33
+ *,
34
+ params: dict[str, Any] | None = None,
35
+ json: dict[str, Any] | None = None,
36
+ ) -> dict[str, Any] | list[Any]:
37
+ """Execute an HTTP request with 1 retry on 502/503/504."""
38
+ for attempt in range(2):
39
+ resp = await self._client.request(
40
+ method, path, params=params, json=json
41
+ )
42
+ if resp.status_code in RETRY_STATUSES and attempt == 0:
43
+ continue
44
+ if resp.status_code == 204:
45
+ return {"ok": True}
46
+ if resp.status_code >= 400:
47
+ try:
48
+ detail = resp.json().get("detail", resp.text)
49
+ except Exception:
50
+ detail = resp.text
51
+ return {"erreur": f"HTTP {resp.status_code} — {detail}"}
52
+ return resp.json()
53
+ return {"erreur": "Le serveur est temporairement indisponible (502/503/504)"}
54
+
55
+ async def get(self, path: str, **params: Any) -> Any:
56
+ clean = {k: v for k, v in params.items() if v is not None}
57
+ return await self._request("GET", path, params=clean or None)
58
+
59
+ async def post(self, path: str, data: dict[str, Any] | None = None) -> Any:
60
+ return await self._request("POST", path, json=data)
61
+
62
+ async def patch(self, path: str, data: dict[str, Any] | None = None) -> Any:
63
+ return await self._request("PATCH", path, json=data)
64
+
65
+ async def delete(self, path: str) -> Any:
66
+ return await self._request("DELETE", path)
67
+
68
+ async def close(self) -> None:
69
+ await self._client.aclose()
70
+
71
+
72
+ # Singleton
73
+ api = PhieldClient()
@@ -0,0 +1,35 @@
1
+ """Serveur MCP Phield — connecte Claude Desktop au CRM Phield."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from phield_mcp.tools import actions, dashboard, interactions, pharmacies, veille
8
+
9
+ mcp = FastMCP(
10
+ "Phield CRM",
11
+ instructions=(
12
+ "Tu es l'assistant CRM des commerciaux Phield. "
13
+ "Utilise les outils Phield pour consulter le dashboard, rechercher des pharmacies, "
14
+ "enregistrer des interactions, planifier des actions, et surveiller les alertes de veille. "
15
+ "Réponds toujours en français. "
16
+ "Quand un commercial décrit une visite ou un appel, propose d'enregistrer l'interaction. "
17
+ "Quand il demande ce qu'il a à faire, utilise le tableau de bord."
18
+ ),
19
+ )
20
+
21
+ # Register all tool modules
22
+ dashboard.register(mcp)
23
+ pharmacies.register(mcp)
24
+ actions.register(mcp)
25
+ interactions.register(mcp)
26
+ veille.register(mcp)
27
+
28
+
29
+ def main() -> None:
30
+ """Entry point for `uvx phield-mcp` or `python -m phield_mcp.server`."""
31
+ mcp.run(transport="stdio")
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
File without changes
@@ -0,0 +1,69 @@
1
+ """Tools MCP — Création et complétion d'actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from phield_mcp.client import api
10
+
11
+
12
+ def register(mcp: FastMCP) -> None:
13
+ @mcp.tool()
14
+ async def creer_action(
15
+ id_pharmacie: str,
16
+ date_prevue: str,
17
+ type_action: str,
18
+ priorite: str = "normale",
19
+ note: Optional[str] = None,
20
+ ) -> str:
21
+ """Planifie une nouvelle action pour une pharmacie.
22
+
23
+ Args:
24
+ id_pharmacie: UUID de la pharmacie
25
+ date_prevue: Date prévue (YYYY-MM-DD)
26
+ type_action: appel, email, visite, relance, demo, devis, signature
27
+ priorite: basse, normale, haute, urgente (défaut: normale)
28
+ note: Note optionnelle
29
+ """
30
+ payload: dict = {
31
+ "id_pharmacie": id_pharmacie,
32
+ "date_prevue": date_prevue,
33
+ "type_action": type_action,
34
+ "priorite": priorite,
35
+ }
36
+ if note:
37
+ payload["note"] = note
38
+
39
+ data = await api.post("/actions", payload)
40
+ if isinstance(data, dict) and "erreur" in data:
41
+ return data["erreur"]
42
+
43
+ return (
44
+ f"Action créée : {type_action} le {date_prevue} "
45
+ f"(priorité {priorite}, ID `{data.get('id_action', '?')}`)"
46
+ )
47
+
48
+ @mcp.tool()
49
+ async def completer_action(
50
+ id_action: str,
51
+ statut: str = "fait",
52
+ note: Optional[str] = None,
53
+ ) -> str:
54
+ """Marque une action comme faite, annulée ou reportée.
55
+
56
+ Args:
57
+ id_action: UUID de l'action
58
+ statut: fait, annule, reporte (défaut: fait)
59
+ note: Note optionnelle
60
+ """
61
+ payload: dict = {"statut": statut}
62
+ if note:
63
+ payload["note"] = note
64
+
65
+ data = await api.patch(f"/actions/{id_action}", payload)
66
+ if isinstance(data, dict) and "erreur" in data:
67
+ return data["erreur"]
68
+
69
+ return f"Action marquée **{statut}**."
@@ -0,0 +1,76 @@
1
+ """Tools MCP — Dashboard et actions en retard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from phield_mcp.client import api
10
+
11
+
12
+ def register(mcp: FastMCP) -> None:
13
+ @mcp.tool()
14
+ async def tableau_de_bord() -> str:
15
+ """Affiche le tableau de bord du jour : actions prévues, en retard, de la semaine, alertes de veille, et dernières fiches consultées."""
16
+ data = await api.get("/dashboard")
17
+ if isinstance(data, dict) and "erreur" in data:
18
+ return data["erreur"]
19
+
20
+ lines: list[str] = []
21
+
22
+ retard = data.get("actions_retard", [])
23
+ if retard:
24
+ lines.append(f"## Actions en retard ({len(retard)})")
25
+ for a in retard:
26
+ lines.append(f"- {a['type_action']} — {a['pharmacie_nom']} (prévu {a['date_prevue']})")
27
+
28
+ jour = data.get("actions_jour", [])
29
+ if jour:
30
+ lines.append(f"\n## Actions du jour ({len(jour)})")
31
+ for a in jour:
32
+ lines.append(f"- [{a['priorite']}] {a['type_action']} — {a['pharmacie_nom']}")
33
+ if a.get("note"):
34
+ lines.append(f" Note : {a['note']}")
35
+
36
+ semaine = data.get("actions_semaine", [])
37
+ if semaine:
38
+ lines.append(f"\n## Cette semaine ({len(semaine)})")
39
+ for a in semaine:
40
+ lines.append(f"- {a['date_prevue']} : {a['type_action']} — {a['pharmacie_nom']}")
41
+
42
+ alertes = data.get("alertes", [])
43
+ if alertes:
44
+ lines.append(f"\n## Alertes veille ({len(alertes)})")
45
+ for al in alertes[:5]:
46
+ lines.append(f"- [{al['type_veille']}] {al['titre']}")
47
+ if al.get("pharmacie_nom"):
48
+ lines.append(f" Pharmacie : {al['pharmacie_nom']}")
49
+
50
+ fiches = data.get("fiches_recentes", [])
51
+ if fiches:
52
+ lines.append(f"\n## Dernières fiches consultées")
53
+ for f in fiches[:5]:
54
+ lines.append(f"- {f['raison_sociale']} ({f['ville']}) — score {f.get('score_priorite', '?')}, {f['statut_commercial']}")
55
+
56
+ if not lines:
57
+ return "Rien de prévu aujourd'hui. Bonne journée !"
58
+
59
+ return "\n".join(lines)
60
+
61
+ @mcp.tool()
62
+ async def mes_actions_en_retard() -> str:
63
+ """Liste uniquement les actions en retard (non effectuées dont la date est passée)."""
64
+ data = await api.get("/actions/overdue")
65
+ if isinstance(data, dict) and "erreur" in data:
66
+ return data["erreur"]
67
+
68
+ if not data:
69
+ return "Aucune action en retard."
70
+
71
+ lines = [f"## Actions en retard ({len(data)})"]
72
+ for a in data:
73
+ lines.append(f"- {a['type_action']} — pharmacie {a['id_pharmacie'][:8]}… (prévu {a['date_prevue']}, priorité {a['priorite']})")
74
+ if a.get("note"):
75
+ lines.append(f" Note : {a['note']}")
76
+ return "\n".join(lines)
@@ -0,0 +1,61 @@
1
+ """Tools MCP — Enregistrement d'interactions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from phield_mcp.client import api
10
+
11
+
12
+ def register(mcp: FastMCP) -> None:
13
+ @mcp.tool()
14
+ async def enregistrer_interaction(
15
+ id_pharmacie: str,
16
+ date: str,
17
+ type: str,
18
+ canal: str,
19
+ resultat: str = "neutre",
20
+ duree_minutes: Optional[int] = None,
21
+ interlocuteur_nom: Optional[str] = None,
22
+ note: Optional[str] = None,
23
+ produits_evoques: Optional[str] = None,
24
+ ) -> str:
25
+ """Enregistre un compte-rendu d'interaction (visite, appel, email…).
26
+
27
+ Args:
28
+ id_pharmacie: UUID de la pharmacie
29
+ date: Date et heure (ISO 8601, ex: 2026-04-10T14:30:00)
30
+ type: appel_sortant, appel_entrant, email_envoye, email_recu, visite, demo, salon, autre
31
+ canal: telephone, email, physique, visio, autre
32
+ resultat: positif, neutre, negatif, absent, a_rappeler (défaut: neutre)
33
+ duree_minutes: Durée en minutes
34
+ interlocuteur_nom: Nom de l'interlocuteur
35
+ note: Compte-rendu / notes
36
+ produits_evoques: Produits évoqués, séparés par des virgules (ex: "robot_officine,agencement")
37
+ """
38
+ payload: dict = {
39
+ "id_pharmacie": id_pharmacie,
40
+ "date": date,
41
+ "type": type,
42
+ "canal": canal,
43
+ "resultat": resultat,
44
+ }
45
+ if duree_minutes is not None:
46
+ payload["duree_minutes"] = duree_minutes
47
+ if interlocuteur_nom:
48
+ payload["interlocuteur_nom"] = interlocuteur_nom
49
+ if note:
50
+ payload["note"] = note
51
+ if produits_evoques:
52
+ payload["produits_evoques"] = [p.strip() for p in produits_evoques.split(",")]
53
+
54
+ data = await api.post("/interactions", payload)
55
+ if isinstance(data, dict) and "erreur" in data:
56
+ return data["erreur"]
57
+
58
+ return (
59
+ f"Interaction enregistrée : {type} ({canal}) — résultat {resultat} "
60
+ f"(ID `{data.get('id_interaction', '?')}`)"
61
+ )
@@ -0,0 +1,198 @@
1
+ """Tools MCP — Recherche, fiche, EHPAD proximité, changement statut."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from phield_mcp.client import api
10
+
11
+
12
+ def register(mcp: FastMCP) -> None:
13
+ @mcp.tool()
14
+ async def rechercher_pharmacie(
15
+ recherche: Optional[str] = None,
16
+ departement: Optional[str] = None,
17
+ statut: Optional[str] = None,
18
+ score_min: Optional[int] = None,
19
+ score_max: Optional[int] = None,
20
+ produit: Optional[str] = None,
21
+ concurrent: Optional[str] = None,
22
+ page: int = 1,
23
+ ) -> str:
24
+ """Recherche des pharmacies avec filtres.
25
+
26
+ Args:
27
+ recherche: Texte libre (nom, ville, SIRET, titulaire)
28
+ departement: Code département (ex: "92", "75")
29
+ statut: prospect, contacte, en_negociation, client, perdu, inactif
30
+ score_min: Score minimum (0-100)
31
+ score_max: Score maximum (0-100)
32
+ produit: Type de produit (robot_officine, robot_ehpad, agencement, grossiste)
33
+ concurrent: Nom du concurrent (partiel)
34
+ page: Page de résultats (défaut 1)
35
+ """
36
+ data = await api.get(
37
+ "/pharmacies",
38
+ search=recherche,
39
+ departement=departement,
40
+ statut=statut,
41
+ score_min=score_min,
42
+ score_max=score_max,
43
+ produit=produit,
44
+ concurrent=concurrent,
45
+ page=page,
46
+ page_size=20,
47
+ )
48
+ if isinstance(data, dict) and "erreur" in data:
49
+ return data["erreur"]
50
+
51
+ items = data.get("items", [])
52
+ total = data.get("total", 0)
53
+
54
+ if not items:
55
+ return "Aucune pharmacie trouvée avec ces critères."
56
+
57
+ lines = [f"## {total} pharmacie(s) trouvée(s) (page {page})\n"]
58
+ for p in items:
59
+ score = p.get("score_priorite") or "—"
60
+ opp = " [OPPOSITION]" if p.get("opposition_prospection") else ""
61
+ lines.append(
62
+ f"- **{p['raison_sociale']}** ({p['ville']}, {p['departement']})\n"
63
+ f" SIRET {p['siret']} | Score {score} | {p['statut_commercial']}{opp}\n"
64
+ f" ID : `{p['id_pharmacie']}`"
65
+ )
66
+
67
+ if total > page * 20:
68
+ lines.append(f"\n_Page {page}/{(total + 19) // 20} — relance avec page={page + 1} pour la suite._")
69
+
70
+ return "\n".join(lines)
71
+
72
+ @mcp.tool()
73
+ async def fiche_pharmacie(id_pharmacie: str) -> str:
74
+ """Affiche la fiche complète d'une pharmacie : coordonnées, financier, score, produits, interactions récentes, actions.
75
+
76
+ Args:
77
+ id_pharmacie: UUID de la pharmacie
78
+ """
79
+ # Fetch pharmacy detail + products + interactions + actions in parallel
80
+ import asyncio
81
+
82
+ pharma, produits, interactions, actions = await asyncio.gather(
83
+ api.get(f"/pharmacies/{id_pharmacie}"),
84
+ api.get(f"/pharmacies/{id_pharmacie}/produits"),
85
+ api.get(f"/interactions/pharmacie/{id_pharmacie}", limit=10),
86
+ api.get(f"/actions/pharmacie/{id_pharmacie}"),
87
+ )
88
+
89
+ if isinstance(pharma, dict) and "erreur" in pharma:
90
+ return pharma["erreur"]
91
+
92
+ lines: list[str] = []
93
+
94
+ # Header
95
+ p = pharma
96
+ opp = " **[OPPOSITION PROSPECTION]**" if p.get("opposition_prospection") else ""
97
+ lines.append(f"# {p['raison_sociale']}{opp}")
98
+ lines.append(f"SIRET {p['siret']} | {p['adresse']}, {p['code_postal']} {p['ville']}")
99
+
100
+ if p.get("telephone"):
101
+ lines.append(f"Tel : {p['telephone']}")
102
+ if p.get("email"):
103
+ lines.append(f"Email : {p['email']}")
104
+ if p.get("titulaire_nom"):
105
+ tit = p['titulaire_nom']
106
+ if p.get("titulaire_prenom"):
107
+ tit = f"{p['titulaire_prenom']} {tit}"
108
+ lines.append(f"Titulaire : {tit}")
109
+
110
+ # Commercial status
111
+ lines.append(f"\n## Statut commercial")
112
+ lines.append(f"Pipeline : **{p['statut_commercial']}** | Score : **{p.get('score_priorite', '—')}/100**")
113
+ if p.get("score_override"):
114
+ lines.append(f"Score forcé — motif : {p.get('score_override_motif', '?')}")
115
+
116
+ # Financier
117
+ if p.get("ca") or p.get("resultat_net"):
118
+ lines.append(f"\n## Données financières")
119
+ if p.get("ca"):
120
+ lines.append(f"CA : {p['ca']:,} EUR".replace(",", " "))
121
+ if p.get("resultat_net"):
122
+ lines.append(f"Résultat net : {p['resultat_net']:,} EUR".replace(",", " "))
123
+ if p.get("effectifs"):
124
+ lines.append(f"Effectifs : {p['effectifs']}")
125
+ if p.get("annee_bilan"):
126
+ lines.append(f"Année bilan : {p['annee_bilan']}")
127
+
128
+ # Products
129
+ if isinstance(produits, list) and produits:
130
+ lines.append(f"\n## Produits ({len(produits)})")
131
+ for pr in produits:
132
+ status_icon = "**installé**" if pr.get("installe") else "pas installé"
133
+ concurrent = f" (concurrent: {pr['concurrent']})" if pr.get("concurrent") else ""
134
+ lines.append(f"- {pr['produit']} : {status_icon}{concurrent}")
135
+ if pr.get("note"):
136
+ lines.append(f" Note : {pr['note']}")
137
+
138
+ # Interactions
139
+ if isinstance(interactions, list) and interactions:
140
+ lines.append(f"\n## Dernières interactions")
141
+ for i in interactions[:5]:
142
+ lines.append(f"- {i['date'][:10]} {i['type']} ({i['canal']}) — {i['resultat']}")
143
+ if i.get("note"):
144
+ lines.append(f" {i['note'][:200]}")
145
+
146
+ # Actions
147
+ if isinstance(actions, list) and actions:
148
+ pending = [a for a in actions if a.get("statut") == "a_faire"]
149
+ if pending:
150
+ lines.append(f"\n## Actions à faire ({len(pending)})")
151
+ for a in pending[:5]:
152
+ lines.append(f"- {a['date_prevue']} : {a['type_action']} [{a['priorite']}]")
153
+ if a.get("note"):
154
+ lines.append(f" {a['note'][:150]}")
155
+
156
+ return "\n".join(lines)
157
+
158
+ @mcp.tool()
159
+ async def ehpad_proximite(id_pharmacie: str) -> str:
160
+ """Liste les EHPAD à proximité d'une pharmacie avec nombre de lits et distance.
161
+
162
+ Args:
163
+ id_pharmacie: UUID de la pharmacie
164
+ """
165
+ data = await api.get(f"/pharmacies/{id_pharmacie}/ehpad-proximite")
166
+ if isinstance(data, dict) and "erreur" in data:
167
+ return data["erreur"]
168
+
169
+ items = data.get("items", []) if isinstance(data, dict) else data
170
+ if not items:
171
+ return "Aucun EHPAD trouvé à proximité."
172
+
173
+ lines = [f"## {len(items)} EHPAD à proximité\n"]
174
+ for e in items:
175
+ lits = f"{e.get('nombre_lits', '?')} lits" if e.get("nombre_lits") else ""
176
+ dist = f"{e.get('distance_km', '?')} km" if e.get("distance_km") is not None else ""
177
+ ref = " (référencé)" if e.get("est_reference") else ""
178
+ lines.append(f"- **{e.get('raison_sociale', '?')}** — {dist}, {lits}{ref}")
179
+ if e.get("adresse"):
180
+ lines.append(f" {e['adresse']}, {e.get('code_postal', '')} {e.get('ville', '')}")
181
+ return "\n".join(lines)
182
+
183
+ @mcp.tool()
184
+ async def changer_statut(id_pharmacie: str, statut: str) -> str:
185
+ """Change le statut pipeline d'une pharmacie.
186
+
187
+ Args:
188
+ id_pharmacie: UUID de la pharmacie
189
+ statut: Nouveau statut — prospect, contacte, en_negociation, client, perdu, inactif
190
+ """
191
+ data = await api.patch(
192
+ f"/pharmacies/{id_pharmacie}/statut",
193
+ {"statut": statut},
194
+ )
195
+ if isinstance(data, dict) and "erreur" in data:
196
+ return data["erreur"]
197
+
198
+ return f"Statut changé en **{statut}** pour {data.get('raison_sociale', 'la pharmacie')}."
@@ -0,0 +1,47 @@
1
+ """Tools MCP — Alertes veille BODACC/BOAMP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from phield_mcp.client import api
10
+
11
+
12
+ def register(mcp: FastMCP) -> None:
13
+ @mcp.tool()
14
+ async def alertes_veille(
15
+ type_veille: Optional[str] = None,
16
+ non_lues: bool = True,
17
+ ) -> str:
18
+ """Affiche les alertes de veille BODACC (procédures collectives) et BOAMP (marchés publics).
19
+
20
+ Args:
21
+ type_veille: Filtrer par type — bodacc, boamp (défaut: tous)
22
+ non_lues: Ne montrer que les non-lues (défaut: oui)
23
+ """
24
+ params: dict = {}
25
+ if type_veille:
26
+ params["type_veille"] = type_veille
27
+ if non_lues:
28
+ params["lu"] = False
29
+
30
+ data = await api.get("/veille", **params)
31
+ if isinstance(data, dict) and "erreur" in data:
32
+ return data["erreur"]
33
+
34
+ items = data.get("items", []) if isinstance(data, dict) else data
35
+ if not items:
36
+ return "Aucune alerte de veille" + (" non lue." if non_lues else ".")
37
+
38
+ lines = [f"## {len(items)} alerte(s) de veille\n"]
39
+ for a in items:
40
+ lines.append(f"- **[{a.get('type_veille', '?')}]** {a.get('titre', '—')}")
41
+ if a.get("resume"):
42
+ lines.append(f" {a['resume'][:200]}")
43
+ if a.get("pharmacie_nom"):
44
+ lines.append(f" Pharmacie : {a['pharmacie_nom']}")
45
+ lines.append(f" Détecté le {a.get('date_detection', '?')}")
46
+
47
+ return "\n".join(lines)