diff --git a/SINGLE_EVALUATION_FLOW.md b/SINGLE_EVALUATION_FLOW.md new file mode 100644 index 0000000..c00bfc0 --- /dev/null +++ b/SINGLE_EVALUATION_FLOW.md @@ -0,0 +1,132 @@ +# SINGLE_EVALUATION_FLOW + +## Scopo +Descrive il flusso frontend della funzionalità **Evaluación Individual** (route: `/evaluations/single`) e il passaggio alla pagina risultati **FastCheck** (route: `/fast-check-ex`), includendo le chiamate API effettuate dal frontend. + +## Componenti coinvolti +- **UI form valutazione**: `src/pages/evaluations/SingleEvaluation.tsx` +- **Pagina risultati**: `src/pages/FastCheckEX.tsx` +- **Client HTTP**: `src/services/api.ts` (`apiClient`) +- **Auth**: `src/contexts/AuthContext.tsx` + `src/services/authService.ts` +- **Tenant/Crediti**: `src/contexts/TenantContext.tsx` + +## Base URL API +Il frontend usa: +- `globalThis.__APP_ENV__?.VITE_API_BASE_URL` +- fallback: `https://duxiter.azurianlab.com/api` + +Tutte le chiamate sotto sono relative a questa base URL. + +## Flusso ad alto livello +1. L’utente apre **Evaluación Individual** (`/evaluations/single`) +2. Il frontend carica contesto sessione/tenant (token + crediti) +3. L’utente inserisce e conferma il **RUT** (validazione lato client + accettazione termini) +4. Alla submit: + - controlla se esiste già un risultato in DB + - se esiste: reindirizza direttamente a **FastCheck** con `rut` in querystring + - se non esiste: avvia `lookup` e poi reindirizza a **FastCheck** +5. In **FastCheck** vengono caricati i dettagli del risultato e i dati “accessori” (AI analysis, liste, note, ecc.) + +## Normalizzazione RUT (frontend) +- Durante digitazione il RUT viene formattato. +- Prima delle API, il RUT viene “sanitizzato” togliendo i punti: `formattedRut.replace(/\./g, '')` +- L’hyphen rimane (es. `12.345.678-9` → `12345678-9`) + +## Diagramma (sequenza) + +```mermaid +sequenceDiagram + autonumber + actor U as Utente + participant SE as FE: /evaluations/single (SingleEvaluation.tsx) + participant AC as FE: AuthContext/AuthService + participant TC as FE: TenantContext + participant API as Backend API (VITE_API_BASE_URL) + participant FC as FE: /fast-check-ex (FastCheckEX.tsx) + + U->>SE: Apre pagina + SE->>AC: Ripristina sessione (token in localStorage) + AC->>API: GET /auth/me + SE->>TC: Carica tenant/crediti + TC->>API: GET /tenant (Bearer token) + + U->>SE: Inserisce RUT + accetta termini + U->>SE: Submit + + SE->>API: GET /rut/results/rut/{rutSanitized} + alt Esiste già un risultato + SE-->>FC: navigate("/fast-check-ex?rut={rutSanitized}") + else Non esiste (404) + SE->>API: POST /rut/lookup { rut: rutSanitized, isMonitoring: false } + SE-->>FC: navigate("/fast-check-ex?rut={rutSanitized}") + end + + FC->>API: GET /rut/results/rut/{rutSanitized} (opzionale ?risk=true) + opt Se non c'è rut o serve "latest" + FC->>API: GET /rut/results/latest + FC->>API: GET /rut/results/rut/{latestRut} + end + opt Funzioni accessorie in FastCheck + FC->>API: GET /rut/ai-analysis/{rut} (opzionale ?regenerate=true) + FC->>API: POST /rut/fast-check-summary/{rut} { summary } + FC->>API: GET /lpalto/search/rut/{rut} + FC->>API: GET /lpmedio/search/rut/{rut} + FC->>API: GET /antiunion-cases/rut/{rut} + FC->>API: GET /user-notes/rut/{rut} + FC->>API: POST /user-notes { rut, note, tags } + FC->>API: PUT /user-notes/{noteId} { note, tags } + FC->>API: DELETE /user-notes/{noteId} + FC->>API: PUT /rut/results/rut/{rut}/update-pep-status { ... } + end +``` + +## Chiamate API per “Evaluación Individual” (pagina `/evaluations/single`) +Chiamate direttamente collegate al submit: + +1. **Controllo esistenza risultato** +- `GET /rut/results/rut/{rut}` + - Se risponde con dati: considerato “già esistente” → redirect a FastCheck + - Se 404: procede con `lookup` + +2. **Avvio valutazione** +- `POST /rut/lookup` + - Body: `{ "rut": "{rut}", "isMonitoring": false }` + - In seguito redirect a FastCheck + +## Chiamate “di contesto” (sessione/tenant) +Queste possono avvenire mentre l’utente è su `/evaluations/single`: + +- `GET /auth/me` (ripristino sessione) +- `GET /tenant` (crediti/tenant; header `Authorization: Bearer `) + +## Caricamento risultati (pagina `/fast-check-ex`) +FastCheck usa tipicamente questi endpoint per mostrare il risultato della valutazione: + +- `GET /rut/results/rut/{rut}` (opzionale `?risk=true`) +- `GET /rut/results/{resultId}` (opzionale `?risk=true`) +- `GET /rut/results/latest` + +In base alle feature attive in UI, possono aggiungersi: +- `GET /rut/ai-analysis/{rut}` (opzionale `?regenerate=true`) +- `POST /rut/fast-check-summary/{rut}` +- `GET /lpalto/search/rut/{rut}` +- `GET /lpmedio/search/rut/{rut}` +- `GET /antiunion-cases/rut/{rut}` +- Note utente: + - `GET /user-notes/rut/{rut}` + - `POST /user-notes` + - `PUT /user-notes/{noteId}` + - `DELETE /user-notes/{noteId}` +- Aggiornamento stato PEP: + - `PUT /rut/results/rut/{rut}/update-pep-status` + +## Stati e UX (sintesi) +- Submit bloccato se: + - RUT non valido + - termini non accettati + - loading in corso +- Se crediti tenant = 0: viene mostrato un dialog informativo. +- In caso di errore non-404 nel check esistenza: viene loggato ma il flusso continua con `lookup`. + +## Note operative +- Il redirect a `/fast-check-ex` è parte integrante del flusso: la pagina “Evaluación Individual” funge da avvio/trigger, mentre la visualizzazione del risultato avviene in FastCheck. diff --git a/fastcheck_api/app/api/v1/routers/rut.py b/fastcheck_api/app/api/v1/routers/rut.py new file mode 100644 index 0000000..5bffa96 --- /dev/null +++ b/fastcheck_api/app/api/v1/routers/rut.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import datetime as dt +import time +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo import ReturnDocument +from pydantic import BaseModel, Field + +from fastcheck_api.app.api.dependencies import CurrentUser, require_permission +from fastcheck_api.app.core.mongodb import get_db +from fastcheck_api.app.services.audit_service import AuditService +from fastcheck_api.app.services.legacy_service import legacy_service +from fastcheck_api.app.utils.mongo import sanitize_rut, to_object_id + + +router = APIRouter(prefix="/rut", tags=["rut"]) + +_bearer = HTTPBearer(auto_error=False) + + +class RutLookupRequest(BaseModel): + rut: str = Field(examples=["12345678-9"]) + isMonitoring: bool = Field(default=False, description="Compatibility flag with legacy backend.") + + +@router.post( + "/lookup", + summary="Lookup RUT (single evaluation)", + description=( + "Runs a single evaluation for a RUT using the legacy backend and persists the result in MongoDB.\n\n" + "If a successful result for the same RUT already exists for the tenant, returns it without consuming credits." + ), +) +async def lookup_rut( + request: Request, + payload: RutLookupRequest, + user: Annotated[CurrentUser, Depends(require_permission("rut:lookup"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None, +): + start = time.perf_counter() + rut = sanitize_rut(payload.rut) + + token = credentials.credentials if credentials else None + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No token provided") + + cached = ( + await db["evaluationresults"] + .find({"tenantId": user.tenant, "rut": rut, "status": "success"}) + .sort([("createdAt", -1)]) + .limit(1) + .to_list(length=1) + ) + if cached and isinstance(cached[0], dict) and isinstance(cached[0].get("data"), dict): + await AuditService.log_consulta( + db=db, + tenant_id=user.tenant, + user_id=user.id, + consulta_type="individual", + rut=rut, + endpoint="/api/v1/rut/lookup", + response_status=status.HTTP_200_OK, + request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, + response_data={"source": "cache"}, + processing_time_ms=int((time.perf_counter() - start) * 1000), + credits_used=0, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + metadata={"source": "cache"}, + ) + return {**cached[0]["data"], "isFromCache": True} + + tenant_oid = to_object_id(user.tenant) + now = dt.datetime.now(dt.timezone.utc) + + tenant_after = await db["tenants"].find_one_and_update( + {"_id": tenant_oid, "creditBalance.availableCredits": {"$gte": 1}}, + { + "$inc": { + "creditBalance.availableCredits": -1, + "creditBalance.totalCreditsUsed": 1, + }, + "$set": { + "creditBalance.lastCreditOperation": now, + "updatedAt": now, + }, + }, + return_document=ReturnDocument.AFTER, + ) + if not tenant_after: + await AuditService.log_consulta( + db=db, + tenant_id=user.tenant, + user_id=user.id, + consulta_type="individual", + rut=rut, + endpoint="/api/v1/rut/lookup", + response_status=status.HTTP_402_PAYMENT_REQUIRED, + request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, + processing_time_ms=int((time.perf_counter() - start) * 1000), + credits_used=0, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + metadata={"reason": "insufficient_credits"}, + ) + raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Insufficient credits") + + new_balance = int((tenant_after.get("creditBalance") or {}).get("availableCredits") or 0) + await db["creditoperations"].insert_one( + { + "tenantId": tenant_oid, + "userId": to_object_id(user.id), + "operationType": "evaluation", + "creditsChanged": -1, + "balanceAfter": new_balance, + "description": f"Valutazione singola per {rut}", + "metadata": {"evaluationType": "single", "supplierRut": rut}, + "createdAt": now, + } + ) + + job_doc: dict[str, Any] = { + "tenantId": user.tenant, + "status": "processing", + "type": "single", + "createdBy": user.id, + "totalEvaluations": 1, + "completedEvaluations": 0, + "failedEvaluations": 0, + "supplierData": {"rut": rut, "name": "N/A"}, + "createdAt": now, + "updatedAt": now, + } + job_insert = await db["evaluationjobs"].insert_one(job_doc) + job_id = str(job_insert.inserted_id) + + response_status = status.HTTP_200_OK + error_message: str | None = None + result_data: dict[str, Any] | None = None + try: + result_data = await legacy_service.lookup_rut( + rut=rut, + token=token, + is_monitoring=payload.isMonitoring, + evaluation_type="individual", + ) + ai_summary = result_data.get("aiAnalysis") + if isinstance(ai_summary, str) and ai_summary.strip(): + try: + await legacy_service.save_fastcheck_summary(rut=rut, summary=ai_summary, token=token) + except Exception: + pass + + await db["evaluationresults"].insert_one( + { + "jobId": job_insert.inserted_id, + "tenantId": user.tenant, + "rut": rut, + "name": None, + "status": "success", + "data": result_data, + "createdAt": now, + "updatedAt": now, + } + ) + await db["evaluationjobs"].update_one( + {"_id": job_insert.inserted_id, "tenantId": user.tenant}, + {"$set": {"status": "completed", "completedEvaluations": 1, "updatedAt": now}}, + ) + return {**result_data, "jobId": job_id, "isFromCache": False} + except HTTPException as e: + raise e + except Exception as e: + response_status = status.HTTP_502_BAD_GATEWAY + error_message = str(e) + await db["evaluationresults"].insert_one( + { + "jobId": job_insert.inserted_id, + "tenantId": user.tenant, + "rut": rut, + "name": None, + "status": "failed", + "error": error_message, + "createdAt": now, + "updatedAt": now, + } + ) + await db["evaluationjobs"].update_one( + {"_id": job_insert.inserted_id, "tenantId": user.tenant}, + {"$set": {"status": "completed", "failedEvaluations": 1, "updatedAt": now}}, + ) + raise HTTPException(status_code=response_status, detail="Legacy lookup failed") + finally: + processing_ms = int((time.perf_counter() - start) * 1000) + audit_response: dict[str, Any] | None = None + if result_data is not None: + audit_response = {"jobId": job_id} + await AuditService.log_consulta( + db=db, + tenant_id=user.tenant, + user_id=user.id, + consulta_type="individual", + rut=rut, + endpoint="/api/v1/rut/lookup", + response_status=response_status, + request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, + response_data=audit_response, + error_message=error_message, + processing_time_ms=processing_ms, + credits_used=1, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + metadata={"evaluationType": "single", "jobId": job_id}, + ) diff --git a/fastcheck_api/app/main.py b/fastcheck_api/app/main.py index 5f57af3..d2cef51 100644 --- a/fastcheck_api/app/main.py +++ b/fastcheck_api/app/main.py @@ -8,7 +8,7 @@ from fastapi import APIRouter from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse -from fastcheck_api.app.api.v1.routers import auth, checks, reports, usage +from fastcheck_api.app.api.v1.routers import auth, checks, reports, rut, usage from fastcheck_api.app.core.logging import configure_logging from fastcheck_api.app.core.mongodb import lifespan_db from fastcheck_api.app.services.legacy_service import legacy_service @@ -24,6 +24,7 @@ tags_metadata = [ {"name": "auth", "description": "Authentication and session management."}, {"name": "checks", "description": "Create checks (evaluations) and retrieve job/results."}, {"name": "reports", "description": "Fetch saved reports from existing persistence."}, + {"name": "rut", "description": "Legacy-compatible RUT lookup endpoints."}, {"name": "usage", "description": "Usage and credit balance information."}, ] @@ -156,5 +157,6 @@ async def prefixed_openapi(): api_v1.include_router(auth.router) api_v1.include_router(checks.router) api_v1.include_router(reports.router) +api_v1.include_router(rut.router) api_v1.include_router(usage.router) app.include_router(api_v1) diff --git a/fastcheck_api/app/services/legacy_service.py b/fastcheck_api/app/services/legacy_service.py index 3597c61..615bf81 100644 --- a/fastcheck_api/app/services/legacy_service.py +++ b/fastcheck_api/app/services/legacy_service.py @@ -17,11 +17,18 @@ class LegacyService: async def close(self) -> None: await self._client.aclose() - async def lookup_rut(self, *, rut: str, token: str) -> dict[str, Any]: + async def lookup_rut( + self, + *, + rut: str, + token: str, + is_monitoring: bool = False, + evaluation_type: str = "individual", + ) -> dict[str, Any]: resp = await self._client.post( "rut/lookup", headers={"Authorization": f"Bearer {token}"}, - json={"rut": rut, "isMonitoring": False, "type": "masiva"}, + json={"rut": rut, "isMonitoring": is_monitoring, "type": evaluation_type}, ) resp.raise_for_status() data = resp.json()