fastcheck/fastcheck_api/app/api/v1/routers/rut.py
2026-04-30 09:00:15 -04:00

441 lines
19 KiB
Python

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]})