fastcheck_api fix

This commit is contained in:
valenti 2026-04-30 09:00:15 -04:00
parent 21ae30fcdf
commit f52c920b98
8 changed files with 1320 additions and 148 deletions

View File

@ -1,38 +1,174 @@
from __future__ import annotations from __future__ import annotations
import logging
import datetime as dt import datetime as dt
import time import time
import re
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from motor.motor_asyncio import AsyncIOMotorDatabase from motor.motor_asyncio import AsyncIOMotorDatabase
from pymongo import ReturnDocument from pymongo import ReturnDocument
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from fastcheck_api.app.api.dependencies import CurrentUser, require_permission from fastcheck_api.app.api.dependencies import CurrentUser, require_permission
from fastcheck_api.app.core.mongodb import get_db 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.audit_service import AuditService
from fastcheck_api.app.services.legacy_service import legacy_service from fastcheck_api.app.services.risk_calculation_service import RiskCalculationService
from fastcheck_api.app.utils.mongo import sanitize_rut, to_object_id 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"]) router = APIRouter(prefix="/rut", tags=["rut"])
logger = logging.getLogger("fastcheck.rut")
_bearer = HTTPBearer(auto_error=False)
class RutLookupRequest(BaseModel): class RutLookupRequest(BaseModel):
rut: str = Field(examples=["12345678-9"]) 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( @router.post(
"/lookup", "/lookup",
summary="Lookup RUT (single evaluation)", summary="Lookup RUT",
description=( description=(
"Runs a single evaluation for a RUT using the legacy backend and persists the result in MongoDB.\n\n" "Runs a native RUT lookup using Sheriff V2 APIs and persists the result in MongoDB (same persistence model as the legacy Node implementation)."
"If a successful result for the same RUT already exists for the tenant, returns it without consuming credits."
), ),
) )
async def lookup_rut( async def lookup_rut(
@ -40,32 +176,33 @@ async def lookup_rut(
payload: RutLookupRequest, payload: RutLookupRequest,
user: Annotated[CurrentUser, Depends(require_permission("rut:lookup"))], user: Annotated[CurrentUser, Depends(require_permission("rut:lookup"))],
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
): ):
start = time.perf_counter() start = time.perf_counter()
rut = sanitize_rut(payload.rut) rut = sanitize_rut(payload.rut)
token = credentials.credentials if credentials else None refresh = bool(payload.isMonitoring or payload.isRefreshing)
if not token: now = dt.datetime.now(dt.timezone.utc)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No token provided")
cached = ( if refresh:
await db["evaluationresults"] await db["results"].update_many(
.find({"tenantId": user.tenant, "rut": rut, "status": "success"}) {"rut": rut, "tenantId": user.tenant, "archived": {"$ne": True}},
.sort([("createdAt", -1)]) {"$set": {"archived": True, "archivedAt": now, "updatedAt": now}},
.limit(1)
.to_list(length=1)
) )
if cached and isinstance(cached[0], dict) and isinstance(cached[0].get("data"), dict):
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( await AuditService.log_consulta(
db=db, db=db,
tenant_id=user.tenant, tenant_id=user.tenant,
user_id=user.id, user_id=user.id,
consulta_type="individual", consulta_type="individual",
rut=rut, rut=rut,
endpoint="/api/v1/rut/lookup", endpoint="/client-api-v1/rut/lookup",
response_status=status.HTTP_200_OK, response_status=status.HTTP_200_OK,
request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, request_data=jsonable(payload.model_dump()),
response_data={"source": "cache"}, response_data={"source": "cache"},
processing_time_ms=int((time.perf_counter() - start) * 1000), processing_time_ms=int((time.perf_counter() - start) * 1000),
credits_used=0, credits_used=0,
@ -73,11 +210,11 @@ async def lookup_rut(
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
metadata={"source": "cache"}, metadata={"source": "cache"},
) )
return {**cached[0]["data"], "isFromCache": True} 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_oid = to_object_id(user.tenant)
now = dt.datetime.now(dt.timezone.utc)
tenant_after = await db["tenants"].find_one_and_update( tenant_after = await db["tenants"].find_one_and_update(
{"_id": tenant_oid, "creditBalance.availableCredits": {"$gte": 1}}, {"_id": tenant_oid, "creditBalance.availableCredits": {"$gte": 1}},
{ {
@ -99,16 +236,19 @@ async def lookup_rut(
user_id=user.id, user_id=user.id,
consulta_type="individual", consulta_type="individual",
rut=rut, rut=rut,
endpoint="/api/v1/rut/lookup", endpoint="/client-api-v1/rut/lookup",
response_status=status.HTTP_402_PAYMENT_REQUIRED, response_status=status.HTTP_402_PAYMENT_REQUIRED,
request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, request_data=jsonable(payload.model_dump()),
processing_time_ms=int((time.perf_counter() - start) * 1000), processing_time_ms=int((time.perf_counter() - start) * 1000),
credits_used=0, credits_used=0,
ip_address=request.client.host if request.client else None, ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
metadata={"reason": "insufficient_credits"}, metadata={"reason": "insufficient_credits"},
) )
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="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) new_balance = int((tenant_after.get("creditBalance") or {}).get("availableCredits") or 0)
await db["creditoperations"].insert_one( await db["creditoperations"].insert_one(
@ -119,101 +259,182 @@ async def lookup_rut(
"creditsChanged": -1, "creditsChanged": -1,
"balanceAfter": new_balance, "balanceAfter": new_balance,
"description": f"Valutazione singola per {rut}", "description": f"Valutazione singola per {rut}",
"metadata": {"evaluationType": "single", "supplierRut": rut}, "metadata": {"evaluationType": "single", "supplierRut": rut, "source": "fastcheck_api"},
"createdAt": now, "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 response_status = status.HTTP_200_OK
error_message: str | None = None error_message: str | None = None
result_data: dict[str, Any] | None = None
try: try:
result_data = await legacy_service.lookup_rut( sheriff_payload = await sheriff_v2_native_service.query_rut(rut=rut, is_monitoring=payload.isMonitoring)
rut=rut, masked_sheriff_payload = _mask_providers_json(sheriff_payload)
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( resumen_data = _safe_get(masked_sheriff_payload, "resumen", "data", default={})
{ data_sii = _safe_get(resumen_data, "identificacion", default={})
"jobId": job_insert.inserted_id, credit_scoring = _safe_get(masked_sheriff_payload, "creditScore", "data", default={})
"tenantId": user.tenant, compliance = _safe_get(masked_sheriff_payload, "compliance", "data", default={})
"rut": rut,
"name": None, filtered_details: dict[str, Any] = {
"status": "success", "rut": _safe_get(resumen_data, "rut", default=rut),
"data": result_data, "razonSocial": _safe_get(data_sii, "razonSocial", default=None),
"createdAt": now, "presentaActividades": _safe_get(data_sii, "presentaActividades", default=None),
"updatedAt": now, "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( filtered_details["complianceSelectedRowsPepChileFamily"] = _as_list(_safe_get(compliance, "familiaresPep", "coincidencias", default=[]))
{"_id": job_insert.inserted_id, "tenantId": user.tenant}, filtered_details["complianceSelectedRowsPenal"] = _as_list(_safe_get(compliance, "penal", "coincidencias", default=[]))
{"$set": {"status": "completed", "completedEvaluations": 1, "updatedAt": now}}, filtered_details["complianceSelectedlistasInternacionales"] = _as_list(
_safe_get(compliance, "listasInternacionales", "coincidencias", default=[])
) )
return {**result_data, "jobId": job_id, "isFromCache": False}
except HTTPException as e: company_general_info: dict[str, Any] = {
raise e "rapresentanteLegal": "N/D",
except Exception as e: "rapresentanteLegalRut": "N/D",
response_status = status.HTTP_502_BAD_GATEWAY "inicioActividades": filtered_details.get("inicioActividades") or "",
error_message = str(e) "fechaDeConstitucion": "",
await db["evaluationresults"].insert_one( "empresaMenor": filtered_details.get("empresaMenor") or "",
{ "razonSocial": filtered_details.get("razonSocial") or "",
"jobId": job_insert.inserted_id, "socios": [],
"tenantId": user.tenant, "administradores": [],
"rut": rut, "rangoVentas": None,
"name": None, "tamanoEmpresa": None,
"status": "failed", "iconImpresa": None,
"error": error_message, "cantidadTrabajadores": None,
"createdAt": now,
"updatedAt": now,
} }
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,
) )
await db["evaluationjobs"].update_one( filtered_details["dequienesRelationships"] = _mask_providers_json(_strip_cache_info_json(deq_relationships))
{"_id": job_insert.inserted_id, "tenantId": user.tenant}, except Exception:
{"$set": {"status": "completed", "failedEvaluations": 1, "updatedAt": now}}, 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,
) )
raise HTTPException(status_code=response_status, detail="Legacy lookup failed") is_pep = _compute_is_pep_from_risk(risk_assessment) or _compute_is_pep_from_compliance(compliance)
finally:
processing_ms = int((time.perf_counter() - start) * 1000) processing_ms = int((time.perf_counter() - start) * 1000)
audit_response: dict[str, Any] | None = None result_doc: dict[str, Any] = {
if result_data is not None: "rut": rut,
audit_response = {"jobId": job_id} "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( await AuditService.log_consulta(
db=db, db=db,
tenant_id=user.tenant, tenant_id=user.tenant,
user_id=user.id, user_id=user.id,
consulta_type="individual", consulta_type="individual",
rut=rut, rut=rut,
endpoint="/api/v1/rut/lookup", endpoint="/client-api-v1/rut/lookup",
response_status=response_status, response_status=response_status,
request_data={"rut": rut, "isMonitoring": payload.isMonitoring}, request_data=jsonable(payload.model_dump()),
response_data=audit_response, response_data={"resultId": response_payload.get("_id")},
error_message=error_message,
processing_time_ms=processing_ms, 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, ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), 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]})

View File

@ -29,6 +29,19 @@ class Settings(BaseSettings):
legacy_base_url: str = Field(default="http://localhost:4040/api", validation_alias="LEGACY_BASE_URL") 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") 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_enabled: bool = Field(default=True, validation_alias="RABBITMQ_ENABLED")
rabbitmq_queue: str = Field(default="evaluation_queue", validation_alias="RABBITMQ_QUEUE") rabbitmq_queue: str = Field(default="evaluation_queue", validation_alias="RABBITMQ_QUEUE")
rabbitmq_url: str | None = Field(default=None, validation_alias="RABBITMQ_URL") rabbitmq_url: str | None = Field(default=None, validation_alias="RABBITMQ_URL")

View File

@ -8,10 +8,13 @@ from fastapi import APIRouter
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse 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.logging import configure_logging
from fastcheck_api.app.core.mongodb import lifespan_db from fastcheck_api.app.core.mongodb import lifespan_db
from fastcheck_api.app.services.legacy_service import legacy_service 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.services.rabbitmq_service import rabbitmq_service
from fastcheck_api.app.middleware.request_logging import RequestLoggingMiddleware from fastcheck_api.app.middleware.request_logging import RequestLoggingMiddleware
from fastcheck_api.app.middleware.tenant_context import TenantContextMiddleware from fastcheck_api.app.middleware.tenant_context import TenantContextMiddleware
@ -46,6 +49,8 @@ async def lifespan(app: FastAPI):
except Exception: except Exception:
logger.exception("RabbitMQ shutdown failed") logger.exception("RabbitMQ shutdown failed")
await legacy_service.close() await legacy_service.close()
await sheriff_v2_native_service.close()
await dequienes_native_service.aclose()
app = FastAPI( app = FastAPI(
@ -156,7 +161,7 @@ async def prefixed_openapi():
api_v1.include_router(auth.router) api_v1.include_router(auth.router)
api_v1.include_router(checks.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(rut.router)
api_v1.include_router(usage.router) api_v1.include_router(usage.router)
app.include_router(api_v1) app.include_router(api_v1)

View File

@ -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()

View File

@ -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,
}

View File

@ -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()

View File

@ -25,7 +25,23 @@ def jsonable(value: Any) -> Any:
def sanitize_rut(value: str) -> str: def sanitize_rut(value: str) -> str:
digits = "".join(re.findall(r"\d", value or "")) raw = (value or "").strip().replace(".", "").replace(" ", "").upper()
if len(digits) <= 1: if not raw:
return digits return ""
return f"{digits[:-1]}-{digits[-1]}"
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}"