Files
Autoparts-DB/pos/services/credit_scoring.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

464 lines
15 KiB
Python

"""Credit risk scoring for store credit ("fiado") — A9.
Pure rule-based / heuristic scoring: no ML dependencies, works with very little
history and stays conservative when evidence is scarce. Every point of the score
is traceable to an explicit rule listed in ``reasons``.
Public API:
score_customer(tenant_conn, customer_id) -> dict
is_enforcement_enabled(tenant_conn) -> bool
The score is 0..100 where **higher is safer**. It is built from up to four
independent signals, each 0..100, then combined with fixed weights over the
signals that actually have data (missing signals are not invented):
tenure 0.15 months since customers.created_at
purchase 0.25 frequency + recency + value from sales
payment 0.35 punctuality on credit sales + open overdue aging
utilization 0.25 credit_balance / credit_limit
Credit sales are assumed to be due 30 days after the sale, matching the
receivables aging convention in ``blueprints/accounting_bp.py``.
"""
import logging
from datetime import datetime, timedelta, timezone
logger = logging.getLogger('pos')
# Credit sale terms used across the app for receivables aging.
CREDIT_TERMS_DAYS = 30
# Weight of each signal in the combined score.
_WEIGHTS = {
'tenure': 0.15,
'purchase': 0.25,
'payment': 0.35,
'utilization': 0.25,
}
_TIER_ORDER = {'risk': 0, 'medium': 1, 'good': 2}
# Enforcement flag key in tenant_config. Off by default.
ENFORCE_FLAG = 'credit_scoring_enforce'
def _now():
return datetime.now(timezone.utc)
def _aware(dt):
"""Normalize a possibly-naive datetime to UTC-aware."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _clamp(value, low=0, high=100):
return max(low, min(high, value))
def is_enforcement_enabled(tenant_conn):
"""Return True when the tenant opted into blocking credit sales for 'risk'.
Defaults to False on any error or missing flag so the sale flow never
breaks because of scoring.
"""
try:
cur = tenant_conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (ENFORCE_FLAG,))
row = cur.fetchone()
cur.close()
if not row:
return False
return str(row[0]).strip().lower() in ('true', '1', 'yes', 'on')
except Exception as exc: # pragma: no cover - defensive
logger.warning("credit_scoring: could not read enforce flag: %s", exc)
return False
def _collect_evidence(cur, customer_id):
"""Read every raw signal in a few queries. Returns a dict or None if the
customer does not exist."""
cur.execute("""
SELECT id, name, created_at, credit_limit, credit_balance,
price_tier, is_active
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
if not row:
return None
ev = {
'customer_id': row[0],
'name': row[1],
'created_at': _aware(row[2]),
'credit_limit': float(row[3] or 0),
'credit_balance': float(row[4] or 0),
'price_tier': row[5],
'is_active': row[6],
}
# Completed sales aggregate (frequency / value / recency).
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(total), 0), MIN(created_at),
MAX(created_at), COALESCE(AVG(total), 0),
COUNT(*) FILTER (WHERE sale_type = 'credit')
FROM sales
WHERE customer_id = %s AND status = 'completed'
""", (customer_id,))
total_orders, ltv, first_purchase, last_purchase, aov, credit_sales = cur.fetchone()
ev.update({
'total_orders': int(total_orders or 0),
'ltv': float(ltv or 0),
'first_purchase': _aware(first_purchase),
'last_purchase': _aware(last_purchase),
'avg_order_value': float(aov or 0),
'credit_sales': int(credit_sales or 0),
})
# Open (unpaid) credit sales: balance and due date for aging.
cur.execute("""
SELECT s.id, s.total, s.created_at,
s.total - COALESCE(SUM(sp.amount), 0) AS balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0.005
ORDER BY s.created_at
""", (customer_id,))
now = _now()
open_sales = []
for sale_id, total, created_at, balance in cur.fetchall():
created_at = _aware(created_at)
due = created_at + timedelta(days=CREDIT_TERMS_DAYS) if created_at else None
overdue = max(0, (now - due).days) if due else 0
open_sales.append({
'sale_id': sale_id,
'total': float(total or 0),
'created_at': created_at,
'balance': float(balance or 0),
'due_date': due,
'days_overdue': overdue,
})
ev['open_sales'] = open_sales
ev['open_balance'] = round(sum(s['balance'] for s in open_sales), 2)
ev['max_overdue_days'] = max((s['days_overdue'] for s in open_sales), default=0)
ev['overdue_balance'] = round(
sum(s['balance'] for s in open_sales if s['days_overdue'] > 0), 2
)
# Aging buckets (by days overdue).
buckets = {'current': 0.0, 'd1_30': 0.0, 'd31_60': 0.0, 'd61_90': 0.0, 'd90_plus': 0.0}
for s in open_sales:
d = s['days_overdue']
if d == 0:
buckets['current'] += s['balance']
elif d <= 30:
buckets['d1_30'] += s['balance']
elif d <= 60:
buckets['d31_60'] += s['balance']
elif d <= 90:
buckets['d61_90'] += s['balance']
else:
buckets['d90_plus'] += s['balance']
ev['aging'] = {k: round(v, 2) for k, v in buckets.items()}
# Settled credit sales: was the final payment within terms?
cur.execute("""
SELECT s.created_at, MAX(sp.created_at) AS last_payment
FROM sales s
JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING SUM(sp.amount) >= s.total - 0.005
""", (customer_id,))
settled = []
for created_at, last_payment in cur.fetchall():
created_at = _aware(created_at)
last_payment = _aware(last_payment)
if not created_at or not last_payment:
continue
due = created_at + timedelta(days=CREDIT_TERMS_DAYS)
settled.append({'days_late': max(0, (last_payment - due).days)})
ev['settled_credit_sales'] = settled
# Direct customer payments (abonos) — extra evidence of engagement.
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(amount), 0), MAX(created_at)
FROM customer_payments WHERE customer_id = %s
""", (customer_id,))
cp_count, cp_total, cp_last = cur.fetchone()
ev.update({
'customer_payments_count': int(cp_count or 0),
'customer_payments_total': float(cp_total or 0),
'customer_payments_last': _aware(cp_last),
})
return ev
# ─── Signal scoring ─────────────────────────────────────
def _tenure_signal(ev, reasons):
created_at = ev['created_at']
if not created_at:
return None
days = max(0, (_now() - created_at).days)
months = days / 30.44
if months < 3:
score = 45
elif months < 6:
score = 60
elif months < 12:
score = 75
elif months < 24:
score = 85
else:
score = 95
reasons.append(f"Cliente con {int(months)} meses de antigüedad ({days} días).")
if ev.get('is_active') is False:
score = min(score, 40)
reasons.append("Cliente marcado como inactivo.")
return score
def _purchase_signal(ev, reasons):
total_orders = ev['total_orders']
if total_orders <= 0:
reasons.append("Sin historial de compras registrado.")
return None
if total_orders == 1:
freq = 50
elif total_orders <= 3:
freq = 65
elif total_orders <= 9:
freq = 80
elif total_orders <= 24:
freq = 90
else:
freq = 95
last = ev['last_purchase']
days_since = (_now() - last).days if last else None
if days_since is None:
recency = 60
elif days_since <= 30:
recency = 100
elif days_since <= 90:
recency = 80
elif days_since <= 180:
recency = 60
elif days_since <= 365:
recency = 40
else:
recency = 25
score = round(0.6 * freq + 0.4 * recency)
reasons.append(
f"{total_orders} compras por ${ev['ltv']:.2f} "
f"(ticket promedio ${ev['avg_order_value']:.2f}), "
f"última hace {days_since} días."
if days_since is not None else
f"{total_orders} compras por ${ev['ltv']:.2f}."
)
return _clamp(score)
def _payment_signal(ev, reasons):
has_history = (
ev['credit_sales'] > 0
or ev['open_balance'] > 0
or ev['customer_payments_count'] > 0
)
if not has_history:
reasons.append("Sin historial de crédito ni abonos.")
return None
settled = ev['settled_credit_sales']
if settled:
on_time = sum(1 for s in settled if s['days_late'] <= 0)
late_ratio = (len(settled) - on_time) / len(settled)
base = 90 - 60 * late_ratio
reasons.append(
f"{on_time}/{len(settled)} créditos liquidados dentro del plazo de "
f"{CREDIT_TERMS_DAYS} días."
)
else:
base = 70 if ev['credit_sales'] > 0 or ev['open_balance'] > 0 else 75
reasons.append("Sin créditos liquidados aún; sin historial de puntualidad.")
max_overdue = ev['max_overdue_days']
if max_overdue > 0:
penalty = min(55.0, 15.0 + max_overdue * 0.5)
base -= penalty
reasons.append(
f"Mora abierta: ${ev['overdue_balance']:.2f} con hasta {max_overdue} "
f"días de atraso."
)
if ev['credit_limit'] > 0:
util = ev['credit_balance'] / ev['credit_limit']
if util > 1.0:
base -= 15
reasons.append(f"Crédito excedido ({util * 100:.0f}% del límite).")
elif util > 0.9:
base -= 8
reasons.append(f"Crédito casi al límite ({util * 100:.0f}%).")
return _clamp(round(base))
def _utilization_signal(ev, reasons):
limit = ev['credit_limit']
balance = ev['credit_balance']
if limit <= 0:
if balance > 0:
reasons.append(f"Saldo de ${balance:.2f} sin límite de crédito definido.")
return 45
return None
util = balance / limit
if util <= 0.001:
score = 100
elif util <= 0.3:
score = 95
elif util <= 0.6:
score = 80
elif util <= 0.9:
score = 60
elif util <= 1.0:
score = 40
else:
score = 15
reasons.append(f"Utilización de crédito {util * 100:.0f}% (${balance:.2f} / ${limit:.2f}).")
return score
def _tier_from_score(score, data_points, max_overdue_days, behavioral_evidence):
if max_overdue_days > 60:
return 'risk'
if score >= 75 and data_points >= 2 and behavioral_evidence:
tier = 'good'
elif score >= 50:
tier = 'medium'
else:
tier = 'risk'
# Conservative when mora is building or evidence is thin.
if max_overdue_days > 30 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
if not behavioral_evidence and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
if data_points <= 1 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
return tier
def _recommended_limit(tier, ev):
if ev['total_orders'] <= 0:
return 0.0
aov = ev['avg_order_value'] or (ev['ltv'] / ev['total_orders'])
if tier == 'good':
factor = 4.0
elif tier == 'medium':
factor = 2.0
else:
return 0.0 # risk: do not recommend extending new credit
suggested = aov * factor
suggested = round(suggested / 100.0) * 100.0
return float(min(suggested, 200000.0))
def score_customer(tenant_conn, customer_id):
"""Score a customer's credit risk.
Returns a dict::
{
'score': 0..100, # higher = safer
'tier': 'good'|'medium'|'risk',
'recommended_limit': float,
'reasons': [str, ...],
'data_points': int, # number of signals with real data (0..4)
'components': {...}, # per-signal sub-scores (None when absent)
'metrics': {...}, # raw evidence for auditing
'computed_at': iso8601 str,
}
Raises ValueError if the customer does not exist.
"""
cur = tenant_conn.cursor()
try:
ev = _collect_evidence(cur, customer_id)
finally:
cur.close()
if ev is None:
raise ValueError(f"Customer {customer_id} not found")
reasons = []
components = {
'tenure': _tenure_signal(ev, reasons),
'purchase': _purchase_signal(ev, reasons),
'payment': _payment_signal(ev, reasons),
'utilization': _utilization_signal(ev, reasons),
}
available = {k: v for k, v in components.items() if v is not None}
data_points = len(available)
if available:
weight_sum = sum(_WEIGHTS[k] for k in available)
score = sum(available[k] * _WEIGHTS[k] for k in available) / weight_sum
else:
score = 50.0
score = int(round(_clamp(score)))
behavioral = components['purchase'] is not None or components['payment'] is not None
# Hard cap: heavy mora can never yield a high score.
if ev['max_overdue_days'] > 60:
score = min(score, 39)
elif ev['max_overdue_days'] > 30:
score = min(score, 60)
tier = _tier_from_score(score, data_points, ev['max_overdue_days'], behavioral)
recommended = _recommended_limit(tier, ev)
if data_points <= 1:
reasons.append(
f"Pocos datos ({data_points} señal{'es' if data_points != 1 else ''}); "
"evaluación conservadora."
)
return {
'customer_id': customer_id,
'score': score,
'tier': tier,
'recommended_limit': recommended,
'reasons': reasons,
'data_points': data_points,
'components': components,
'metrics': {
'months_active': round((_now() - ev['created_at']).days / 30.44, 1) if ev['created_at'] else None,
'total_orders': ev['total_orders'],
'ltv': round(ev['ltv'], 2),
'avg_order_value': round(ev['avg_order_value'], 2),
'days_since_last_purchase': (_now() - ev['last_purchase']).days if ev['last_purchase'] else None,
'credit_limit': ev['credit_limit'],
'credit_balance': ev['credit_balance'],
'open_credit_balance': ev['open_balance'],
'max_overdue_days': ev['max_overdue_days'],
'overdue_balance': ev['overdue_balance'],
'aging': ev['aging'],
'credit_sales': ev['credit_sales'],
'settled_credit_sales': len(ev['settled_credit_sales']),
},
'computed_at': _now().isoformat(),
}