diff --git a/fastcheck_api/app/api/v1/routers/reports.py b/fastcheck_api/app/api/v1/routers/reports.py__ similarity index 100% rename from fastcheck_api/app/api/v1/routers/reports.py rename to fastcheck_api/app/api/v1/routers/reports.py__ diff --git a/fastcheck_api/app/api/v1/routers/rut.py b/fastcheck_api/app/api/v1/routers/rut.py index 5bffa96..7090e56 100644 --- a/fastcheck_api/app/api/v1/routers/rut.py +++ b/fastcheck_api/app/api/v1/routers/rut.py @@ -1,38 +1,174 @@ 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 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.dequienes_native_service import dequienes_native_service 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 +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"]) - -_bearer = HTTPBearer(auto_error=False) +logger = logging.getLogger("fastcheck.rut") class RutLookupRequest(BaseModel): rut: str = Field(examples=["12345678-9"]) - isMonitoring: bool = Field(default=False, description="Compatibility flag with legacy backend.") + 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 (single evaluation)", + summary="Lookup RUT", 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." + "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( @@ -40,32 +176,33 @@ async def lookup_rut( 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") + refresh = bool(payload.isMonitoring or payload.isRefreshing) + now = dt.datetime.now(dt.timezone.utc) - cached = ( - await db["evaluationresults"] - .find({"tenantId": user.tenant, "rut": rut, "status": "success"}) - .sort([("createdAt", -1)]) - .limit(1) - .to_list(length=1) + 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 cached and isinstance(cached[0], dict) and isinstance(cached[0].get("data"), dict): + 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="/api/v1/rut/lookup", + endpoint="/client-api-v1/rut/lookup", response_status=status.HTTP_200_OK, - request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, + request_data=jsonable(payload.model_dump()), response_data={"source": "cache"}, processing_time_ms=int((time.perf_counter() - start) * 1000), credits_used=0, @@ -73,147 +210,231 @@ async def lookup_rut( user_agent=request.headers.get("user-agent"), metadata={"source": "cache"}, ) - return {**cached[0]["data"], "isFromCache": True} + return _sanitize_result_payload(jsonable(existing)) - 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, + 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, + }, }, - "$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"}, + return_document=ReturnDocument.AFTER, ) - raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Insufficient credits") + 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}, - "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) + 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 - 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 + sheriff_payload = await sheriff_v2_native_service.query_rut(rut=rut, is_monitoring=payload.isMonitoring) + masked_sheriff_payload = _mask_providers_json(sheriff_payload) - 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, - } + 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=[]) ) - await db["evaluationjobs"].update_one( - {"_id": job_insert.inserted_id, "tenantId": user.tenant}, - {"$set": {"status": "completed", "completedEvaluations": 1, "updatedAt": now}}, + 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=[]) ) - 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, - } + + 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, ) - 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: + is_pep = _compute_is_pep_from_risk(risk_assessment) or _compute_is_pep_from_compliance(compliance) + 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} + 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="/api/v1/rut/lookup", + endpoint="/client-api-v1/rut/lookup", response_status=response_status, - request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, - response_data=audit_response, - error_message=error_message, + request_data=jsonable(payload.model_dump()), + response_data={"resultId": response_payload.get("_id")}, processing_time_ms=processing_ms, - credits_used=1, + 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", "jobId": job_id}, + 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]}) diff --git a/fastcheck_api/app/core/config.py b/fastcheck_api/app/core/config.py index a46abdd..9ca010d 100644 --- a/fastcheck_api/app/core/config.py +++ b/fastcheck_api/app/core/config.py @@ -29,6 +29,19 @@ class Settings(BaseSettings): legacy_base_url: str = Field(default="http://localhost:4040/api", validation_alias="LEGACY_BASE_URL") legacy_http_timeout_seconds: int = Field(default=30, validation_alias="LEGACY_HTTP_TIMEOUT_SECONDS") + sheriff_v2_base_url: str | None = Field(default=None, validation_alias="SHERIFF_V2_BASE_URL") + sheriff_v2_access_key: str | None = Field(default=None, validation_alias="SHERIFF_V2_ACCESS_KEY") + sheriff_v2_access_secret: str | None = Field(default=None, validation_alias="SHERIFF_V2_ACCESS_SECRET") + sheriff_v2_client_identifier: str = Field( + default="SheriffSecureClient-v1", + validation_alias="SHERIFF_V2_CLIENT_IDENTIFIER", + ) + sheriff_v2_token_ttl_seconds: int = Field(default=600, validation_alias="SHERIFF_V2_TOKEN_TTL_SECONDS") + + dequienes_base_url: str = Field(default="http://127.0.0.1:8023", validation_alias="DEQUIENES_BASE_URL") + dequienes_api_key: str | None = Field(default=None, validation_alias="DEQUIENES_API_KEY") + dequienes_http_timeout_seconds: int = Field(default=30, validation_alias="DEQUIENES_HTTP_TIMEOUT_SECONDS") + rabbitmq_enabled: bool = Field(default=True, validation_alias="RABBITMQ_ENABLED") rabbitmq_queue: str = Field(default="evaluation_queue", validation_alias="RABBITMQ_QUEUE") rabbitmq_url: str | None = Field(default=None, validation_alias="RABBITMQ_URL") diff --git a/fastcheck_api/app/main.py b/fastcheck_api/app/main.py index d2cef51..5737c42 100644 --- a/fastcheck_api/app/main.py +++ b/fastcheck_api/app/main.py @@ -8,10 +8,13 @@ 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, rut, usage +#from fastcheck_api.app.api.v1.routers import auth, checks, reports, rut, usage +from fastcheck_api.app.api.v1.routers import auth, checks, 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 +from fastcheck_api.app.services.dequienes_native_service import dequienes_native_service +from fastcheck_api.app.services.sheriff_v2_native_service import sheriff_v2_native_service from fastcheck_api.app.services.rabbitmq_service import rabbitmq_service from fastcheck_api.app.middleware.request_logging import RequestLoggingMiddleware from fastcheck_api.app.middleware.tenant_context import TenantContextMiddleware @@ -46,6 +49,8 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("RabbitMQ shutdown failed") await legacy_service.close() + await sheriff_v2_native_service.close() + await dequienes_native_service.aclose() app = FastAPI( @@ -156,7 +161,7 @@ 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(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/dequienes_native_service.py b/fastcheck_api/app/services/dequienes_native_service.py new file mode 100644 index 0000000..64aa46e --- /dev/null +++ b/fastcheck_api/app/services/dequienes_native_service.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import re +from typing import Any + +import httpx + +from fastcheck_api.app.core.config import settings + + +def _sanitize_rut_for_dequienes(rut: str) -> str: + rut_no_dots = (rut or "").replace(".", "").strip() + parts = rut_no_dots.split("-", 1) + numeric = re.sub(r"\D+", "", parts[0]) + numeric = re.sub(r"^0+", "", numeric) or "0" + return numeric + + +class DequienesNativeService: + def __init__(self) -> None: + self._client: httpx.AsyncClient | None = None + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient( + base_url=settings.dequienes_base_url.rstrip("/"), + timeout=httpx.Timeout(settings.dequienes_http_timeout_seconds), + ) + return self._client + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + def _headers(self) -> dict[str, str]: + headers: dict[str, str] = {"accept": "application/json"} + api_key = (settings.dequienes_api_key or "").strip() + if api_key: + headers["x-api-key"] = api_key + return headers + + async def relationships( + self, + *, + rut: str, + distance: int = 2, + relationship_direction: str = "BOTH", + one_path_per_node: bool = False, + ) -> dict[str, Any]: + rut_sanitized = _sanitize_rut_for_dequienes(rut) + client = self._get_client() + resp = await client.get( + f"/relationships/{rut_sanitized}", + params={ + "distance": int(distance), + "relationship_direction": relationship_direction, + "one_path_per_node": "true" if one_path_per_node else "false", + }, + headers=self._headers(), + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {"raw": data} + + async def legal_events(self, *, rut: str) -> dict[str, Any]: + rut_sanitized = _sanitize_rut_for_dequienes(rut) + client = self._get_client() + resp = await client.get(f"/api/legal-events/{rut_sanitized}", headers=self._headers()) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {"raw": data} + + +dequienes_native_service = DequienesNativeService() + diff --git a/fastcheck_api/app/services/risk_calculation_service.py b/fastcheck_api/app/services/risk_calculation_service.py new file mode 100644 index 0000000..388377f --- /dev/null +++ b/fastcheck_api/app/services/risk_calculation_service.py @@ -0,0 +1,697 @@ +from __future__ import annotations + +import datetime as dt +import re +from typing import Any + +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.utils.mongo import sanitize_rut + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _safe_get(value: Any, *path: str, default: Any = None) -> Any: + cur = value + for key in path: + if not isinstance(cur, dict): + return default + cur = cur.get(key) + return default if cur is None else cur + + +def _risk_from_impact(detected: bool, impacto: str) -> str: + if not detected: + return "bajo" + if impacto == "severo": + return "critico" + if impacto == "significativo": + return "alto" + return "bajo" + + +def _normalize_text(value: str) -> str: + value = (value or "").strip() + value = value.replace("Á", "A").replace("É", "E").replace("Í", "I").replace("Ó", "O").replace("Ú", "U") + value = value.replace("á", "a").replace("é", "e").replace("í", "i").replace("ó", "o").replace("ú", "u") + value = re.sub(r"[^\w\s]", " ", value, flags=re.UNICODE) + value = re.sub(r"\s+", " ", value, flags=re.UNICODE).strip() + return value + + +def _rut_variants(rut: str) -> list[str]: + rut = sanitize_rut(rut) + clean = rut.replace(".", "").replace("-", "").upper() + if len(clean) < 2: + return [rut] + dotted = f"{clean[:-1]:0>9}" + dotted = re.sub(r"^(\d{2})(\d{3})(\d{3})$", r"\1.\2.\3", dotted[:-1]) + "-" + clean[-1] + with_dash = f"{clean[:-1]}-{clean[-1]}" + return list(dict.fromkeys([rut, with_dash, clean, dotted])) + + +class RiskCalculationService: + @staticmethod + async def _load_equifax_normalized( + db: AsyncIOMotorDatabase, *, tenant_id: str, rut: str + ) -> dict[str, Any] | None: + rut = sanitize_rut(rut) + base = rut.replace(".", "").replace("-", "").upper() + rut_with_dash = f"{base[:-1]}-{base[-1]}" if len(base) > 1 else rut + rut_without_dash = base + query: dict[str, Any] = {"tenantId": tenant_id, "$or": [{"rut": rut}, {"rut": rut_with_dash}, {"rut": rut_without_dash}]} + doc = await db["equifax-responses"].find_one(query, sort=[("createdAt", -1)]) + if not doc or not isinstance(doc, dict): + return None + normalized = doc.get("normalizedData") + return normalized if isinstance(normalized, dict) else None + + @staticmethod + async def _load_antiunion_cases(db: AsyncIOMotorDatabase, *, rut: str) -> list[dict[str, Any]]: + variants = _rut_variants(rut) + ors: list[dict[str, Any]] = [] + for v in variants: + ors.append({"rut": {"$regex": f"^{re.escape(v)}$", "$options": "i"}}) + cursor = db["antiunioncases"].find({"$or": ors}).sort([("createdAt", -1)]).limit(200) + out: list[dict[str, Any]] = [] + async for doc in cursor: + if isinstance(doc, dict): + out.append(doc) + return out + + @staticmethod + async def _load_ley_records(db: AsyncIOMotorDatabase, *, collection: str, rut: str) -> list[dict[str, Any]]: + clean = rut.replace(".", "").upper() + cursor = db[collection].find({"rut": clean}).sort([("createdAt", -1)]).limit(200) + out: list[dict[str, Any]] = [] + async for doc in cursor: + if isinstance(doc, dict): + out.append(doc) + return out + + @staticmethod + async def _load_listas_propias(db: AsyncIOMotorDatabase, *, collection: str, tenant_id: str, rut: str) -> list[dict[str, Any]]: + clean = rut.replace(".", "").upper() + cursor = db[collection].find({"rut": clean, "tenant": tenant_id}).sort([("createdAt", -1)]).limit(200) + out: list[dict[str, Any]] = [] + async for doc in cursor: + if isinstance(doc, dict): + out.append(doc) + return out + + @staticmethod + async def _find_snifa_by_company_name( + db: AsyncIOMotorDatabase, + *, + razon_social: str, + field_flag: str, + ) -> list[dict[str, Any]]: + name = _normalize_text(razon_social) + if not name: + return [] + tokens = [t for t in name.split(" ") if len(t) >= 3] + tokens = tokens[:6] + if not tokens: + return [] + regex = ".*".join(re.escape(t) for t in tokens) + query = { + field_flag: {"$regex": r"^SI$", "$options": "i"}, + "razonSocial": {"$regex": regex, "$options": "i"}, + } + cursor = db["snifasancionatorios"].find(query).sort([("createdAt", -1)]).limit(200) + out: list[dict[str, Any]] = [] + async for doc in cursor: + if isinstance(doc, dict): + out.append(doc) + return out + + @staticmethod + def _evaluate_compliance_rules(log_entry: dict[str, Any]) -> list[dict[str, Any]]: + compliance_rules: list[dict[str, Any]] = [] + summary = _as_dict(_safe_get(log_entry, "summaryData", "data", default={})) + v2 = _as_dict(_safe_get(log_entry, "filteredDetails", "sheriffV2Data", default={})) + compliance_data = _as_dict(_safe_get(v2, "compliance", "data", default={})) + compliance_local = _as_dict(_safe_get(log_entry, "filteredDetails", "compliance", default={})) + + ley21121_records = _as_list(summary.get("ley21121Records") or []) + ley21121_detected = bool(summary.get("ley21121Detected")) + compliance_rules.append( + { + "label": "Condenas Ley 21.121", + "impacto": "severo", + "detected": ley21121_detected, + "risk": _risk_from_impact(ley21121_detected, "severo"), + "score": len(ley21121_records), + "details": ley21121_records, + } + ) + + ley20393_records = _as_list(summary.get("ley20393Records") or []) + ley20393_detected = bool(summary.get("ley20393Detected")) + compliance_rules.append( + { + "label": "Condenas Ley 20.393", + "impacto": "severo", + "detected": ley20393_detected, + "risk": _risk_from_impact(ley20393_detected, "severo"), + "score": len(ley20393_records), + "details": ley20393_records, + } + ) + + listas_int = _as_list(_safe_get(compliance_data, "listasInternacionales", "coincidencias", default=[])) + detected = len(listas_int) > 0 + compliance_rules.append( + { + "label": "Listas Internacionales", + "impacto": "severo", + "detected": detected, + "risk": _risk_from_impact(detected, "severo"), + "score": len(listas_int), + "details": listas_int, + } + ) + + sanciones = _as_list(_safe_get(summary, "snifaSancionesRecords", default=[])) + detected = len(sanciones) > 0 + compliance_rules.append( + { + "label": "Sanciones Medioambientales", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(sanciones), + "details": sanciones, + } + ) + + procesos = _as_list(_safe_get(summary, "snifaProcesoRecords", default=[])) + detected = len(procesos) > 0 + compliance_rules.append( + { + "label": "Proceso Sanciones Medioambientales", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(procesos), + "details": procesos, + } + ) + + lpaltos = _as_list(summary.get("lpaltosRecords") or []) + lpaltos_detected = bool(summary.get("lpaltosDetected")) + compliance_rules.append( + { + "label": "Listas Propias Alto Impacto", + "impacto": "severo", + "detected": lpaltos_detected, + "risk": _risk_from_impact(lpaltos_detected, "severo"), + "score": len(lpaltos), + "details": lpaltos, + } + ) + + lpmedios = _as_list(summary.get("lpmediosRecords") or []) + lpmedios_detected = bool(summary.get("lpmediosDetected")) + compliance_rules.append( + { + "label": "Listas Propias Mediano Impacto", + "impacto": "significativo", + "detected": lpmedios_detected, + "risk": _risk_from_impact(lpmedios_detected, "significativo"), + "score": len(lpmedios), + "details": lpmedios, + } + ) + + noticias = _as_list(_safe_get(compliance_local, "noticias", "coincidencias", default=[])) + detected = len(noticias) > 0 + compliance_rules.append( + { + "label": "Reputación Pública y Mediática", + "impacto": "severo", + "detected": detected, + "risk": _risk_from_impact(detected, "severo"), + "score": len(noticias), + "details": noticias, + } + ) + + pep = _as_list(_safe_get(compliance_data, "pepChile", "coincidencias", default=[])) + detected = len(pep) > 0 + compliance_rules.append( + { + "label": "PEP Chile", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(pep), + "details": pep, + } + ) + + penales = _as_list(_safe_get(compliance_data, "penal", "coincidencias", default=[])) + filtered_penales: list[Any] = [] + for d in penales: + posture = str(_safe_get(d, "postura", default="") or "").lower() + if posture and posture not in {"denunciante", "querellante"}: + filtered_penales.append(d) + detected = len(filtered_penales) > 0 + compliance_rules.append( + { + "label": "Causas Penales", + "impacto": "severo", + "detected": detected, + "risk": _risk_from_impact(detected, "severo"), + "score": len(filtered_penales), + "details": filtered_penales, + } + ) + + familiares = _as_list(_safe_get(compliance_data, "familiaresPep", "coincidencias", default=[])) + detected = len(familiares) > 0 + compliance_rules.append( + { + "label": "Familiares PEP", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(familiares), + "details": familiares, + } + ) + + return compliance_rules + + @staticmethod + def _evaluate_capital_humano_rules(log_entry: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + antiunion = _as_list(_safe_get(log_entry, "antiunionCases", default=[])) + detected = len(antiunion) > 0 + out.append( + { + "label": "Condenas por Prácticas Antisindicales", + "impacto": "severo", + "detected": detected, + "risk": _risk_from_impact(detected, "severo"), + "score": len(antiunion), + "details": antiunion, + } + ) + + compliance_person_type = str(_safe_get(log_entry, "filteredDetails", "compliancePersonType", default="") or "").lower() + equifax = _as_dict(_safe_get(log_entry, "equifaxData", default={})) + bolab = _as_list(_safe_get(equifax, "allData", "commercialData", "credit", "debtsSummary", "bolab", "commercialBolab", default=[])) + deuda_previsional = [] + if compliance_person_type == "juridical": + for entry in bolab: + it = str(_safe_get(entry, "injuryType", default="") or _safe_get(entry, "injurytype", default="") or "").upper() + if it != "M": + deuda_previsional.append(entry) + detected = len(deuda_previsional) > 0 + out.append( + { + "label": "Deuda Previsional Publicada", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(deuda_previsional), + "details": deuda_previsional, + } + ) + + bolab_j = _as_list(_safe_get(log_entry, "filteredDetails", "bolabTypePersonaJuridica", default=[])) + bolab_n = _as_list(_safe_get(log_entry, "filteredDetails", "bolabTypePersonaNatural", default=[])) + multas = [] + for entry in [*bolab_j, *bolab_n]: + it = str(_safe_get(entry, "injuryType", default="") or _safe_get(entry, "injurytype", default="") or "").upper() + if it == "M": + multas.append(entry) + detected = len(multas) > 0 + out.append( + { + "label": "Multas Laborales", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(multas), + "details": multas, + } + ) + + v2 = _as_dict(_safe_get(log_entry, "filteredDetails", "sheriffV2Data", default={})) + mora_casos = _as_list(_safe_get(v2, "cobranzaLaboral", "moraPrevisional", "data", "casos", default=[])) + detected = len(mora_casos) > 0 + out.append( + { + "label": "Deuda Previsional Presunta", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(mora_casos), + "details": mora_casos, + } + ) + + return out + + @staticmethod + def _extract_cases(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, dict): + data = value.get("data") + if isinstance(data, dict): + cases = data.get("casos") + if isinstance(cases, list): + return cases + if isinstance(data, list): + return data + return [] + + @staticmethod + def _evaluate_legal_rules(log_entry: dict[str, Any]) -> list[dict[str, Any]]: + v2 = _as_dict(_safe_get(log_entry, "filteredDetails", "sheriffV2Data", default={})) + out: list[dict[str, Any]] = [] + + casos_quiebra = int(_safe_get(v2, "resumen", "data", "judicial", "casosQuiebra", default=0) or 0) + detected = casos_quiebra > 0 + out.append( + { + "label": "Quiebra Judicial", + "impacto": "severo", + "detected": detected, + "risk": _risk_from_impact(detected, "severo"), + "score": casos_quiebra, + "details": ["Existe quiebra judicial"] if detected else [], + } + ) + + equifax = _as_dict(_safe_get(log_entry, "equifaxData", default={})) + boletin_concursal = _as_list( + _safe_get( + equifax, + "allData", + "commercialData", + "credit", + "boletinConcursal", + "detailBoletinConcursal", + "commercialDetailBoletinConcursal", + default=[], + ) + ) + detected = len(boletin_concursal) > 0 + out.append( + { + "label": "Boletin Concursal", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(boletin_concursal), + "details": boletin_concursal, + } + ) + + civil_cases = RiskCalculationService._extract_cases(_safe_get(v2, "judicial", "civil", default={})) + detected = len(civil_cases) > 0 + out.append( + { + "label": "Causas Civiles", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(civil_cases), + "details": civil_cases, + } + ) + + laboral_cases = RiskCalculationService._extract_cases(_safe_get(v2, "judicial", "laboral", default={})) + detected = len(laboral_cases) > 0 + out.append( + { + "label": "Causas Laborales", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(laboral_cases), + "details": laboral_cases, + } + ) + + cobranza_cases = RiskCalculationService._extract_cases(_safe_get(v2, "judicial", "cobranza", default={})) + detected = len(cobranza_cases) > 0 + out.append( + { + "label": "Causas de Cobranza Laboral", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(cobranza_cases), + "details": cobranza_cases, + } + ) + + return out + + @staticmethod + def _evaluate_financiero_tributario_rules(log_entry: dict[str, Any]) -> list[dict[str, Any]]: + v2 = _as_dict(_safe_get(log_entry, "filteredDetails", "sheriffV2Data", default={})) + ident = _as_dict(_safe_get(v2, "resumen", "data", "identificacion", default={})) + out: list[dict[str, Any]] = [] + + observaciones = str(_safe_get(ident, "observaciones", default="") or "") + termino = "término de giro" in observaciones.lower() + out.append( + { + "label": "Término de Giro", + "impacto": "severo", + "detected": termino, + "risk": _risk_from_impact(termino, "severo"), + "score": 1 if termino else 0, + "details": observaciones if termino else [], + } + ) + + equifax = _as_dict(_safe_get(log_entry, "filteredDetails", "equifaxData", default={})) + icom = _as_list(_safe_get(equifax, "allData", "commercialData", "credit", "debtsSummary", "icom", "commercialIcom", default=[])) + if not icom: + icom = _as_list(_safe_get(equifax, "protestosMorosidadesPersonaNaturalList", default=[])) + detected = len(icom) > 0 + out.append( + { + "label": "Protestos y Morosidades", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": len(icom), + "details": icom, + } + ) + + inicio_actividades = _safe_get(ident, "inicioActividades", default=None) + actividad = _as_list(_safe_get(ident, "actividadEconomicaVigente", default=[])) + missing_inicio = inicio_actividades is None + out.append( + { + "label": "Inicio de Actividades", + "impacto": "significativo", + "detected": missing_inicio, + "risk": _risk_from_impact(missing_inicio, "significativo"), + "score": 1 if missing_inicio else 0, + "details": actividad if not missing_inicio else [], + } + ) + + situacion = str(_safe_get(ident, "situacionActual", default="") or "") + detected = bool(situacion) and not re.search(r"No se encuentra", situacion, flags=re.IGNORECASE) + out.append( + { + "label": "Contribuyente de difícil fiscalización", + "impacto": "significativo", + "detected": detected, + "risk": _risk_from_impact(detected, "significativo"), + "score": 1 if detected else 0, + "details": [{"situacionActual": situacion}] if detected else [], + } + ) + + return out + + @staticmethod + def _risk_summary( + compliance_rules: list[dict[str, Any]], + legal_rules: list[dict[str, Any]], + capital_humano_rules: list[dict[str, Any]], + financiero_tributario_rules: list[dict[str, Any]], + ) -> dict[str, Any]: + def _count_detected(rules: list[dict[str, Any]], impacto: str) -> int: + return sum(1 for r in rules if r.get("impacto") == impacto and bool(r.get("detected"))) + + total_severo = _count_detected(compliance_rules, "severo") + _count_detected(legal_rules, "severo") + _count_detected(capital_humano_rules, "severo") + _count_detected(financiero_tributario_rules, "severo") + total_significativo = _count_detected(compliance_rules, "significativo") + _count_detected(legal_rules, "significativo") + _count_detected(capital_humano_rules, "significativo") + _count_detected(financiero_tributario_rules, "significativo") + total_detected = ( + sum(1 for r in compliance_rules if r.get("detected")) + + sum(1 for r in legal_rules if r.get("detected")) + + sum(1 for r in capital_humano_rules if r.get("detected")) + + sum(1 for r in financiero_tributario_rules if r.get("detected")) + ) + total_parameters = len(compliance_rules) + len(legal_rules) + len(capital_humano_rules) + len(financiero_tributario_rules) + + semaphore = "green" + if total_severo > 0: + semaphore = "red" + elif total_significativo > 0.7 * total_parameters: + semaphore = "red" + elif total_significativo > 0.5 * total_parameters: + semaphore = "orange" + elif total_significativo > 0: + semaphore = "yellow" + + icon = "🟢" + desc = "Riego Bajo" + if semaphore == "red": + icon = "🔴" + desc = "Riesgo Crítico" + elif semaphore == "orange": + icon = "🟠" + desc = "Riesgo Alto" + elif semaphore == "yellow": + icon = "🟡" + desc = "Riesgo Medio" + + return { + "totalSevero": total_severo, + "totalSignificativo": total_significativo, + "totalDetected": total_detected, + "totalParameters": total_parameters, + "semaphore": semaphore, + "semaphoreIcon": icon, + "riskLevelDescription": desc, + } + + @staticmethod + def _build_doc_markdown(rut: str, razon_social: str | None, risk_summary: dict[str, Any], all_rules: dict[str, Any]) -> str: + company = razon_social or "N/A" + lines = [ + f"# Evaluación de Riesgo\n", + f"**RUT**: {rut}\n", + f"**Razón Social**: {company}\n", + f"**Semáforo**: {risk_summary.get('semaphoreIcon')} ({risk_summary.get('riskLevelDescription')})\n", + "\n", + "## Resumen\n", + f"- Total parámetros: {risk_summary.get('totalParameters')}\n", + f"- Detectados: {risk_summary.get('totalDetected')}\n", + f"- Severos detectados: {risk_summary.get('totalSevero')}\n", + f"- Significativos detectados: {risk_summary.get('totalSignificativo')}\n", + "\n", + "## Detalle de Reglas\n", + ] + for section_key, title in [ + ("complianceRules", "Compliance"), + ("legalRules", "Legal"), + ("capitalHumanoRules", "Capital Humano"), + ("financieroTributarioRules", "Financiero / Tributario"), + ]: + rules = _as_list(_safe_get(all_rules, section_key, default=[])) + lines.append(f"### {title}\n") + lines.append("| Parámetro | Impacto | Riesgo | Detectado |\n") + lines.append("|---|---|---|---|\n") + for r in rules: + label = str(_safe_get(r, "label", default="") or "") + impacto = str(_safe_get(r, "impacto", default="") or "") + risk = str(_safe_get(r, "risk", default="") or "") + detected = "SI" if bool(_safe_get(r, "detected", default=False)) else "NO" + lines.append(f"| {label} | {impacto} | {risk} | {detected} |\n") + lines.append("\n") + return "".join(lines) + + @staticmethod + async def calculate_risk( + db: AsyncIOMotorDatabase, + *, + tenant_id: str, + rut: str, + sheriff_v2_data: dict[str, Any], + filtered_details: dict[str, Any], + company_general_info: dict[str, Any] | None, + is_pep_only: bool, + ) -> dict[str, Any] | None: + if is_pep_only: + return None + + clean_rut = sanitize_rut(rut) + + ley21121 = await RiskCalculationService._load_ley_records(db, collection="ley21121s", rut=clean_rut) + ley20393 = await RiskCalculationService._load_ley_records(db, collection="ley20393s", rut=clean_rut) + lpaltos = await RiskCalculationService._load_listas_propias(db, collection="lpaltos", tenant_id=tenant_id, rut=clean_rut) + lpmedios = await RiskCalculationService._load_listas_propias(db, collection="lpmedios", tenant_id=tenant_id, rut=clean_rut) + antiunion = await RiskCalculationService._load_antiunion_cases(db, rut=clean_rut) + equifax = await RiskCalculationService._load_equifax_normalized(db, tenant_id=tenant_id, rut=clean_rut) + + razon_social = str(filtered_details.get("razonSocial") or "") + snifa_sanciones = await RiskCalculationService._find_snifa_by_company_name( + db, razon_social=razon_social, field_flag="fastCheckSanciones" + ) + snifa_procesos = await RiskCalculationService._find_snifa_by_company_name( + db, razon_social=razon_social, field_flag="fastCheckProcesoSancionatorio" + ) + + summary_data = { + "ley21121Detected": len(ley21121) > 0, + "ley21121Records": ley21121, + "ley20393Detected": len(ley20393) > 0, + "ley20393Records": ley20393, + "lpaltosDetected": len(lpaltos) > 0, + "lpaltosRecords": lpaltos, + "lpmediosDetected": len(lpmedios) > 0, + "lpmediosRecords": lpmedios, + "snifaSancionesRecords": snifa_sanciones, + "snifaProcesoRecords": snifa_procesos, + } + + log_entry: dict[str, Any] = { + "rut": clean_rut, + "tenantId": tenant_id, + "summaryData": {"data": summary_data}, + "filteredDetails": {**filtered_details, "sheriffV2Data": sheriff_v2_data}, + "companyGeneralInfo": company_general_info or {}, + "antiunionCases": antiunion, + "equifaxData": equifax or {}, + } + + if equifax: + log_entry["filteredDetails"]["equifaxData"] = equifax + if "bolabTypePersonaJuridica" in equifax: + log_entry["filteredDetails"]["bolabTypePersonaJuridica"] = _as_list(equifax.get("bolabTypePersonaJuridica")) + if "bolabTypePersonaNatural" in equifax: + log_entry["filteredDetails"]["bolabTypePersonaNatural"] = _as_list(equifax.get("bolabTypePersonaNatural")) + if "protestosMorosidadesPersonaNaturalList" in equifax: + log_entry["filteredDetails"]["protestosMorosidadesPersonaNaturalList"] = _as_list(equifax.get("protestosMorosidadesPersonaNaturalList")) + + compliance_rules = RiskCalculationService._evaluate_compliance_rules(log_entry) + legal_rules = RiskCalculationService._evaluate_legal_rules(log_entry) + capital_rules = RiskCalculationService._evaluate_capital_humano_rules(log_entry) + financiero_rules = RiskCalculationService._evaluate_financiero_tributario_rules(log_entry) + + all_rules = { + "complianceRules": compliance_rules, + "legalRules": legal_rules, + "capitalHumanoRules": capital_rules, + "financieroTributarioRules": financiero_rules, + } + risk_summary = RiskCalculationService._risk_summary(compliance_rules, legal_rules, capital_rules, financiero_rules) + summary_md = RiskCalculationService._build_doc_markdown(clean_rut, razon_social or None, risk_summary, all_rules) + + return { + "riskSummary": risk_summary, + "allRules": all_rules, + "summaryDocumentMD": summary_md, + "financialRisk": None, + } + diff --git a/fastcheck_api/app/services/sheriff_v2_native_service.py b/fastcheck_api/app/services/sheriff_v2_native_service.py new file mode 100644 index 0000000..6eb4a27 --- /dev/null +++ b/fastcheck_api/app/services/sheriff_v2_native_service.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import httpx + +from fastcheck_api.app.core.config import settings + + +class SheriffV2NativeService: + def __init__(self) -> None: + self._client = httpx.AsyncClient(timeout=60) + self._token: str | None = None + self._token_expires_at: float = 0.0 + + async def close(self) -> None: + await self._client.aclose() + + def _base_url(self) -> str: + base_url = (settings.sheriff_v2_base_url or "").strip() + if not base_url: + raise RuntimeError("Missing SHERIFF_V2_BASE_URL") + return base_url.rstrip("/") + + def _client_identifier(self) -> str: + return (settings.sheriff_v2_client_identifier or "SheriffSecureClient-v1").strip() + + def _credentials(self) -> tuple[str, str]: + access_key = (settings.sheriff_v2_access_key or "").strip() + access_secret = (settings.sheriff_v2_access_secret or "").strip() + if not access_key or not access_secret: + raise RuntimeError("Missing shf V2 access credentials") + return access_key, access_secret + + async def _get_token(self) -> str: + now = time.time() + if self._token and now < (self._token_expires_at - 30): + return self._token + + base_url = self._base_url() + access_key, access_secret = self._credentials() + url = f"{base_url}/api/clients/v2/apiCredentials/getToken" + headers = { + "Content-Type": "application/json", + "accept": "application/json", + "x-client-identifier": self._client_identifier(), + } + resp = await self._client.post(url, headers=headers, json={"accessKey": access_key, "accessSecret": access_secret}) + resp.raise_for_status() + payload = resp.json() + token: str | None = None + if isinstance(payload, dict): + raw = payload.get("data") or payload.get("token") or payload.get("access_token") or payload.get("accessToken") + if isinstance(raw, str): + token = raw + elif isinstance(raw, dict): + inner = raw.get("token") or raw.get("access_token") or raw.get("accessToken") + if isinstance(inner, str): + token = inner + token = (token or "").strip() + if not token: + raise RuntimeError("shf token response invalid") + + ttl = int(settings.sheriff_v2_token_ttl_seconds or 600) + self._token = token + self._token_expires_at = now + ttl + return token + + async def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None) -> dict[str, Any]: + base_url = self._base_url() + url = f"{base_url}{path}" + token = await self._get_token() + headers = { + "accept": "application/json", + "Authorization": f"Bearer {token}", + "x-client-identifier": self._client_identifier(), + } + resp = await self._client.request(method, url, headers=headers, params=params, json=json) + if resp.status_code == 401: + self._token = None + token = await self._get_token() + headers["Authorization"] = f"Bearer {token}" + resp = await self._client.request(method, url, headers=headers, params=params, json=json) + if resp.status_code >= 400: + body = (resp.text or "")[:2000] + raise RuntimeError(f"shf {resp.status_code} for {method} {path}: {body}") + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("Unexpected shf response") + return data + + async def query_rut(self, *, rut: str, is_monitoring: bool = False) -> dict[str, Any]: + rut = (rut or "").strip() + if not rut: + raise ValueError("Missing rut") + + cargar = await self._request( + "POST", + "/api/clients/v2/helper/cargarRut", + json={"rut": rut, "isMonitoring": bool(is_monitoring), "includeEquifax": False}, + ) + await asyncio.sleep(2) + + resumen = await self._request( + "GET", + "/api/clients/v2/helper/resumen", + params={"rut": rut, "complete": "True"}, + ) + + result: dict[str, Any] = {"success": True, "cargarRut": cargar, "resumen": resumen} + + ok = True + if isinstance(cargar, dict) and ("success" in cargar) and (not bool(cargar.get("success"))): + ok = False + if isinstance(resumen, dict) and ("success" in resumen) and (not bool(resumen.get("success"))): + ok = False + + if ok: + judicial_civil, judicial_cobranza, judicial_laboral, multa_laboral, mora_previsional, compliance, malla_societaria, credit_score = await asyncio.gather( + self._request("GET", f"/api/clients/v2/helper/judicial/{rut}/civil"), + self._request("GET", f"/api/clients/v2/helper/judicial/{rut}/cobranza"), + self._request("GET", f"/api/clients/v2/helper/judicial/{rut}/laboral"), + self._request("GET", f"/api/clients/v2/helper/cobranzaLaboral/{rut}/multaLaboral"), + self._request("GET", f"/api/clients/v2/helper/cobranzaLaboral/{rut}/moraPrevisional"), + self._request("GET", f"/api/clients/v2/helper/compliance/{rut}"), + self._request("GET", f"/api/clients/v2/helper/legal/{rut}/mallaSocietaria"), + self._request("GET", f"/api/clients/v2/creditScore/{rut}"), + ) + result.update( + { + "judicial": {"civil": judicial_civil, "cobranza": judicial_cobranza, "laboral": judicial_laboral}, + "cobranzaLaboral": {"multaLaboral": multa_laboral, "moraPrevisional": mora_previsional}, + "compliance": compliance, + "legal": {"mallaSocietaria": malla_societaria}, + "creditScore": credit_score, + } + ) + + return result + + +sheriff_v2_native_service = SheriffV2NativeService() diff --git a/fastcheck_api/app/utils/mongo.py b/fastcheck_api/app/utils/mongo.py index a3fbf75..4e4377d 100644 --- a/fastcheck_api/app/utils/mongo.py +++ b/fastcheck_api/app/utils/mongo.py @@ -25,7 +25,23 @@ def jsonable(value: Any) -> Any: def sanitize_rut(value: str) -> str: - digits = "".join(re.findall(r"\d", value or "")) - if len(digits) <= 1: - return digits - return f"{digits[:-1]}-{digits[-1]}" + raw = (value or "").strip().replace(".", "").replace(" ", "").upper() + if not raw: + return "" + + if "-" in raw: + base, dv = raw.split("-", 1) + base_digits = "".join(re.findall(r"\d", base)) + dv_clean = "".join(re.findall(r"[0-9K]", dv))[:1] + if not base_digits: + return raw + base_digits = base_digits.lstrip("0") or "0" + return f"{base_digits}-{dv_clean}" if dv_clean else base_digits + + cleaned = "".join(re.findall(r"[0-9K]", raw)) + if len(cleaned) <= 1: + return cleaned + base_digits = "".join(re.findall(r"\d", cleaned[:-1])) + dv = cleaned[-1] + base_digits = base_digits.lstrip("0") or "0" + return f"{base_digits}-{dv}"