OCR + reportistica

This commit is contained in:
2026-03-27 13:54:07 +01:00
parent cbeedc2d2f
commit bb2060c1ae
26 changed files with 5503 additions and 237 deletions
-7
View File
@@ -52,14 +52,7 @@ La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i se
Canale WhatsApp: nessuna implementazione reale (stub completo) Canale WhatsApp: nessuna implementazione reale (stub completo)
Canale Email SMTP: nessuna implementazione reale (stub completo) Canale Email SMTP: nessuna implementazione reale (stub completo)
Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente
2. Ricerca avanzata full-text (Fase 5-B completamente mancante)
Non esiste backend/app/api/v1/search.py
Non esiste backend/app/services/search_service.py
Non esiste frontend/src/pages/Search/ ne' frontend/src/api/search.api.ts
La "ricerca" nell'InboxPage usa solo ILIKE su subject/from_address/body_text: e' lenta su volumi grandi e non cerca nel testo degli allegati
La colonna search_vector tsvector non e' nelle migrazioni Alembic attuali (00010007)
Non c'e' Apache Tika e non c'e' worker/app/jobs/index_message.py per l'estrazione testo da PDF/DOCX
3. Archiviazione Sostitutiva (Fase 6 ~15% implementata) 3. Archiviazione Sostitutiva (Fase 6 ~15% implementata)
worker/app/archival/conservatore_client.py esiste (mock + produzione) ma non e' mai chiamato da nessun job reale worker/app/archival/conservatore_client.py esiste (mock + produzione) ma non e' mai chiamato da nessun job reale
+4
View File
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
gcc \ gcc \
libpq-dev \ libpq-dev \
tesseract-ocr \
tesseract-ocr-ita \
tesseract-ocr-eng \
poppler-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+114
View File
@@ -0,0 +1,114 @@
"""
Router Reports Dashboard e Reportistica (Fase 7).
Endpoint:
GET /reports/summary KPI + serie storica + breakdown caselle
GET /reports/export export CSV o PDF
Permessi:
- Tutti gli utenti autenticati possono accedere.
- Admin e super_admin: vedono tutto il tenant.
- Operator/Supervisor/Readonly: vedono solo le caselle su cui hanno can_read.
"""
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query
from fastapi.responses import Response
from app.dependencies import CurrentUser, DB
from app.schemas.reports import ReportSummaryResponse
from app.services.report_service import ReportService
router = APIRouter(prefix="/reports", tags=["Reports"])
async def _get_visible_ids(user, db) -> Optional[list[uuid.UUID]]:
"""Restituisce None per admin (nessun filtro), lista per non-admin."""
if user.is_admin:
return None
from app.services.permission_service import PermissionService
svc = PermissionService(db)
return await svc.get_visible_mailboxes(user)
# ─── GET /reports/summary ─────────────────────────────────────────────────────
@router.get("/summary", response_model=ReportSummaryResponse)
async def get_report_summary(
current_user: CurrentUser,
db: DB,
days: int = Query(7, ge=1, le=365, description="Periodo in giorni per la serie storica"),
) -> ReportSummaryResponse:
"""
Restituisce il riepilogo completo per la dashboard:
- KPI (PEC ricevute/inviate oggi, anomalie, tasso consegna, ...)
- Serie storica giornaliera per il grafico a barre
- Distribuzione stati outbound per il grafico a torta
- Statistiche per casella
"""
visible = await _get_visible_ids(current_user, db)
svc = ReportService(db)
return await svc.get_summary(
tenant_id=current_user.tenant_id,
period_days=days,
visible_mailbox_ids=visible,
)
# ─── GET /reports/export ──────────────────────────────────────────────────────
@router.get("/export")
async def export_report(
current_user: CurrentUser,
db: DB,
format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"),
date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
mailbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per casella specifica"),
) -> Response:
"""
Esporta i dati in formato CSV o PDF.
- CSV: lista completa dei messaggi del periodo con tutti i metadati.
- PDF: riepilogo KPI + tabella caselle (generato con reportlab).
"""
visible = await _get_visible_ids(current_user, db)
svc = ReportService(db)
now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
if format == "csv":
data = await svc.export_csv(
tenant_id=current_user.tenant_id,
date_from=date_from,
date_to=date_to,
mailbox_id=mailbox_id,
visible_mailbox_ids=visible,
)
return Response(
content=data,
media_type="text/csv; charset=utf-8-sig",
headers={
"Content-Disposition": f'attachment; filename="pechub_report_{now_str}.csv"',
"Content-Length": str(len(data)),
},
)
# PDF
data = await svc.export_pdf(
tenant_id=current_user.tenant_id,
date_from=date_from,
date_to=date_to,
visible_mailbox_ids=visible,
)
return Response(
content=data,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="pechub_report_{now_str}.pdf"',
"Content-Length": str(len(data)),
},
)
+289 -11
View File
@@ -1,23 +1,57 @@
""" """
Router Impostazioni Tenant (Fase 6). Router Impostazioni Tenant.
Endpoint: Endpoint esistenti:
GET /settings legge le impostazioni del tenant corrente (admin) GET /settings -> legge le impostazioni del tenant corrente (admin)
PUT /settings aggiorna le impostazioni del tenant corrente (admin) PUT /settings -> aggiorna le impostazioni del tenant corrente (admin)
Solo gli admin e super_admin possono accedere. Endpoint indicizzazione full-text (Fase 8):
La sezione "archiviazione" gestisce il toggle mock/produzione per il GET /settings/indexing/stats -> statistiche copertura indicizzazione
conservatore AgID (Fase 6 Archiviazione Sostitutiva). GET /settings/indexing/status -> stato job reindex in corso
POST /settings/indexing/reindex -> avvia reindex (full o differential)
DELETE /settings/indexing/reindex -> cancella job in corso
Solo admin e super_admin possono accedere.
Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=<uuid>.
""" """
from fastapi import APIRouter import uuid
from typing import Annotated, Optional
from fastapi import APIRouter, HTTPException, Query, status
from app.config import get_settings as get_app_settings
from app.dependencies import AdminUser, DB from app.dependencies import AdminUser, DB
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate from app.schemas.tenant_settings import (
IndexingJobStatus,
IndexingStats,
StartReindexRequest,
StartRescanRequest,
TenantSettingsResponse,
TenantSettingsUpdate,
)
from app.services.indexing_service import IndexingService
from app.services.tenant_settings_service import TenantSettingsService from app.services.tenant_settings_service import TenantSettingsService
router = APIRouter(prefix="/settings", tags=["Impostazioni"]) router = APIRouter(prefix="/settings", tags=["Impostazioni"])
# ─── Helper tenant_id resolution ─────────────────────────────────────────────
def _resolve_tenant_id(
current_user: AdminUser,
tenant_id_param: Optional[uuid.UUID] = None,
) -> uuid.UUID:
"""
Risolve il tenant_id da usare per l'operazione.
- super_admin: puo' passare un tenant_id arbitrario
- admin: usa sempre il proprio tenant_id (tenant_id_param ignorato)
"""
if current_user.role == "super_admin" and tenant_id_param is not None:
return tenant_id_param
return current_user.tenant_id
# ─── Impostazioni generali ────────────────────────────────────────────────────
@router.get( @router.get(
"", "",
@@ -25,7 +59,7 @@ router = APIRouter(prefix="/settings", tags=["Impostazioni"])
summary="Legge le impostazioni del tenant", summary="Legge le impostazioni del tenant",
description=( description=(
"Restituisce la configurazione operativa del tenant: " "Restituisce la configurazione operativa del tenant: "
"modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore." "modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
), ),
) )
async def get_settings( async def get_settings(
@@ -44,7 +78,7 @@ async def get_settings(
description=( description=(
"Aggiorna la configurazione operativa del tenant. " "Aggiorna la configurazione operativa del tenant. "
"Tutti i campi sono opzionali (semantica PATCH). " "Tutti i campi sono opzionali (semantica PATCH). "
"Il passaggio a modalità 'production' richiede un endpoint conservatore configurato." "Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato."
), ),
) )
async def update_settings( async def update_settings(
@@ -57,3 +91,247 @@ async def update_settings(
await db.commit() await db.commit()
await db.refresh(settings) await db.refresh(settings)
return TenantSettingsService.to_response(settings) return TenantSettingsService.to_response(settings)
# ─── Indicizzazione full-text ─────────────────────────────────────────────────
@router.get(
"/indexing/stats",
response_model=IndexingStats,
summary="Statistiche indicizzazione full-text",
description=(
"Restituisce il numero di messaggi totali, indicizzati e non indicizzati "
"per il tenant, con percentuale di copertura. "
"Include anche le statistiche sugli allegati PDF/DOCX con testo estratto. "
"Il super_admin puo' specificare ?tenant_id=<uuid> per un tenant arbitrario."
),
)
async def get_indexing_stats(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingStats:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = IndexingService(db)
stats = await service.get_stats(target_tenant_id)
return IndexingStats(**stats)
@router.get(
"/indexing/status",
response_model=IndexingJobStatus,
summary="Stato job indicizzazione in corso",
description=(
"Restituisce lo stato del job di reindex per il tenant: "
"idle, running (con progresso), completed, failed o cancelled. "
"Se il job e' running da piu' di 2 ore, il flag is_stale e' True."
),
)
async def get_indexing_status(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingJobStatus:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
app_settings = get_app_settings()
status_data = await IndexingService.get_job_status(
target_tenant_id, app_settings.redis_url
)
return IndexingJobStatus(**status_data)
@router.post(
"/indexing/reindex",
response_model=IndexingJobStatus,
status_code=status.HTTP_202_ACCEPTED,
summary="Avvia job di reindex",
description=(
"Avvia un job di reindex full-text in background. "
"mode='differential' indicizza solo i messaggi con search_vector NULL (piu' veloce). "
"mode='full' riscrive il vettore di tutti i messaggi del tenant. "
"Restituisce 409 se un job e' gia' in corso."
),
)
async def start_reindex(
body: StartReindexRequest,
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingJobStatus:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
app_settings = get_app_settings()
try:
await IndexingService.start_reindex(
tenant_id=target_tenant_id,
mode=body.mode,
started_by_email=current_user.email,
redis_url=app_settings.redis_url,
db_url=app_settings.database_url,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
# Ritorna lo stato appena creato
status_data = await IndexingService.get_job_status(
target_tenant_id, app_settings.redis_url
)
return IndexingJobStatus(**status_data)
@router.delete(
"/indexing/reindex",
response_model=IndexingJobStatus,
summary="Cancella job di reindex in corso",
description=(
"Invia il segnale di cancellazione al job di reindex in corso. "
"Il job si fermera' alla fine del batch corrente (max qualche secondo). "
"Se non c'e' nessun job in corso, ritorna 404."
),
)
async def cancel_reindex(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingJobStatus:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
app_settings = get_app_settings()
cancelled = await IndexingService.cancel_reindex(
target_tenant_id, app_settings.redis_url
)
if not cancelled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Nessun job di reindex in corso per questo tenant",
)
status_data = await IndexingService.get_job_status(
target_tenant_id, app_settings.redis_url
)
return IndexingJobStatus(**status_data)
# ─── Scansione allegati ───────────────────────────────────────────────────────
@router.get(
"/indexing/rescan-status",
response_model=IndexingJobStatus,
summary="Stato job scansione allegati in corso",
description=(
"Restituisce lo stato del job di scansione allegati per il tenant: "
"idle, running (con progresso), completed, failed o cancelled."
),
)
async def get_rescan_status(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingJobStatus:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
app_settings = get_app_settings()
status_data = await IndexingService.get_rescan_status(
target_tenant_id, app_settings.redis_url
)
return IndexingJobStatus(**status_data)
@router.post(
"/indexing/rescan",
response_model=IndexingJobStatus,
status_code=status.HTTP_202_ACCEPTED,
summary="Avvia job di scansione allegati",
description=(
"Avvia un job di scansione allegati in background. "
"force=false (default): estrae il testo solo dagli allegati non ancora elaborati. "
"force=true: ri-estrae il testo da tutti gli allegati del tenant. "
"Al termine di ogni batch aggiorna anche il search_vector dei messaggi interessati. "
"Restituisce 409 se un job di scansione o reindex e' gia' in corso."
),
)
async def start_rescan(
body: StartRescanRequest,
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingJobStatus:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
app_settings = get_app_settings()
try:
await IndexingService.start_rescan(
tenant_id=target_tenant_id,
started_by_email=current_user.email,
redis_url=app_settings.redis_url,
db_url=app_settings.database_url,
force=body.force,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
status_data = await IndexingService.get_rescan_status(
target_tenant_id, app_settings.redis_url
)
return IndexingJobStatus(**status_data)
@router.delete(
"/indexing/rescan",
response_model=IndexingJobStatus,
summary="Cancella job di scansione allegati in corso",
description=(
"Invia il segnale di cancellazione al job di scansione allegati in corso. "
"Il job si fermera' alla fine del batch corrente. "
"Se non c'e' nessun job in corso, ritorna 404."
),
)
async def cancel_rescan(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> IndexingJobStatus:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
app_settings = get_app_settings()
cancelled = await IndexingService.cancel_rescan(
target_tenant_id, app_settings.redis_url
)
if not cancelled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Nessun job di scansione allegati in corso per questo tenant",
)
status_data = await IndexingService.get_rescan_status(
target_tenant_id, app_settings.redis_url
)
return IndexingJobStatus(**status_data)
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router from app.api.v1 import settings as settings_router
from app.config import get_settings from app.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
@@ -96,6 +96,7 @@ app.include_router(virtual_boxes.router, prefix=API_PREFIX)
app.include_router(notifications.router, prefix=API_PREFIX) app.include_router(notifications.router, prefix=API_PREFIX)
app.include_router(labels.router, prefix=API_PREFIX) app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX) app.include_router(settings_router.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
# ─── Health check ───────────────────────────────────────────────────────────── # ─── Health check ─────────────────────────────────────────────────────────────
+75
View File
@@ -0,0 +1,75 @@
"""
Schemi Pydantic per la Dashboard e Reportistica (Fase 7).
"""
from datetime import date, datetime
from typing import Optional
import uuid
from pydantic import BaseModel, Field
class KpiSummary(BaseModel):
"""Contatori KPI principali del tenant."""
# Oggi
received_today: int = Field(0, description="PEC ricevute oggi")
sent_today: int = Field(0, description="PEC inviate oggi (outbound)")
# Ultimi 7 giorni
received_7d: int = Field(0, description="PEC ricevute negli ultimi 7 giorni")
sent_7d: int = Field(0, description="PEC inviate negli ultimi 7 giorni")
# Ultimi 30 giorni
received_30d: int = Field(0, description="PEC ricevute negli ultimi 30 giorni")
sent_30d: int = Field(0, description="PEC inviate negli ultimi 30 giorni")
# Stato
anomalie_attive: int = Field(0, description="Messaggi outbound in stato anomaly")
tasso_consegna: float = Field(0.0, description="Percentuale consegna (0-100)")
caselle_in_errore: int = Field(0, description="Caselle con status=error")
messaggi_non_letti: int = Field(0, description="Messaggi inbound non letti")
# Totali assoluti
totale_messaggi: int = Field(0, description="Totale messaggi nel tenant")
class DailyStat(BaseModel):
"""Statistiche giornaliere per il grafico a barre."""
day: date = Field(..., description="Data (YYYY-MM-DD)")
received: int = Field(0, description="PEC ricevute in quel giorno")
sent: int = Field(0, description="PEC inviate in quel giorno")
class OutboundStateStat(BaseModel):
"""Conteggio messaggi outbound per stato (per il grafico a torta)."""
state: str
count: int
class MailboxStat(BaseModel):
"""Statistiche per singola casella."""
mailbox_id: uuid.UUID
email_address: str
display_name: Optional[str] = None
status: str
received_total: int = 0
sent_total: int = 0
anomalie: int = 0
non_letti: int = 0
last_sync_at: Optional[datetime] = None
class ReportSummaryResponse(BaseModel):
"""Risposta completa dell'endpoint /reports/summary."""
generated_at: datetime
period_days: int = Field(..., description="Numero di giorni del periodo selezionato")
kpi: KpiSummary
daily_stats: list[DailyStat] = Field(default_factory=list)
outbound_states: list[OutboundStateStat] = Field(default_factory=list)
mailbox_stats: list[MailboxStat] = Field(default_factory=list)
+53 -2
View File
@@ -1,12 +1,13 @@
""" """
Schema Pydantic per TenantSettings lettura e aggiornamento impostazioni tenant. Schema Pydantic per TenantSettings lettura e aggiornamento impostazioni tenant.
Include schemi per il modulo di indicizzazione full-text.
""" """
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal, Optional
from pydantic import BaseModel, Field, HttpUrl, field_validator from pydantic import BaseModel, Field, field_validator
ArchivalMode = Literal["mock", "production"] ArchivalMode = Literal["mock", "production"]
@@ -68,3 +69,53 @@ class TenantSettingsUpdate(BaseModel):
if v is not None and v not in ("mock", "production"): if v is not None and v not in ("mock", "production"):
raise ValueError("archival_mode deve essere 'mock' o 'production'") raise ValueError("archival_mode deve essere 'mock' o 'production'")
return v return v
# ─── Schemi indicizzazione full-text ──────────────────────────────────────────
class IndexingStats(BaseModel):
"""Statistiche di copertura dell'indicizzazione per un tenant."""
total_messages: int
indexed_messages: int
unindexed_messages: int
coverage_pct: float # percentuale messaggi con search_vector != NULL
attachments_total: int # allegati PDF/DOCX totali
attachments_extracted: int # allegati con testo estratto
attachments_pct: float # percentuale allegati con testo estratto
class IndexingJobStatus(BaseModel):
"""Stato di un job di reindex in corso o completato."""
status: str # idle | running | completed | failed | cancelled
mode: Optional[str] = None # full | differential
total: int = 0
processed: int = 0
progress_pct: float = 0.0
started_at: Optional[str] = None # ISO datetime
finished_at: Optional[str] = None # ISO datetime
started_by: Optional[str] = None # email utente che ha avviato il job
elapsed_seconds: Optional[int] = None
is_stale: bool = False # True se running da piu' di STALE_THRESHOLD_HOURS
error: Optional[str] = None
class StartReindexRequest(BaseModel):
"""Body per POST /settings/indexing/reindex."""
mode: Literal["full", "differential"] = "differential"
class StartRescanRequest(BaseModel):
"""Body per POST /settings/indexing/rescan."""
force: bool = Field(
default=False,
description=(
"False (default): estrae solo allegati con extracted_text NULL. "
"True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti."
),
)
File diff suppressed because it is too large Load Diff
+594
View File
@@ -0,0 +1,594 @@
"""
ReportService calcola KPI, serie storiche e produce export CSV/PDF.
Non richiede migrazioni: lavora sulle tabelle messages e mailboxes esistenti.
"""
import csv
import io
import uuid
from datetime import date, datetime, timedelta, timezone
from typing import AsyncGenerator, Optional
from sqlalchemy import case, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models.mailbox import Mailbox
from app.models.message import Message
from app.schemas.reports import (
DailyStat,
KpiSummary,
MailboxStat,
OutboundStateStat,
ReportSummaryResponse,
)
logger = get_logger(__name__)
class ReportService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── KPI principali ──────────────────────────────────────────────────────
async def get_summary(
self,
tenant_id: uuid.UUID,
period_days: int = 7,
visible_mailbox_ids: Optional[list[uuid.UUID]] = None,
) -> ReportSummaryResponse:
"""
Restituisce il riepilogo completo per la dashboard.
visible_mailbox_ids: se None l'utente e admin e vede tutto il tenant,
altrimenti filtra sulle caselle accessibili.
"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
d7_start = now - timedelta(days=7)
d30_start = now - timedelta(days=30)
# ── Filtro base tenant + caselle visibili ────────────────────────────
def _base(q):
q = q.where(Message.tenant_id == tenant_id)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
# Nessuna casella visibile: ritorna subito valori zero
return None
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
return q
async def _count(q) -> int:
q = _base(q)
if q is None:
return 0
r = await self.db.execute(q)
return r.scalar_one() or 0
# PEC ricevute
received_today = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.received_at >= today_start,
)
)
received_7d = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.received_at >= d7_start,
)
)
received_30d = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.received_at >= d30_start,
)
)
# PEC inviate
sent_today = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= today_start,
)
)
sent_7d = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= d7_start,
)
)
sent_30d = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= d30_start,
)
)
# Anomalie (outbound con state=anomaly, senza genitore)
anomalie = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.state == "anomaly",
)
)
# Tasso consegna: delivered / (delivered + anomaly + failed)
delivered = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.state == "delivered",
)
)
failed = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.state.in_(["anomaly", "failed"]),
)
)
total_terminal = delivered + failed
tasso_consegna = round((delivered / total_terminal * 100), 1) if total_terminal > 0 else 0.0
# Non letti
non_letti = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.is_read == False, # noqa: E712
Message.is_trashed == False, # noqa: E712
)
)
# Totale messaggi
totale = await _count(select(func.count(Message.id)))
# Caselle in errore (NON filtrato per visible_mailbox_ids e una info admin)
caselle_errore_r = await self.db.execute(
select(func.count(Mailbox.id)).where(
Mailbox.tenant_id == tenant_id,
Mailbox.status == "error",
)
)
caselle_errore = caselle_errore_r.scalar_one() or 0
kpi = KpiSummary(
received_today=received_today,
sent_today=sent_today,
received_7d=received_7d,
sent_7d=sent_7d,
received_30d=received_30d,
sent_30d=sent_30d,
anomalie_attive=anomalie,
tasso_consegna=tasso_consegna,
caselle_in_errore=caselle_errore,
messaggi_non_letti=non_letti,
totale_messaggi=totale,
)
# ── Serie storica giornaliera ─────────────────────────────────────────
daily_stats = await self._get_daily_stats(tenant_id, period_days, visible_mailbox_ids)
# ── Distribuzione stati outbound ──────────────────────────────────────
outbound_states = await self._get_outbound_states(tenant_id, visible_mailbox_ids)
# ── Statistiche per casella ───────────────────────────────────────────
mailbox_stats = await self._get_mailbox_stats(tenant_id, visible_mailbox_ids)
return ReportSummaryResponse(
generated_at=now,
period_days=period_days,
kpi=kpi,
daily_stats=daily_stats,
outbound_states=outbound_states,
mailbox_stats=mailbox_stats,
)
# ─── Serie storica ────────────────────────────────────────────────────────
async def _get_daily_stats(
self,
tenant_id: uuid.UUID,
days: int,
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> list[DailyStat]:
"""Conta PEC ricevute e inviate per ciascuno degli ultimi `days` giorni."""
since = datetime.now(timezone.utc) - timedelta(days=days)
def _apply_filters(q):
q = q.where(Message.tenant_id == tenant_id)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return None
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
return q
# Aggregazione ricevute per giorno
q_recv = (
select(
func.date_trunc("day", Message.received_at).label("day"),
func.count(Message.id).label("cnt"),
)
.where(
Message.direction == "inbound",
Message.received_at >= since,
)
.group_by(text("day"))
.order_by(text("day"))
)
q_recv = _apply_filters(q_recv)
# Aggregazione inviate per giorno
q_sent = (
select(
func.date_trunc("day", Message.sent_at).label("day"),
func.count(Message.id).label("cnt"),
)
.where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= since,
)
.group_by(text("day"))
.order_by(text("day"))
)
q_sent = _apply_filters(q_sent)
recv_map: dict[date, int] = {}
sent_map: dict[date, int] = {}
if q_recv is not None:
r = await self.db.execute(q_recv)
for row in r.all():
if row.day:
d = row.day.date() if hasattr(row.day, "date") else row.day
recv_map[d] = row.cnt
if q_sent is not None:
r = await self.db.execute(q_sent)
for row in r.all():
if row.day:
d = row.day.date() if hasattr(row.day, "date") else row.day
sent_map[d] = row.cnt
# Costruisce la serie completa (tutti i giorni, anche quelli a zero)
result: list[DailyStat] = []
for i in range(days, -1, -1):
d = (datetime.now(timezone.utc) - timedelta(days=i)).date()
result.append(DailyStat(
day=d,
received=recv_map.get(d, 0),
sent=sent_map.get(d, 0),
))
return result
# ─── Distribuzione stati outbound ────────────────────────────────────────
async def _get_outbound_states(
self,
tenant_id: uuid.UUID,
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> list[OutboundStateStat]:
q = (
select(Message.state, func.count(Message.id).label("cnt"))
.where(
Message.tenant_id == tenant_id,
Message.direction == "outbound",
Message.parent_message_id.is_(None),
)
.group_by(Message.state)
)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return []
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
r = await self.db.execute(q)
return [OutboundStateStat(state=row.state, count=row.cnt) for row in r.all()]
# ─── Statistiche per casella ──────────────────────────────────────────────
async def _get_mailbox_stats(
self,
tenant_id: uuid.UUID,
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> list[MailboxStat]:
# Carica le caselle
mb_q = select(Mailbox).where(Mailbox.tenant_id == tenant_id)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return []
mb_q = mb_q.where(Mailbox.id.in_(visible_mailbox_ids))
mb_result = await self.db.execute(mb_q)
mailboxes = mb_result.scalars().all()
if not mailboxes:
return []
mailbox_ids = [m.id for m in mailboxes]
mailbox_map = {m.id: m for m in mailboxes}
# Aggregazione messaggi per casella e direction
agg_q = (
select(
Message.mailbox_id,
Message.direction,
Message.state,
Message.is_read,
func.count(Message.id).label("cnt"),
)
.where(
Message.tenant_id == tenant_id,
Message.mailbox_id.in_(mailbox_ids),
Message.parent_message_id.is_(None),
)
.group_by(Message.mailbox_id, Message.direction, Message.state, Message.is_read)
)
agg_result = await self.db.execute(agg_q)
# Accumula per casella
stats: dict[uuid.UUID, MailboxStat] = {}
for mb in mailboxes:
stats[mb.id] = MailboxStat(
mailbox_id=mb.id,
email_address=mb.email_address,
display_name=mb.display_name,
status=mb.status,
last_sync_at=mb.last_sync_at,
)
for row in agg_result.all():
s = stats.get(row.mailbox_id)
if not s:
continue
if row.direction == "inbound":
s.received_total += row.cnt
if not row.is_read:
s.non_letti += row.cnt
elif row.direction == "outbound":
s.sent_total += row.cnt
if row.state == "anomaly":
s.anomalie += row.cnt
# Ordina per volume decrescente
return sorted(
stats.values(),
key=lambda x: x.received_total + x.sent_total,
reverse=True,
)
# ─── Export CSV ───────────────────────────────────────────────────────────
async def export_csv(
self,
tenant_id: uuid.UUID,
date_from: Optional[datetime],
date_to: Optional[datetime],
mailbox_id: Optional[uuid.UUID],
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> bytes:
"""Genera un CSV con tutti i messaggi del periodo."""
q = (
select(
Message.id,
Message.direction,
Message.state,
Message.pec_type,
Message.subject,
Message.from_address,
Message.received_at,
Message.sent_at,
Message.size_bytes,
Message.is_read,
Message.has_attachments,
Mailbox.email_address.label("mailbox_email"),
)
.join(Mailbox, Message.mailbox_id == Mailbox.id)
.where(
Message.tenant_id == tenant_id,
Message.parent_message_id.is_(None),
)
.order_by(Message.received_at.desc().nullslast(), Message.created_at.desc())
)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_HEADERS)
return buf.getvalue().encode("utf-8-sig")
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
if mailbox_id:
q = q.where(Message.mailbox_id == mailbox_id)
if date_from:
q = q.where(
(Message.received_at >= date_from) | (Message.sent_at >= date_from)
)
if date_to:
q = q.where(
(Message.received_at <= date_to) | (Message.sent_at <= date_to)
)
result = await self.db.execute(q)
rows = result.all()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_HEADERS)
for r in rows:
ts = r.received_at or r.sent_at
writer.writerow([
str(r.id),
r.mailbox_email or "",
r.direction or "",
r.state or "",
r.pec_type or "",
r.subject or "",
r.from_address or "",
ts.strftime("%Y-%m-%d %H:%M:%S") if ts else "",
r.size_bytes or "",
"Si" if r.is_read else "No",
"Si" if r.has_attachments else "No",
])
return buf.getvalue().encode("utf-8-sig")
# ─── Export PDF ───────────────────────────────────────────────────────────
async def export_pdf(
self,
tenant_id: uuid.UUID,
date_from: Optional[datetime],
date_to: Optional[datetime],
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> bytes:
"""
Genera un PDF di riepilogo con KPI e tabella caselle.
Usa reportlab (puro Python, nessuna dipendenza di sistema).
"""
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import (
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
)
except ImportError:
raise RuntimeError(
"reportlab non installato. Aggiungere 'reportlab>=4.2.0' "
"alle dipendenze del backend."
)
summary = await self.get_summary(tenant_id, 30, visible_mailbox_ids)
now_str = summary.generated_at.strftime("%d/%m/%Y %H:%M")
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"Title", parent=styles["Title"], fontSize=18, spaceAfter=6,
)
subtitle_style = ParagraphStyle(
"Subtitle", parent=styles["Normal"], fontSize=10, textColor=colors.grey, spaceAfter=20,
)
heading_style = ParagraphStyle(
"Heading2", parent=styles["Heading2"], fontSize=13, spaceBefore=14, spaceAfter=6,
)
story = []
# Intestazione
story.append(Paragraph("PEChub Report Attivita PEC", title_style))
date_range = ""
if date_from:
date_range += f"Dal {date_from.strftime('%d/%m/%Y')} "
if date_to:
date_range += f"Al {date_to.strftime('%d/%m/%Y')} "
story.append(Paragraph(
f"Generato il {now_str} {date_range}",
subtitle_style,
))
# Sezione KPI
story.append(Paragraph("Indicatori Chiave (ultimi 30 giorni)", heading_style))
kpi = summary.kpi
kpi_data = [
["Indicatore", "Valore"],
["PEC ricevute oggi", str(kpi.received_today)],
["PEC inviate oggi", str(kpi.sent_today)],
["PEC ricevute (7 gg)", str(kpi.received_7d)],
["PEC inviate (7 gg)", str(kpi.sent_7d)],
["PEC ricevute (30 gg)", str(kpi.received_30d)],
["PEC inviate (30 gg)", str(kpi.sent_30d)],
["Anomalie attive", str(kpi.anomalie_attive)],
["Tasso di consegna", f"{kpi.tasso_consegna}%"],
["Caselle in errore", str(kpi.caselle_in_errore)],
["Messaggi non letti", str(kpi.messaggi_non_letti)],
]
kpi_table = Table(kpi_data, colWidths=[10 * cm, 5 * cm])
kpi_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ALIGN", (1, 0), (1, -1), "RIGHT"),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
story.append(kpi_table)
story.append(Spacer(1, 0.5 * cm))
# Sezione caselle
if summary.mailbox_stats:
story.append(Paragraph("Dettaglio per Casella", heading_style))
mb_header = ["Casella", "Stato", "Ricevute", "Inviate", "Anomalie", "Non letti"]
mb_data = [mb_header]
for ms in summary.mailbox_stats:
mb_data.append([
ms.email_address,
ms.status,
str(ms.received_total),
str(ms.sent_total),
str(ms.anomalie),
str(ms.non_letti),
])
mb_table = Table(
mb_data,
colWidths=[6.5 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm],
)
mb_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]))
story.append(mb_table)
doc.build(story)
return buf.getvalue()
# ─── Costanti ────────────────────────────────────────────────────────────────
CSV_HEADERS = [
"ID",
"Casella",
"Direzione",
"Stato",
"Tipo PEC",
"Oggetto",
"Mittente",
"Data/Ora",
"Dimensione (byte)",
"Letto",
"Allegati",
]
+12
View File
@@ -46,6 +46,15 @@ dependencies = [
# Storage MinIO/S3 # Storage MinIO/S3
"miniopy-async>=1.21.0", "miniopy-async>=1.21.0",
# Estrazione testo allegati (usato anche dal job rescan nel backend)
"pypdf>=4.0.0",
"python-docx>=1.1.0",
# OCR per allegati image-only (immagini dirette e PDF scansionati)
"pytesseract>=0.3.13",
"pdf2image>=1.17.0",
"Pillow>=11.0.0",
# IMAP async (per test connessione nel backend + mailbox service) # IMAP async (per test connessione nel backend + mailbox service)
"aioimaplib>=2.0.0", "aioimaplib>=2.0.0",
@@ -58,6 +67,9 @@ dependencies = [
# Utilities # Utilities
"python-multipart>=0.0.9", # upload file "python-multipart>=0.0.9", # upload file
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
# Generazione PDF report (puro Python, nessuna dipendenza di sistema)
"reportlab>=4.2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+345 -2
View File
@@ -1,5 +1,5 @@
{ {
"name": "pecflow-frontend", "name": "pechub-frontend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
@@ -251,6 +251,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2797,6 +2806,69 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3378,6 +3450,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -3406,6 +3599,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3445,6 +3644,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3601,6 +3810,12 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -3861,6 +4076,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4000,6 +4224,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4184,7 +4414,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4465,6 +4694,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": { "node_modules/prosemirror-changeset": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@@ -4763,6 +5009,12 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4852,6 +5104,21 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -4874,6 +5141,22 @@
} }
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4897,6 +5180,38 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5161,6 +5476,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -5396,6 +5717,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+347 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "pecflow-frontend", "name": "pechub-frontend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pecflow-frontend", "name": "pechub-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
@@ -42,6 +42,7 @@
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"recharts": "^2.13.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
@@ -306,6 +307,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -3548,6 +3558,69 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4129,6 +4202,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -4157,6 +4351,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -4196,6 +4396,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4352,6 +4562,12 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4627,6 +4843,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4766,6 +4991,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4950,7 +5181,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5231,6 +5461,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": { "node_modules/prosemirror-changeset": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@@ -5529,6 +5776,12 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5618,6 +5871,21 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -5640,6 +5908,22 @@
} }
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -5663,6 +5947,38 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5927,6 +6243,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6162,6 +6484,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -48,6 +48,7 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"recharts": "^2.13.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
+4
View File
@@ -12,6 +12,7 @@ import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage' import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage' import { SearchPage } from '@/pages/Search/SearchPage'
import { ReportsPage } from '@/pages/Reports/ReportsPage'
/** /**
* Routing principale dell'applicazione PEChub. * Routing principale dell'applicazione PEChub.
@@ -80,6 +81,9 @@ export default function App() {
{/* Ricerca avanzata full-text */} {/* Ricerca avanzata full-text */}
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />
{/* Dashboard e Reportistica */}
<Route path="/reports" element={<ReportsPage />} />
{/* Profilo utente */} {/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
+99
View File
@@ -0,0 +1,99 @@
/**
* API client per la Dashboard e Reportistica (Fase 7).
*/
import { apiClient } from './client'
// ─── Tipi ─────────────────────────────────────────────────────────────────────
export interface KpiSummary {
received_today: number
sent_today: number
received_7d: number
sent_7d: number
received_30d: number
sent_30d: number
anomalie_attive: number
tasso_consegna: number
caselle_in_errore: number
messaggi_non_letti: number
totale_messaggi: number
}
export interface DailyStat {
day: string // "YYYY-MM-DD"
received: number
sent: number
}
export interface OutboundStateStat {
state: string
count: number
}
export interface MailboxStat {
mailbox_id: string
email_address: string
display_name: string | null
status: string
received_total: number
sent_total: number
anomalie: number
non_letti: number
last_sync_at: string | null
}
export interface ReportSummaryResponse {
generated_at: string
period_days: number
kpi: KpiSummary
daily_stats: DailyStat[]
outbound_states: OutboundStateStat[]
mailbox_stats: MailboxStat[]
}
// ─── API ──────────────────────────────────────────────────────────────────────
export const reportsApi = {
/**
* Recupera il riepilogo KPI + grafici per la dashboard.
* @param days Numero di giorni per la serie storica (default 7)
*/
getSummary: async (days = 7): Promise<ReportSummaryResponse> => {
const res = await apiClient.get<ReportSummaryResponse>('/reports/summary', {
params: { days },
})
return res.data
},
/**
* Scarica il report in formato CSV.
* Il browser riceverà un file da scaricare.
*/
exportCsv: (params?: {
date_from?: string
date_to?: string
mailbox_id?: string
}) => {
const url = new URL('/api/v1/reports/export', window.location.origin)
url.searchParams.set('format', 'csv')
if (params?.date_from) url.searchParams.set('date_from', params.date_from)
if (params?.date_to) url.searchParams.set('date_to', params.date_to)
if (params?.mailbox_id) url.searchParams.set('mailbox_id', params.mailbox_id)
return url.toString()
},
/**
* Scarica il report in formato PDF.
*/
exportPdf: (params?: {
date_from?: string
date_to?: string
}) => {
const url = new URL('/api/v1/reports/export', window.location.origin)
url.searchParams.set('format', 'pdf')
if (params?.date_from) url.searchParams.set('date_from', params.date_from)
if (params?.date_to) url.searchParams.set('date_to', params.date_to)
return url.toString()
},
}
+132 -4
View File
@@ -2,13 +2,17 @@
* API client per le impostazioni del tenant. * API client per le impostazioni del tenant.
* *
* Endpoint: * Endpoint:
* GET /api/v1/settings legge configurazione (admin) * GET /api/v1/settings -> legge configurazione (admin)
* PUT /api/v1/settings aggiorna configurazione (admin) * PUT /api/v1/settings -> aggiorna configurazione (admin)
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
* GET /api/v1/settings/indexing/status -> stato job reindex
* POST /api/v1/settings/indexing/reindex -> avvia reindex
* DELETE /api/v1/settings/indexing/reindex -> cancella reindex
*/ */
import { apiClient } from './client' import { apiClient } from './client'
// ─── Tipi ────────────────────────────────────────────────────────────────── // ─── Tipi impostazioni generali ────────────────────────────────────────────
export type ArchivalMode = 'mock' | 'production' export type ArchivalMode = 'mock' | 'production'
@@ -37,7 +41,38 @@ export interface TenantSettingsUpdate {
archival_notes?: string archival_notes?: string
} }
// ─── Client ──────────────────────────────────────────────────────────────── // ─── Tipi indicizzazione full-text ─────────────────────────────────────────
export interface IndexingStats {
total_messages: number
indexed_messages: number
unindexed_messages: number
coverage_pct: number // 0-100
attachments_total: number
attachments_extracted: number
attachments_pct: number // 0-100
}
export type ReindexMode = 'full' | 'differential'
export type JobStatus = 'idle' | 'running' | 'completed' | 'failed' | 'cancelled'
export interface IndexingJobStatus {
status: JobStatus
mode: ReindexMode | null
total: number
processed: number
progress_pct: number
started_at: string | null // ISO datetime
finished_at: string | null // ISO datetime
started_by: string | null // email
elapsed_seconds: number | null
is_stale: boolean // running da piu' di 2 ore
error: string | null
}
// ─── Client impostazioni generali ──────────────────────────────────────────
export const settingsApi = { export const settingsApi = {
/** /**
@@ -57,4 +92,97 @@ export const settingsApi = {
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload) const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
return data return data
}, },
// ── Indicizzazione full-text ──────────────────────────────────────────────
/**
* Restituisce le statistiche di copertura dell'indicizzazione.
* @param tenantId - (solo super_admin) UUID del tenant target
*/
getIndexingStats: async (tenantId?: string): Promise<IndexingStats> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<IndexingStats>('/settings/indexing/stats', { params })
return data
},
/**
* Restituisce lo stato del job di reindex in corso (o idle se nessuno).
* @param tenantId - (solo super_admin) UUID del tenant target
*/
getIndexingStatus: async (tenantId?: string): Promise<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<IndexingJobStatus>('/settings/indexing/status', { params })
return data
},
/**
* Avvia un job di reindex in background.
* @param mode - 'differential' (solo NULL) o 'full' (tutti i messaggi)
* @param tenantId - (solo super_admin) UUID del tenant target
*/
startReindex: async (mode: ReindexMode, tenantId?: string): Promise<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.post<IndexingJobStatus>(
'/settings/indexing/reindex',
{ mode },
{ params }
)
return data
},
/**
* Cancella il job di reindex in corso.
* @param tenantId - (solo super_admin) UUID del tenant target
*/
cancelReindex: async (tenantId?: string): Promise<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.delete<IndexingJobStatus>(
'/settings/indexing/reindex',
{ params }
)
return data
},
// ── Scansione allegati ────────────────────────────────────────────────────
/**
* Restituisce lo stato del job di scansione allegati (idle se nessuno).
* @param tenantId - (solo super_admin) UUID del tenant target
*/
getRescanStatus: async (tenantId?: string): Promise<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<IndexingJobStatus>(
'/settings/indexing/rescan-status',
{ params }
)
return data
},
/**
* Avvia un job di scansione allegati in background.
* @param force - false: solo allegati senza testo estratto; true: tutti
* @param tenantId - (solo super_admin) UUID del tenant target
*/
startRescan: async (force: boolean = false, tenantId?: string): Promise<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.post<IndexingJobStatus>(
'/settings/indexing/rescan',
{ force },
{ params }
)
return data
},
/**
* Cancella il job di scansione allegati in corso.
* @param tenantId - (solo super_admin) UUID del tenant target
*/
cancelRescan: async (tenantId?: string): Promise<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.delete<IndexingJobStatus>(
'/settings/indexing/rescan',
{ params }
)
return data
},
} }
+19 -2
View File
@@ -51,6 +51,7 @@ import {
Building2, Building2,
Trash2, Trash2,
Search, Search,
BarChart2,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
@@ -344,10 +345,10 @@ export function Sidebar() {
</div> </div>
)} )}
{/* ── Ricerca avanzata ── */} {/* ── Ricerca avanzata + Dashboard ── */}
<div> <div>
<div className="border-t border-gray-700 mx-4 mb-3" /> <div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2"> <div className="px-2 space-y-0.5">
<NavLink <NavLink
to="/search" to="/search"
className={({ isActive }) => className={({ isActive }) =>
@@ -364,6 +365,22 @@ export function Sidebar() {
<Search className="h-4 w-4 flex-shrink-0" /> <Search className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Ricerca</span>} {!collapsed && <span>Ricerca</span>}
</NavLink> </NavLink>
<NavLink
to="/reports"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Dashboard / Report' : undefined}
>
<BarChart2 className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Dashboard</span>}
</NavLink>
</div> </div>
</div> </div>
+528
View File
@@ -0,0 +1,528 @@
/**
* ReportsPage Dashboard e Reportistica (Fase 7).
*
* Visualizza:
* - 6 KPI cards (ricevute oggi, inviate oggi, anomalie, tasso consegna,
* caselle in errore, messaggi non letti)
* - Selettore periodo (7 / 30 giorni)
* - Grafico a barre: PEC ricevute vs inviate per giorno
* - Grafico a torta: distribuzione stati messaggi outbound
* - Tabella: dettaglio per casella
* - Pulsanti export CSV e PDF
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
ResponsiveContainer,
} from 'recharts'
import {
Inbox,
Send,
AlertTriangle,
CheckCircle2,
ServerCrash,
MailOpen,
Download,
FileText,
RefreshCw,
BarChart2,
} from 'lucide-react'
import { format, parseISO } from 'date-fns'
import { it } from 'date-fns/locale'
import { reportsApi } from '@/api/reports.api'
import { cn } from '@/lib/utils'
// ─── Costanti ─────────────────────────────────────────────────────────────────
const PERIOD_OPTIONS = [
{ label: '7 giorni', value: 7 },
{ label: '30 giorni', value: 30 },
{ label: '90 giorni', value: 90 },
]
const STATE_COLORS: Record<string, string> = {
delivered: '#22c55e',
accepted: '#3b82f6',
sent: '#8b5cf6',
anomaly: '#ef4444',
failed: '#dc2626',
queued: '#f59e0b',
draft: '#6b7280',
unknown: '#9ca3af',
}
const STATE_LABELS: Record<string, string> = {
delivered: 'Consegnata',
accepted: 'Accettata',
sent: 'Inviata',
anomaly: 'Anomalia',
failed: 'Fallita',
queued: 'In coda',
draft: 'Bozza',
unknown: 'Sconosciuto',
}
const STATUS_BADGE: Record<string, string> = {
active: 'bg-green-100 text-green-800',
paused: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
deleted: 'bg-gray-100 text-gray-600',
}
// ─── Componente KPI card ──────────────────────────────────────────────────────
interface KpiCardProps {
title: string
value: string | number
subtitle?: string
icon: React.ReactNode
color: string
alert?: boolean
}
function KpiCard({ title, value, subtitle, icon, color, alert }: KpiCardProps) {
return (
<div
className={cn(
'bg-white rounded-xl border p-5 flex items-start gap-4',
alert && 'border-red-300 bg-red-50',
)}
>
<div className={cn('p-2.5 rounded-lg flex-shrink-0', color)}>
{icon}
</div>
<div className="min-w-0">
<p className="text-sm text-gray-500 font-medium truncate">{title}</p>
<p className={cn('text-2xl font-bold mt-0.5', alert ? 'text-red-700' : 'text-gray-900')}>
{value}
</p>
{subtitle && (
<p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>
)}
</div>
</div>
)
}
// ─── Tooltip grafico barre ────────────────────────────────────────────────────
function CustomBarTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
<p className="font-semibold text-gray-700 mb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.dataKey} style={{ color: p.fill }}>
{p.name}: <strong>{p.value}</strong>
</p>
))}
</div>
)
}
// ─── Tooltip grafico torta ────────────────────────────────────────────────────
function CustomPieTooltip({ active, payload }: any) {
if (!active || !payload?.length) return null
const item = payload[0]
return (
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
<p className="font-semibold" style={{ color: item.payload.fill }}>
{STATE_LABELS[item.name] ?? item.name}
</p>
<p className="text-gray-700">Totale: <strong>{item.value}</strong></p>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function ReportsPage() {
const [days, setDays] = useState(7)
const { data, isLoading, isError, refetch, isFetching } = useQuery({
queryKey: ['reports-summary', days],
queryFn: () => reportsApi.getSummary(days),
staleTime: 2 * 60 * 1000,
refetchOnWindowFocus: false,
})
// ── Formatta le date per l'asse X del grafico ────────────────────────────
const chartData = (data?.daily_stats ?? []).map((s) => ({
giorno: format(parseISO(s.day), 'dd/MM', { locale: it }),
Ricevute: s.received,
Inviate: s.sent,
}))
const pieData = (data?.outbound_states ?? [])
.filter((s) => s.count > 0)
.map((s) => ({
name: s.state,
value: s.count,
fill: STATE_COLORS[s.state] ?? '#9ca3af',
}))
const kpi = data?.kpi
const generatedAt = data?.generated_at
? format(parseISO(data.generated_at), "dd/MM/yyyy HH:mm", { locale: it })
: null
// ── URL export con token auth (il browser apre con i cookie di sessione) ──
const handleExport = (format: 'csv' | 'pdf') => {
const url = format === 'csv'
? reportsApi.exportCsv()
: reportsApi.exportPdf()
// Apre il link in una nuova scheda; il token JWT viene inviato
// automaticamente grazie all'interceptor axios, ma per i download
// diretti usiamo fetch con il token dallo store.
const token = localStorage.getItem('access_token')
if (!token) {
window.open(url, '_blank')
return
}
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => {
if (!res.ok) throw new Error('Errore download')
return res.blob()
})
.then((blob) => {
const ext = format === 'csv' ? 'csv' : 'pdf'
const ts = new Date().toISOString().slice(0, 16).replace('T', '_').replace(':', '')
const filename = `pechub_report_${ts}.${ext}`
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
})
.catch(() => alert('Errore durante il download del report'))
}
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full overflow-y-auto bg-gray-50">
{/* ── Intestazione ── */}
<div className="sticky top-0 z-10 bg-white border-b px-6 py-4 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<BarChart2 className="h-6 w-6 text-blue-600 flex-shrink-0" />
<div className="min-w-0">
<h1 className="text-xl font-bold text-gray-900">Dashboard</h1>
{generatedAt && (
<p className="text-xs text-gray-400">Aggiornato il {generatedAt}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Selettore periodo */}
<div className="flex rounded-lg border bg-white overflow-hidden text-sm">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setDays(opt.value)}
className={cn(
'px-3 py-1.5 font-medium transition-colors',
days === opt.value
? 'bg-blue-600 text-white'
: 'text-gray-600 hover:bg-gray-50',
)}
>
{opt.label}
</button>
))}
</div>
{/* Aggiorna */}
<button
onClick={() => refetch()}
disabled={isFetching}
className="p-2 rounded-lg border bg-white text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
title="Aggiorna dati"
>
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
</button>
{/* Export CSV */}
<button
onClick={() => handleExport('csv')}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<Download className="h-4 w-4" />
CSV
</button>
{/* Export PDF */}
<button
onClick={() => handleExport('pdf')}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileText className="h-4 w-4" />
PDF
</button>
</div>
</div>
{/* ── Contenuto ── */}
<div className="flex-1 p-6 space-y-6 max-w-7xl mx-auto w-full">
{/* ── Loading / Errore ── */}
{isLoading && (
<div className="flex items-center justify-center h-64 text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mr-2" />
Caricamento dati...
</div>
)}
{isError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center text-red-700">
Errore nel caricamento dei dati. Riprova.
</div>
)}
{data && (
<>
{/* ── Riga KPI ── */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<KpiCard
title="Ricevute oggi"
value={kpi!.received_today}
subtitle={`${kpi!.received_7d} negli ultimi 7gg`}
icon={<Inbox className="h-5 w-5 text-blue-600" />}
color="bg-blue-50"
/>
<KpiCard
title="Inviate oggi"
value={kpi!.sent_today}
subtitle={`${kpi!.sent_7d} negli ultimi 7gg`}
icon={<Send className="h-5 w-5 text-indigo-600" />}
color="bg-indigo-50"
/>
<KpiCard
title="Anomalie attive"
value={kpi!.anomalie_attive}
icon={<AlertTriangle className="h-5 w-5 text-orange-600" />}
color="bg-orange-50"
alert={kpi!.anomalie_attive > 0}
/>
<KpiCard
title="Tasso consegna"
value={`${kpi!.tasso_consegna}%`}
icon={<CheckCircle2 className="h-5 w-5 text-green-600" />}
color="bg-green-50"
/>
<KpiCard
title="Caselle in errore"
value={kpi!.caselle_in_errore}
icon={<ServerCrash className="h-5 w-5 text-red-600" />}
color="bg-red-50"
alert={kpi!.caselle_in_errore > 0}
/>
<KpiCard
title="Non letti"
value={kpi!.messaggi_non_letti}
icon={<MailOpen className="h-5 w-5 text-purple-600" />}
color="bg-purple-50"
/>
</div>
{/* ── Grafici ── */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Grafico a barre (2/3) */}
<div className="xl:col-span-2 bg-white rounded-xl border p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">
Attivita PEC ultimi {days} giorni
</h2>
{chartData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-gray-400 text-sm">
Nessun dato nel periodo selezionato
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={chartData}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="giorno"
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomBarTooltip />} />
<Legend
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
/>
<Bar
dataKey="Ricevute"
fill="#3b82f6"
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar
dataKey="Inviate"
fill="#8b5cf6"
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Grafico a torta (1/3) */}
<div className="bg-white rounded-xl border p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">
Stato messaggi outbound
</h2>
{pieData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-gray-400 text-sm">
Nessun messaggio outbound
</div>
) : (
<>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={45}
outerRadius={72}
paddingAngle={2}
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={index} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomPieTooltip />} />
</PieChart>
</ResponsiveContainer>
{/* Legenda manuale */}
<div className="mt-2 space-y-1">
{pieData.map((entry) => (
<div key={entry.name} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-gray-600">
{STATE_LABELS[entry.name] ?? entry.name}
</span>
</div>
<span className="font-semibold text-gray-800">{entry.value}</span>
</div>
))}
</div>
</>
)}
</div>
</div>
{/* ── Tabella caselle ── */}
{data.mailbox_stats.length > 0 && (
<div className="bg-white rounded-xl border">
<div className="px-5 py-4 border-b">
<h2 className="text-base font-semibold text-gray-800">
Dettaglio per casella
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b">
<th className="text-left px-5 py-3 font-semibold text-gray-600">Casella</th>
<th className="text-center px-4 py-3 font-semibold text-gray-600">Stato</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Ricevute</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Inviate</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Anomalie</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Non letti</th>
<th className="text-right px-5 py-3 font-semibold text-gray-600">Ultima sync</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.mailbox_stats.map((mb) => (
<tr key={mb.mailbox_id} className="hover:bg-gray-50 transition-colors">
<td className="px-5 py-3">
<div className="font-medium text-gray-900 truncate max-w-xs">
{mb.display_name || mb.email_address}
</div>
{mb.display_name && (
<div className="text-xs text-gray-400">{mb.email_address}</div>
)}
</td>
<td className="px-4 py-3 text-center">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
STATUS_BADGE[mb.status] ?? 'bg-gray-100 text-gray-600',
)}
>
{mb.status}
</span>
</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">
{mb.received_total}
</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">
{mb.sent_total}
</td>
<td className="px-4 py-3 text-right">
{mb.anomalie > 0 ? (
<span className="font-semibold text-red-600">{mb.anomalie}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-4 py-3 text-right">
{mb.non_letti > 0 ? (
<span className="font-semibold text-blue-600">{mb.non_letti}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-5 py-3 text-right text-gray-500 text-xs">
{mb.last_sync_at
? format(parseISO(mb.last_sync_at), 'dd/MM HH:mm', { locale: it })
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Footer */}
<p className="text-xs text-gray-400 text-center pb-2">
Totale messaggi nel sistema: {kpi!.totale_messaggi.toLocaleString('it-IT')}
</p>
</>
)}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
libpq-dev \ libpq-dev \
curl \ curl \
tesseract-ocr \
tesseract-ocr-ita \
tesseract-ocr-eng \
poppler-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /worker WORKDIR /worker
+9 -5
View File
@@ -540,11 +540,15 @@ async def _save_message(
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip") logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
return False return False
# ── Parsing completo EML ────────────────────────────────────────────────── # ── Classificazione PEC da header (veloce, senza body) ───────────────────
parsed = parse_eml(raw_eml) # La classificazione avviene PRIMA del parsing completo perche' il parser
pec_class = classify_pec_message( # deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
parsed.raw_message or email.message_from_bytes(raw_eml) # il body_text (testo della ricevuta) con il contenuto di postacert.eml.
) quick_msg = email.message_from_bytes(raw_eml)
pec_class = classify_pec_message(quick_msg)
# ── Parsing completo EML (con is_receipt per proteggere il body) ──────────
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
received_at = datetime.now(UTC) received_at = datetime.now(UTC)
# ── State machine: trova e aggiorna messaggio outbound ──────────────────── # ── State machine: trova e aggiorna messaggio outbound ────────────────────
+538 -38
View File
@@ -2,17 +2,29 @@
Indicizzazione full-text dei messaggi PEC. Indicizzazione full-text dei messaggi PEC.
Responsabilita': Responsabilita':
1. Scarica gli allegati PDF e DOCX da MinIO 1. Scarica gli allegati da MinIO
2. Estrae il testo con pypdf (PDF) e python-docx (DOCX) 2. Estrae il testo in base al formato del file
3. Aggiorna la colonna extracted_text in attachments 3. Aggiorna la colonna extracted_text in attachments
4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati 4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati
Formati supportati:
- PDF (.pdf) tramite pypdf
- Word (.docx, .doc) tramite python-docx
- Excel (.xlsx, .xls) tramite openpyxl
- PowerPoint(.pptx, .ppt) tramite python-pptx
- LibreOffice (.odt, .ods, .odp) tramite odfpy
- RTF (.rtf) tramite striprtf
- Testo (.txt, .csv, .xml, .html, .htm) testo grezzo
- Email (.eml, .msg) tramite stdlib email
- Firmati (.p7m) unwrap CMS poi estrae in base all'estensione interna
Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante: Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante:
un'eccezione qui non interrompe la sincronizzazione del messaggio. un'eccezione qui non interrompe la sincronizzazione del messaggio.
""" """
import io import io
import logging import logging
import re
import uuid import uuid
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -26,61 +38,534 @@ MAX_EXTRACTED_TEXT_LEN = 50_000
MAX_COMBINED_TEXT_LEN = 200_000 MAX_COMBINED_TEXT_LEN = 200_000
# ─── Estrazione testo ───────────────────────────────────────────────────────── # ─── Rilevamento tipo file ────────────────────────────────────────────────────
def _extract_pdf_text(content: bytes) -> str: def _ext(filename: str | None) -> str:
"""Estrae testo da un PDF usando pypdf.""" """Restituisce l'estensione del file in minuscolo, senza punto."""
if not filename:
return ""
fn = filename.lower()
# Gestione doppia estensione es. documento.pdf.p7m
if fn.endswith(".p7m"):
return "p7m"
idx = fn.rfind(".")
return fn[idx + 1:] if idx >= 0 else ""
def _is_extractable(content_type: str | None, filename: str | None) -> bool:
"""Ritorna True se il formato e' supportato dall'estrattore."""
ct = (content_type or "").lower()
e = _ext(filename)
return e in _EXTRACTORS or ct in _CONTENT_TYPE_MAP
def _resolve_extractor(content_type: str | None, filename: str | None):
"""Ritorna la funzione estrattore appropriata, o None."""
e = _ext(filename)
if e in _EXTRACTORS:
return _EXTRACTORS[e]
ct = (content_type or "").lower()
if ct in _CONTENT_TYPE_MAP:
return _EXTRACTORS.get(_CONTENT_TYPE_MAP[ct])
return None
# ─── Estrattori ───────────────────────────────────────────────────────────────
# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR.
# Un PDF di testo reale produce migliaia di caratteri; una scansione ne produce
# zero o pochissimi (artefatti). 50 char e' un valore conservativo sicuro.
_PDF_OCR_THRESHOLD = 50
# Numero massimo di pagine su cui eseguire OCR per evitare timeout su PDF lunghi.
_PDF_OCR_MAX_PAGES = 15
def _extract_pdf(content: bytes) -> str:
"""
Estrae testo da PDF tramite pypdf.
Se il testo estratto e' inferiore a _PDF_OCR_THRESHOLD caratteri (PDF
image-only / scansione), attiva il fallback OCR via Tesseract.
"""
try: try:
import pypdf # type: ignore[import] import pypdf # type: ignore[import]
reader = pypdf.PdfReader(io.BytesIO(content)) reader = pypdf.PdfReader(io.BytesIO(content))
parts: list[str] = [] parts: list[str] = []
for page in reader.pages: for page in reader.pages:
try: try:
txt = page.extract_text() t = page.extract_text()
if txt: if t:
parts.append(txt) parts.append(t)
except Exception: except Exception:
continue continue
return " ".join(parts) text = " ".join(parts)
except ImportError: except ImportError:
logger.warning("pypdf non installato: impossibile estrarre testo da PDF") logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
return "" return ""
except Exception as e: except Exception as e:
logger.debug(f"Errore estrazione testo PDF: {e}") logger.debug(f"Errore estrazione PDF: {e}")
return ""
# Se il testo e' troppo corto, il PDF e' probabilmente una scansione
if len(text.strip()) < _PDF_OCR_THRESHOLD:
logger.debug(
f"PDF con testo insufficiente ({len(text.strip())} char), "
"tentativo OCR..."
)
ocr_text = _extract_pdf_ocr(content)
if len(ocr_text.strip()) > len(text.strip()):
return ocr_text
return text
def _extract_pdf_ocr(content: bytes) -> str:
"""
OCR su PDF image-only tramite pdf2image + Tesseract.
Converte le pagine del PDF in immagini PIL a 200 DPI (buon compromesso
qualita'/velocita' su CPU) e applica Tesseract con lingua italiana + inglese.
Processa al massimo _PDF_OCR_MAX_PAGES pagine per evitare timeout.
"""
try:
from pdf2image import convert_from_bytes # type: ignore[import]
import pytesseract # type: ignore[import]
pages = convert_from_bytes(
content,
dpi=200,
last_page=_PDF_OCR_MAX_PAGES,
)
parts: list[str] = []
for page_img in pages:
try:
t = pytesseract.image_to_string(page_img, lang="ita+eng")
if t and t.strip():
parts.append(t.strip())
except Exception:
continue
return " ".join(parts)
except ImportError:
logger.warning(
"pdf2image o pytesseract non installati: impossibile OCR PDF"
)
return ""
except Exception as e:
logger.debug(f"Errore OCR PDF: {e}")
return "" return ""
def _extract_docx_text(content: bytes) -> str: def _extract_image_ocr(content: bytes) -> str:
"""Estrae testo da un DOCX usando python-docx.""" """
Estrae testo da un file immagine (PNG, JPEG, TIFF, BMP, ecc.) tramite OCR.
Usa Tesseract con lingua italiana + inglese per massima copertura
su documenti italiani.
"""
try:
import pytesseract # type: ignore[import]
from PIL import Image # type: ignore[import]
img = Image.open(io.BytesIO(content))
# Converti in RGB se necessario (TIFF multi-frame, palette, ecc.)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
text = pytesseract.image_to_string(img, lang="ita+eng")
return " ".join(text.split())
except ImportError:
logger.warning(
"pytesseract o Pillow non installati: impossibile OCR immagine"
)
return ""
except Exception as e:
logger.debug(f"Errore OCR immagine: {e}")
return ""
def _extract_docx(content: bytes) -> str:
"""Estrae testo da DOCX/DOC tramite python-docx."""
try: try:
import docx # type: ignore[import] import docx # type: ignore[import]
doc = docx.Document(io.BytesIO(content)) doc = docx.Document(io.BytesIO(content))
parts = [para.text for para in doc.paragraphs if para.text and para.text.strip()] parts = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
# Include anche le tabelle
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
if cell.text and cell.text.strip():
parts.append(cell.text.strip())
return " ".join(parts) return " ".join(parts)
except ImportError: except ImportError:
logger.warning("python-docx non installato: impossibile estrarre testo da DOCX") logger.warning("python-docx non installato: impossibile estrarre testo da DOCX")
return "" return ""
except Exception as e: except Exception as e:
logger.debug(f"Errore estrazione testo DOCX: {e}") logger.debug(f"Errore estrazione DOCX: {e}")
return "" return ""
def _is_pdf(content_type: str | None, filename: str | None) -> bool: def _extract_xlsx(content: bytes) -> str:
ct = (content_type or "").lower() """Estrae testo da XLSX/XLS tramite openpyxl."""
fn = (filename or "").lower() try:
return ct == "application/pdf" or fn.endswith(".pdf") import openpyxl # type: ignore[import]
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
parts: list[str] = []
for ws in wb.worksheets:
for row in ws.iter_rows():
for cell in row:
if cell.value is not None:
v = str(cell.value).strip()
if v:
parts.append(v)
return " ".join(parts)
except ImportError:
logger.warning("openpyxl non installato: impossibile estrarre testo da XLSX")
return ""
except Exception as e:
logger.debug(f"Errore estrazione XLSX: {e}")
return ""
def _is_docx(content_type: str | None, filename: str | None) -> bool: def _extract_pptx(content: bytes) -> str:
ct = (content_type or "").lower() """Estrae testo da PPTX/PPT tramite python-pptx."""
fn = (filename or "").lower() try:
return ct in ( from pptx import Presentation # type: ignore[import]
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", prs = Presentation(io.BytesIO(content))
"application/msword", parts: list[str] = []
"application/vnd.ms-word", for slide in prs.slides:
) or fn.endswith((".docx", ".doc")) for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
t = para.text.strip()
if t:
parts.append(t)
return " ".join(parts)
except ImportError:
logger.warning("python-pptx non installato: impossibile estrarre testo da PPTX")
return ""
except Exception as e:
logger.debug(f"Errore estrazione PPTX: {e}")
return ""
def _extract_odt(content: bytes) -> str:
"""Estrae testo da ODT/ODS/ODP tramite odfpy."""
try:
from odf import opendocument, teletype # type: ignore[import]
from odf.text import P # type: ignore[import]
doc = opendocument.load(io.BytesIO(content))
parts: list[str] = []
for el in doc.body.getElementsByType(P):
t = teletype.extractText(el).strip()
if t:
parts.append(t)
return " ".join(parts)
except ImportError:
logger.warning("odfpy non installato: impossibile estrarre testo da ODT")
return ""
except Exception as e:
logger.debug(f"Errore estrazione ODT: {e}")
return ""
def _extract_rtf(content: bytes) -> str:
"""Estrae testo da RTF tramite striprtf."""
try:
from striprtf.striprtf import rtf_to_text # type: ignore[import]
raw = content.decode("latin-1", errors="replace")
return rtf_to_text(raw)
except ImportError:
logger.warning("striprtf non installato: impossibile estrarre testo da RTF")
return ""
except Exception as e:
logger.debug(f"Errore estrazione RTF: {e}")
return ""
def _extract_plain(content: bytes) -> str:
"""Estrae testo da file di testo puro (txt, csv, xml, html, ecc.)."""
try:
# Prova UTF-8 prima, poi latin-1 come fallback
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = content.decode("latin-1", errors="replace")
# Per XML/HTML: rimuove i tag
if "<" in text and ">" in text:
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"&[a-zA-Z]+;", " ", text)
return " ".join(text.split())
except Exception as e:
logger.debug(f"Errore estrazione testo plain: {e}")
return ""
def _extract_eml(content: bytes) -> str:
"""Estrae testo da un file EML allegato."""
try:
import email as emaillib
msg = emaillib.message_from_bytes(content)
parts: list[str] = []
subject = msg.get("Subject", "")
if subject:
parts.append(subject)
sender = msg.get("From", "")
if sender:
parts.append(sender)
# Estrae body
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
try:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
parts.append(payload.decode(charset, errors="replace"))
except Exception:
pass
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
parts.append(payload.decode(charset, errors="replace")) # type: ignore[arg-type]
return " ".join(parts)
except Exception as e:
logger.debug(f"Errore estrazione EML: {e}")
return ""
def _extract_p7m(content: bytes, original_filename: str | None = None) -> str:
"""
Estrae testo da un documento con firma digitale CAdES (.p7m).
Prova a fare l'unwrap del CMS envelope tramite la libreria cryptography
(gia' presente nel worker). Se l'unwrap ha successo, determina il formato
del documento interno dall'estensione del nome originale (es. fattura.pdf.p7m
-> PDF) e applica l'estrattore appropriato.
"""
inner_content: bytes | None = None
# Metodo 1: cryptography (CMS/PKCS7)
try:
from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import]
# load_pem_pkcs7_certificates / load_der_pkcs7_certificates non espongono il payload
# Usiamo il modulo backend direttamente
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
from cryptography.x509 import load_der_x509_certificate # noqa: F401
# Prova parsing DER diretto della struttura CMS ContentInfo
# La struttura ASN.1 di SignedData contiene encapContentInfo -> eContent
from cryptography.hazmat.bindings._rust import ( # type: ignore[import]
x509 as rust_x509,
)
_ = rust_x509 # solo per verificare import
except Exception:
pass
# Metodo piu' semplice: parsing ASN.1 manuale per estrarre eContent
# La struttura DER di CMS SignedData:
# SEQUENCE {
# OID (signedData)
# [0] EXPLICIT SEQUENCE {
# INTEGER (version)
# SET (digestAlgorithms)
# SEQUENCE (encapContentInfo) {
# OID (contentType = data)
# [0] EXPLICIT OCTET STRING (eContent) <- questo e' il contenuto originale
# }
# ...
# }
# }
try:
inner_content = _unwrap_p7m_asn1(content)
except Exception as e:
logger.debug(f"Unwrap P7M ASN1 fallito: {e}")
if not inner_content:
logger.debug("Impossibile estrarre contenuto dal file .p7m")
return ""
# Determina l'estensione interna dal nome file originale
# es. "fattura.pdf.p7m" -> inner ext = "pdf"
inner_ext = ""
if original_filename:
fn = original_filename.lower()
if fn.endswith(".p7m"):
fn = fn[:-4] # rimuove .p7m
idx = fn.rfind(".")
if idx >= 0:
inner_ext = fn[idx + 1:]
extractor = _EXTRACTORS.get(inner_ext)
if extractor:
logger.debug(f"P7M: estrazione interna come {inner_ext!r}")
return extractor(inner_content)
# Fallback: prova a riconoscere il formato dall'header del contenuto
if inner_content[:4] == b"%PDF":
return _extract_pdf(inner_content)
if inner_content[:2] in (b"PK",): # ZIP-based (docx, xlsx, pptx, odt)
# Prova nell'ordine piu' comune
for fn in (_extract_docx, _extract_xlsx, _extract_pptx, _extract_odt):
result = fn(inner_content)
if result.strip():
return result
# Ultimo tentativo: plain text
return _extract_plain(inner_content)
def _unwrap_p7m_asn1(data: bytes) -> bytes | None:
"""
Parsing ASN.1 DER minimale per estrarre eContent da una struttura CMS SignedData.
Non verifica la firma: serve solo per l'estrazione del testo.
"""
pos = 0
length = len(data)
def read_tag_length(buf: bytes, offset: int) -> tuple[int, int, int]:
"""Ritorna (tag, length, new_offset)."""
tag = buf[offset]
offset += 1
lb = buf[offset]
offset += 1
if lb & 0x80:
num_bytes = lb & 0x7F
ln = int.from_bytes(buf[offset:offset + num_bytes], "big")
offset += num_bytes
else:
ln = lb
return tag, ln, offset
# outer SEQUENCE
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x30:
return None
# OID (contentType = signedData)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x06:
return None
pos += ln # skip OID value
# [0] EXPLICIT
tag, ln, pos = read_tag_length(data, pos)
if tag != 0xA0:
return None
# SEQUENCE (SignedData)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x30:
return None
# INTEGER (version)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x02:
return None
pos += ln
# SET (digestAlgorithms)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x31:
return None
pos += ln
# SEQUENCE (encapContentInfo)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x30:
return None
# OID (contentType dentro encapContentInfo)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x06:
return None
pos += ln
# [0] EXPLICIT (eContent, opzionale)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0xA0:
return None
# OCTET STRING con il contenuto originale
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x04:
return None
return data[pos: pos + ln]
# ─── Mapping formato -> estrattore ────────────────────────────────────────────
_EXTRACTORS: dict[str, object] = {
# Documenti Office
"pdf": _extract_pdf,
"docx": _extract_docx,
"doc": _extract_docx,
"xlsx": _extract_xlsx,
"xls": _extract_xlsx,
"pptx": _extract_pptx,
"ppt": _extract_pptx,
# LibreOffice
"odt": _extract_odt,
"ods": _extract_odt,
"odp": _extract_odt,
# Testo
"txt": _extract_plain,
"csv": _extract_plain,
"xml": _extract_plain,
"html": _extract_plain,
"htm": _extract_plain,
"json": _extract_plain,
# RTF
"rtf": _extract_rtf,
# Email
"eml": _extract_eml,
"msg": _extract_eml,
# Firma digitale CAdES
"p7m": _extract_p7m,
# Immagini (OCR)
"png": _extract_image_ocr,
"jpg": _extract_image_ocr,
"jpeg": _extract_image_ocr,
"tiff": _extract_image_ocr,
"tif": _extract_image_ocr,
"bmp": _extract_image_ocr,
"gif": _extract_image_ocr,
"webp": _extract_image_ocr,
}
# Mapping content-type -> estensione normalizzata (per fallback quando il filename manca)
_CONTENT_TYPE_MAP: dict[str, str] = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/msword": "doc",
"application/vnd.ms-word": "doc",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.oasis.opendocument.text": "odt",
"application/vnd.oasis.opendocument.spreadsheet": "ods",
"application/vnd.oasis.opendocument.presentation": "odp",
"application/rtf": "rtf",
"text/rtf": "rtf",
"text/plain": "txt",
"text/csv": "csv",
"text/xml": "xml",
"application/xml": "xml",
"text/html": "html",
"message/rfc822": "eml",
"application/pkcs7-mime": "p7m",
"application/x-pkcs7-mime": "p7m",
# Immagini (OCR)
"image/png": "png",
"image/jpeg": "jpeg",
"image/jpg": "jpeg",
"image/tiff": "tiff",
"image/bmp": "bmp",
"image/gif": "gif",
"image/webp": "webp",
}
# ─── Job principale ─────────────────────────────────────────────────────────── # ─── Job principale ───────────────────────────────────────────────────────────
@@ -157,8 +642,13 @@ async def _do_index_message(
attachment_texts.append(att.extracted_text) attachment_texts.append(att.extracted_text)
continue continue
# Controlla se e' un PDF o DOCX # Controlla se il formato e' supportato
if not (_is_pdf(att.content_type, att.filename) or _is_docx(att.content_type, att.filename)): extractor = _resolve_extractor(att.content_type, att.filename)
if extractor is None:
logger.debug(
f"Formato non supportato per indicizzazione: "
f"{att.filename!r} ({att.content_type!r})"
)
continue continue
# Scarica da MinIO # Scarica da MinIO
@@ -173,19 +663,29 @@ async def _do_index_message(
) )
continue continue
# Estrai testo # Estrai testo - per p7m passa anche il filename originale
if _is_pdf(att.content_type, att.filename): try:
extracted = _extract_pdf_text(content) e = _ext(att.filename)
if e == "p7m":
extracted = _extract_p7m(content, att.filename)
else: else:
extracted = _extract_docx_text(content) extracted = extractor(content) # type: ignore[operator]
except Exception as ex:
if not extracted or not extracted.strip(): logger.debug(f"Errore estrazione {att.filename!r}: {ex}")
continue continue
# Limita la dimensione e salva if not extracted or not extracted.strip():
logger.debug(f"Nessun testo estratto da {att.filename!r}")
continue
# Limita la dimensione e salva sull'ORM object (col. mappata)
att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN] att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
attachment_texts.append(att.extracted_text) attachment_texts.append(att.extracted_text)
indexed_count += 1 indexed_count += 1
logger.debug(
f"Testo estratto da {att.filename!r}: "
f"{len(att.extracted_text)} caratteri"
)
# ── Aggiorna search_vector includendo il testo degli allegati ───────────── # ── Aggiorna search_vector includendo il testo degli allegati ─────────────
if attachment_texts: if attachment_texts:
@@ -214,5 +714,5 @@ async def _do_index_message(
) )
else: else:
logger.debug( logger.debug(
f"Messaggio {message_id}: nessun allegato PDF/DOCX con testo estraibile" f"Messaggio {message_id}: nessun allegato con testo estraibile"
) )
+2
View File
@@ -194,6 +194,8 @@ class Attachment(Base):
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False) storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Testo estratto dall'indicizzatore full-text per la ricerca
extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
+19 -8
View File
@@ -116,7 +116,7 @@ def parse_date(date_str: str | None) -> datetime | None:
# ─── Parser principale ──────────────────────────────────────────────────────── # ─── Parser principale ────────────────────────────────────────────────────────
def parse_eml(raw_bytes: bytes) -> ParsedEmail: def parse_eml(raw_bytes: bytes, is_receipt: bool = False) -> ParsedEmail:
""" """
Parsing completo di un raw EML. Parsing completo di un raw EML.
@@ -127,6 +127,10 @@ def parse_eml(raw_bytes: bytes) -> ParsedEmail:
Args: Args:
raw_bytes: byte del messaggio EML grezzo raw_bytes: byte del messaggio EML grezzo
is_receipt: True se il messaggio e' una ricevuta PEC (accettazione,
avvenuta_consegna, ecc.). In questo caso il body_text/html
esterno (testo della ricevuta) non viene sovrascritto con
il contenuto del messaggio annidato in postacert.eml.
Returns: Returns:
ParsedEmail con tutti i campi estratti (fields None/[] se non presenti) ParsedEmail con tutti i campi estratti (fields None/[] se non presenti)
@@ -153,7 +157,7 @@ def parse_eml(raw_bytes: bytes) -> ParsedEmail:
# ── Body e allegati ─────────────────────────────────────────────────────── # ── Body e allegati ───────────────────────────────────────────────────────
if msg.is_multipart(): if msg.is_multipart():
_walk_parts(msg, result) _walk_parts(msg, result, is_receipt=is_receipt)
else: else:
_extract_single_part_body(msg, result) _extract_single_part_body(msg, result)
@@ -208,7 +212,7 @@ def _get_filename(part: email.message.Message) -> str | None:
return None return None
def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None: def _walk_parts(msg: email.message.Message, result: ParsedEmail, is_receipt: bool = False) -> None:
""" """
Naviga ricorsivamente tutti i part MIME del messaggio. Naviga ricorsivamente tutti i part MIME del messaggio.
@@ -230,7 +234,7 @@ def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
# ── EML-in-EML (message/rfc822) ─────────────────────────────────────── # ── EML-in-EML (message/rfc822) ───────────────────────────────────────
if ct == "message/rfc822": if ct == "message/rfc822":
_extract_eml_in_eml(part, filename, result) _extract_eml_in_eml(part, filename, result, is_receipt=is_receipt)
continue continue
# ── Allegato esplicito (Content-Disposition: attachment) ────────────── # ── Allegato esplicito (Content-Disposition: attachment) ──────────────
@@ -292,12 +296,16 @@ def _extract_eml_in_eml(
part: email.message.Message, part: email.message.Message,
filename: str | None, filename: str | None,
result: ParsedEmail, result: ParsedEmail,
is_receipt: bool = False,
) -> None: ) -> None:
""" """
Estrae il messaggio EML annidato in un part message/rfc822. Estrae il messaggio EML annidato in un part message/rfc822.
Per postacert.eml (busta PEC in arrivo): ricorre dentro per estrarre Per postacert.eml in messaggi posta_certificata: ricorre dentro per estrarre
gli allegati utente e il corpo del messaggio originale del mittente. gli allegati utente e il corpo del messaggio originale del mittente.
Per le ricevute (is_receipt=True): estrae solo gli allegati utente senza
sovrascrivere il body gia' impostato (che e' il testo della ricevuta stessa).
""" """
try: try:
payload = part.get_payload() payload = part.get_payload()
@@ -305,7 +313,7 @@ def _extract_eml_in_eml(
inner_bytes: bytes | None = None inner_bytes: bytes | None = None
if isinstance(payload, list) and payload: if isinstance(payload, list) and payload:
# Forma canonica: payload è lista di Message # Forma canonica: payload e' lista di Message
inner_msg = payload[0] inner_msg = payload[0]
if isinstance(inner_msg, email.message.Message): if isinstance(inner_msg, email.message.Message):
inner_bytes = inner_msg.as_bytes() inner_bytes = inner_msg.as_bytes()
@@ -330,14 +338,17 @@ def _extract_eml_in_eml(
) )
result.attachments.append(att) result.attachments.append(att)
# Per postacert.eml: ricorre dentro per trovare allegati utente e corpo originale # Per postacert.eml: ricorre dentro per trovare allegati utente
if is_system and eff_filename.lower() == "postacert.eml": if is_system and eff_filename.lower() == "postacert.eml":
inner_parsed = parse_eml(inner_bytes) inner_parsed = parse_eml(inner_bytes)
# Allegati non-sistema del messaggio originale del mittente # Allegati non-sistema del messaggio originale del mittente
for inner_att in inner_parsed.attachments: for inner_att in inner_parsed.attachments:
if not inner_att.is_pec_system: if not inner_att.is_pec_system:
result.attachments.append(inner_att) result.attachments.append(inner_att)
# Corpo del messaggio originale (più utile del testo della busta PEC) # Sovrascrive il corpo SOLO per messaggi posta_certificata (non ricevute).
# Per le ricevute il body esterno e' gia' il testo corretto della ricevuta;
# postacert.eml contiene il messaggio originale inviato che non va mostrato.
if not is_receipt:
if inner_parsed.body_html: if inner_parsed.body_html:
result.body_html = inner_parsed.body_html result.body_html = inner_parsed.body_html
result.body_text = inner_parsed.body_text result.body_text = inner_parsed.body_text
+10 -1
View File
@@ -42,9 +42,18 @@ dependencies = [
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"email-validator>=2.2.0", "email-validator>=2.2.0",
# Full-text search: estrazione testo da allegati PDF e DOCX # Full-text search: estrazione testo da allegati
"pypdf>=4.0.0", "pypdf>=4.0.0",
"python-docx>=1.1.0", "python-docx>=1.1.0",
"openpyxl>=3.1.0",
"python-pptx>=1.0.0",
"odfpy>=1.4.1",
"striprtf>=0.0.26",
# OCR per allegati image-only (immagini dirette e PDF scansionati)
"pytesseract>=0.3.13",
"pdf2image>=1.17.0",
"Pillow>=11.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+129
View File
@@ -0,0 +1,129 @@
"""
Script one-shot: corregge il body_text/body_html delle ricevute PEC gia' in DB.
Problema: il parser EML sovrascriveva il body delle ricevute con il contenuto
di postacert.eml (messaggio originale inviato), invece di mostrare il testo
della ricevuta stessa.
Questo script:
1. Trova tutti i messaggi in DB con pec_type di tipo ricevuta
2. Scarica l'EML grezzo da MinIO (raw_eml_path)
3. Lo ri-parsa con is_receipt=True (parser corretto)
4. Aggiorna body_text e body_html nel DB
Uso:
cd /opt/pechub
docker compose exec pechub-worker-1 python /app/scripts/fix_receipt_body.py
"""
import asyncio
import logging
import sys
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# Aggiungi il path dell'app
sys.path.insert(0, "/app")
from app.config import get_settings
from app.models import Message
from app.parsers.eml_parser import parse_eml
from app.storage.minio_client import download_attachment
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
# Tipi di ricevuta che potrebbero avere il body sbagliato
RECEIPT_TYPES = {
"accettazione",
"non_accettazione",
"presa_in_carico",
"avvenuta_consegna",
"mancata_consegna",
"errore_consegna",
"preavviso_mancata_consegna",
"rilevazione_virus",
}
async def fix_receipt_bodies() -> None:
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
# Trova tutti i messaggi ricevuta con raw_eml_path
result = await db.execute(
select(Message).where(
Message.pec_type.in_(RECEIPT_TYPES),
Message.raw_eml_path.is_not(None),
).order_by(Message.created_at)
)
messages = result.scalars().all()
logger.info(f"Trovate {len(messages)} ricevute da verificare")
fixed = 0
skipped = 0
errors = 0
for msg in messages:
try:
# Scarica EML grezzo da MinIO (download_attachment funziona per qualsiasi path)
raw_eml = await download_attachment(msg.raw_eml_path)
if not raw_eml:
logger.warning(f"EML non trovato su MinIO per messaggio {msg.id} (path={msg.raw_eml_path!r})")
skipped += 1
continue
# Re-parsing con is_receipt=True (parser corretto)
parsed = parse_eml(raw_eml, is_receipt=True)
# Controlla se il body e' cambiato
new_body_text = parsed.body_text
new_body_html = parsed.body_html
if new_body_text == msg.body_text and new_body_html == msg.body_html:
logger.debug(f"Messaggio {msg.id} ({msg.pec_type}): body invariato, skip")
skipped += 1
continue
# Aggiorna nel DB
msg.body_text = new_body_text
msg.body_html = new_body_html
msg.updated_at = datetime.now(UTC)
logger.info(
f"Fixato: id={msg.id} pec_type={msg.pec_type!r} subject={msg.subject!r} "
f"body_text_len={len(new_body_text or '')}"
)
fixed += 1
except Exception as e:
logger.error(f"Errore su messaggio {msg.id}: {e}", exc_info=True)
errors += 1
continue
if fixed > 0:
await db.commit()
logger.info(f"Commit eseguito: {fixed} messaggi aggiornati")
else:
logger.info("Nessun messaggio da aggiornare")
logger.info(
f"Completato: fixed={fixed} skipped={skipped} errors={errors} "
f"totale={len(messages)}"
)
await engine.dispose()
if __name__ == "__main__":
asyncio.run(fix_receipt_bodies())