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.
- phield_mcp-0.1.0/.gitignore +68 -0
- phield_mcp-0.1.0/PKG-INFO +51 -0
- phield_mcp-0.1.0/README.md +41 -0
- phield_mcp-0.1.0/pyproject.toml +21 -0
- phield_mcp-0.1.0/src/phield_mcp/__init__.py +1 -0
- phield_mcp-0.1.0/src/phield_mcp/client.py +73 -0
- phield_mcp-0.1.0/src/phield_mcp/server.py +35 -0
- phield_mcp-0.1.0/src/phield_mcp/tools/__init__.py +0 -0
- phield_mcp-0.1.0/src/phield_mcp/tools/actions.py +69 -0
- phield_mcp-0.1.0/src/phield_mcp/tools/dashboard.py +76 -0
- phield_mcp-0.1.0/src/phield_mcp/tools/interactions.py +61 -0
- phield_mcp-0.1.0/src/phield_mcp/tools/pharmacies.py +198 -0
- phield_mcp-0.1.0/src/phield_mcp/tools/veille.py +47 -0
|
@@ -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)
|