from __future__ import annotations import logging import datetime as dt import time import re from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status 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.dequienes_native_service import dequienes_native_service from fastcheck_api.app.services.audit_service import AuditService from fastcheck_api.app.services.risk_calculation_service import RiskCalculationService from fastcheck_api.app.services.sheriff_v2_native_service import sheriff_v2_native_service from fastcheck_api.app.utils.mongo import jsonable, sanitize_rut, to_object_id router = APIRouter(prefix="/rut", tags=["rut"]) logger = logging.getLogger("fastcheck.rut") class RutLookupRequest(BaseModel): rut: str = Field(examples=["12345678-9"]) isMonitoring: bool = Field(default=False) isRefreshing: bool = Field(default=False) isPep: bool = Field(default=False) type: str = Field(default="individual") def _as_list(value: Any) -> list[Any]: return value if isinstance(value, list) else [] def _safe_get(dct: Any, *path: str, default: Any = None) -> Any: cur = dct for key in path: if not isinstance(cur, dict): return default cur = cur.get(key) return default if cur is None else cur def _extract_socios_from_diario_oficial(rut: str, diario_oficial: Any, limit: int = 200) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for item in _as_list(diario_oficial): for socio in _as_list(_safe_get(item, "socios", default=[])): socio_rut = sanitize_rut(str(_safe_get(socio, "rut", default="") or "")) if not socio_rut or socio_rut == rut: continue if socio_rut in seen: continue seen.add(socio_rut) name = str(_safe_get(socio, "nombre", default="") or _safe_get(socio, "name", default="") or "").strip() out.append({"name": name or "N/A", "rut": socio_rut, "personType": None, "isNaturalPerson": None}) if len(out) >= limit: return out return out def _compute_is_pep_from_compliance(compliance: Any) -> bool: pep = _as_list(_safe_get(compliance, "pepChile", "coincidencias", default=[])) fam = _as_list(_safe_get(compliance, "familiaresPep", "coincidencias", default=[])) pub = _as_list(_safe_get(compliance, "funcionariosPublicos", "coincidencias", default=[])) return bool(pep or fam or pub) def _simple_risk_assessment(filtered_details: dict[str, Any]) -> dict[str, Any]: compliance = filtered_details.get("compliance") or {} is_pep = _compute_is_pep_from_compliance(compliance) has_judicial = bool(filtered_details.get("civilCasesData") or filtered_details.get("laboralCasesData") or filtered_details.get("cobranzaCasesData")) has_labor_collection = bool(filtered_details.get("moraPrevisionalCasesData") or filtered_details.get("multaLaboralCasesData")) semaphore = "bajo" if is_pep: semaphore = "critico" elif has_judicial or has_labor_collection: semaphore = "alto" return { "riskSummary": {"semaphore": semaphore}, "allRules": { "complianceRules": [], "legalRules": [], "capitalHumanoRules": [], "financieroTributarioRules": [], }, "summaryDocumentMD": "", } def _compute_is_pep_from_risk(risk_assessment: dict[str, Any] | None) -> bool: if not isinstance(risk_assessment, dict): return False rules = _safe_get(risk_assessment, "allRules", "complianceRules", default=[]) for r in _as_list(rules): label = str(_safe_get(r, "label", default="") or "") if label in {"PEP Chile", "Familiares PEP"} and bool(_safe_get(r, "detected", default=False)): return True return False _PROV_REPLACEMENTS: dict[str, str] = { "sheriff": "sProv", "dequienes": "dProv", } _PROV_PATTERN = re.compile("|".join(re.escape(k) for k in _PROV_REPLACEMENTS), re.IGNORECASE) def _mask_providers_text(value: str) -> str: return _PROV_PATTERN.sub(lambda m: _PROV_REPLACEMENTS[m.group(0).lower()], value) def _mask_providers_json(value: Any) -> Any: if isinstance(value, str): return _mask_providers_text(value) if isinstance(value, list): return [_mask_providers_json(v) for v in value] if isinstance(value, dict): out: dict[Any, Any] = {} for k, v in value.items(): masked_key = _mask_providers_text(k) if isinstance(k, str) else k out[masked_key] = _mask_providers_json(v) return out return value def _normalize_date(value: str | None) -> str | None: if not value: return None raw = str(value).strip() if not raw: return None if re.match(r"^\d{4}-\d{2}-\d{2}$", raw): return raw m = re.match(r"^(\d{2})-(\d{2})-(\d{4})$", raw) if m: dd, mm, yyyy = m.group(1), m.group(2), m.group(3) return f"{yyyy}-{mm}-{dd}" m = re.match(r"^(\d{2})/(\d{2})/(\d{4})$", raw) if m: dd, mm, yyyy = m.group(1), m.group(2), m.group(3) return f"{yyyy}-{mm}-{dd}" return None def _strip_cache_info_json(value: Any) -> Any: if isinstance(value, list): return [_strip_cache_info_json(v) for v in value] if isinstance(value, dict): out: dict[Any, Any] = {} for k, v in value.items(): if k == "_cache_info": continue out[k] = _strip_cache_info_json(v) return out return value def _sanitize_result_payload(payload: Any) -> Any: return _mask_providers_json(_strip_cache_info_json(payload)) @router.post( "/lookup", summary="Lookup RUT", description=( "Runs a native RUT lookup using Sheriff V2 APIs and persists the result in MongoDB (same persistence model as the legacy Node implementation)." ), ) async def lookup_rut( request: Request, payload: RutLookupRequest, user: Annotated[CurrentUser, Depends(require_permission("rut:lookup"))], db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], ): start = time.perf_counter() rut = sanitize_rut(payload.rut) refresh = bool(payload.isMonitoring or payload.isRefreshing) now = dt.datetime.now(dt.timezone.utc) if refresh: await db["results"].update_many( {"rut": rut, "tenantId": user.tenant, "archived": {"$ne": True}}, {"$set": {"archived": True, "archivedAt": now, "updatedAt": now}}, ) existing = await db["results"].find_one( {"rut": rut, "tenantId": user.tenant, "archived": {"$ne": True}}, sort=[("createdAt", -1)], ) if existing and not refresh: await AuditService.log_consulta( db=db, tenant_id=user.tenant, user_id=user.id, consulta_type="individual", rut=rut, endpoint="/client-api-v1/rut/lookup", response_status=status.HTTP_200_OK, request_data=jsonable(payload.model_dump()), 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 _sanitize_result_payload(jsonable(existing)) should_deduct = (not payload.isPep) and (payload.isRefreshing or not refresh) if should_deduct: tenant_oid = to_object_id(user.tenant) 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="/client-api-v1/rut/lookup", response_status=status.HTTP_402_PAYMENT_REQUIRED, request_data=jsonable(payload.model_dump()), 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={"message": "Insufficient credits", "availableCredits": int((tenant_after or {}).get("creditBalance", {}).get("availableCredits", 0))}, ) 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, "source": "fastcheck_api"}, "createdAt": now, } ) response_status = status.HTTP_200_OK error_message: str | None = None try: sheriff_payload = await sheriff_v2_native_service.query_rut(rut=rut, is_monitoring=payload.isMonitoring) masked_sheriff_payload = _mask_providers_json(sheriff_payload) resumen_data = _safe_get(masked_sheriff_payload, "resumen", "data", default={}) data_sii = _safe_get(resumen_data, "identificacion", default={}) credit_scoring = _safe_get(masked_sheriff_payload, "creditScore", "data", default={}) compliance = _safe_get(masked_sheriff_payload, "compliance", "data", default={}) filtered_details: dict[str, Any] = { "rut": _safe_get(resumen_data, "rut", default=rut), "razonSocial": _safe_get(data_sii, "razonSocial", default=None), "presentaActividades": _safe_get(data_sii, "presentaActividades", default=None), "inicioActividades": _safe_get(data_sii, "inicioActividades", default=None), "autorizadoMonedaExtranjera": _safe_get(data_sii, "autorizadoMonedaExtranjera", default=None), "empresaMenor": _safe_get(data_sii, "empresaMenor", default=None), "observaciones": _safe_get(data_sii, "observaciones", default=None), "notas": _safe_get(data_sii, "notas", default=None), "situacionActual": _safe_get(data_sii, "situacionActual", default=None), "fetchedAt": now.date().isoformat(), "creditScoringData": credit_scoring, "compliance": compliance, "boletinComercialSummary": _safe_get(masked_sheriff_payload, "resumen", "boletinComercial", default={}), "boletinLaboralSummary": _safe_get(resumen_data, "boletinLaboral", default={}), "boletinLaboralLastUpdate": _safe_get(resumen_data, "boletinLaboral", "ultimaActualizacion", default=None), "vehiclesSummary": _safe_get(resumen_data, "bienes", "vehiculos", default={}), "propertiesSummary": _safe_get(resumen_data, "bienes", "propiedades", default={}), "deudaBancariaSummary": _safe_get(masked_sheriff_payload, "resumen", "deudaBancaria", default={}), "mallaSocietariaData": _safe_get(masked_sheriff_payload, "legal", "mallaSocietaria", "data", default={}), "officialDiaryData": _safe_get(masked_sheriff_payload, "legal", "mallaSocietaria", "data", "diarioOficial", default=[]), "civilCasesData": _safe_get(masked_sheriff_payload, "judicial", "civil", "data", default={}), "laboralCasesData": _safe_get(masked_sheriff_payload, "judicial", "laboral", "data", default={}), "cobranzaCasesData": _safe_get(masked_sheriff_payload, "judicial", "cobranza", "data", default={}), "moraPrevisionalCasesData": _safe_get(masked_sheriff_payload, "cobranzaLaboral", "moraPrevisional", "data", default={}), "multaLaboralCasesData": _safe_get(masked_sheriff_payload, "cobranzaLaboral", "multaLaboral", "data", default={}), } compliance_person_type = "juridical" if bool(_safe_get(resumen_data, "esPersonaJuridica", default=False)) else "natural" filtered_details["compliancePersonType"] = compliance_person_type filtered_details["complianceSelectedRowsPepChile"] = _as_list(_safe_get(compliance, "pepChile", "coincidencias", default=[])) filtered_details["complianceSelectedRowsPublicOfficial"] = _as_list( _safe_get(compliance, "funcionariosPublicos", "coincidencias", default=[]) ) filtered_details["complianceSelectedRowsPepChileFamily"] = _as_list(_safe_get(compliance, "familiaresPep", "coincidencias", default=[])) filtered_details["complianceSelectedRowsPenal"] = _as_list(_safe_get(compliance, "penal", "coincidencias", default=[])) filtered_details["complianceSelectedlistasInternacionales"] = _as_list( _safe_get(compliance, "listasInternacionales", "coincidencias", default=[]) ) company_general_info: dict[str, Any] = { "rapresentanteLegal": "N/D", "rapresentanteLegalRut": "N/D", "inicioActividades": filtered_details.get("inicioActividades") or "", "fechaDeConstitucion": "", "empresaMenor": filtered_details.get("empresaMenor") or "", "razonSocial": filtered_details.get("razonSocial") or "", "socios": [], "administradores": [], "rangoVentas": None, "tamanoEmpresa": None, "iconImpresa": None, "cantidadTrabajadores": None, } diario = filtered_details.get("officialDiaryData") company_general_info["socios"] = _extract_socios_from_diario_oficial(rut, diario) if not payload.isPep: try: deq_relationships = await dequienes_native_service.relationships( rut=rut, distance=2, relationship_direction="BOTH", one_path_per_node=False, ) filtered_details["dequienesRelationships"] = _mask_providers_json(_strip_cache_info_json(deq_relationships)) except Exception: logger.exception("Dequienes relationships failed") try: deq_legal = await dequienes_native_service.legal_events(rut=rut) filtered_details["dequienesLegalEvents"] = _mask_providers_json(_strip_cache_info_json(deq_legal)) raw_date = deq_legal.get("constitution_date") if isinstance(deq_legal, dict) else None normalized = _normalize_date(raw_date if isinstance(raw_date, str) else None) if normalized and not company_general_info.get("fechaDeConstitucion"): company_general_info["fechaDeConstitucion"] = normalized except Exception: logger.exception("Dequienes legal events failed") if not company_general_info.get("fechaDeConstitucion"): company_general_info["fechaDeConstitucion"] = "No hay informacion" risk_assessment = await RiskCalculationService.calculate_risk( db, tenant_id=user.tenant, rut=rut, sheriff_v2_data=masked_sheriff_payload, filtered_details=filtered_details, company_general_info=company_general_info, is_pep_only=payload.isPep, ) is_pep = _compute_is_pep_from_risk(risk_assessment) or _compute_is_pep_from_compliance(compliance) processing_ms = int((time.perf_counter() - start) * 1000) result_doc: dict[str, Any] = { "rut": rut, "tenantId": user.tenant, "queryType": "derived_socio_pep" if payload.isPep else "primary", "sheriffLogData": { "rut": rut, "tenantId": user.tenant, "fetchedAt": now, "companyGeneralInfo": company_general_info if not payload.isPep else None, "filteredDetails": filtered_details, "rawSheriffV2": masked_sheriff_payload, }, "details": filtered_details, "companyGeneralInfo": None if payload.isPep else company_general_info, "riskAssessment": risk_assessment, "isPep": bool(is_pep), "userEmail": user.email, "status": 200, "type": payload.type, "processingTime": f"{processing_ms}ms", "creditsConsumed": 1 if should_deduct else 0, "archived": False, "createdAt": now, "updatedAt": now, } insert = await db["results"].insert_one(result_doc) stored = await db["results"].find_one({"_id": insert.inserted_id}) response_payload = jsonable(stored) if stored else jsonable({**result_doc, "_id": str(insert.inserted_id)}) response_payload = _sanitize_result_payload(response_payload) await AuditService.log_consulta( db=db, tenant_id=user.tenant, user_id=user.id, consulta_type="individual", rut=rut, endpoint="/client-api-v1/rut/lookup", response_status=response_status, request_data=jsonable(payload.model_dump()), response_data={"resultId": response_payload.get("_id")}, processing_time_ms=processing_ms, credits_used=1 if should_deduct else 0, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), metadata={"evaluationType": "single", "source": "fastcheck_api"}, ) return response_payload except Exception as e: response_status = status.HTTP_502_BAD_GATEWAY error_message = str(e) logger.exception(f"Native lookup failed error={error_message[:500]}") await AuditService.log_consulta( db=db, tenant_id=user.tenant, user_id=user.id, consulta_type="individual", rut=rut, endpoint="/client-api-v1/rut/lookup", response_status=response_status, request_data=jsonable(payload.model_dump()), error_message=error_message[:2000], processing_time_ms=int((time.perf_counter() - start) * 1000), credits_used=1 if should_deduct else 0, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), metadata={"evaluationType": "single", "source": "fastcheck_api"}, ) raise HTTPException(status_code=response_status, detail={"message": "Native lookup failed", "error": error_message[:2000]})