Saltar al contenido
hg.Héctor GarridoCiencia de datos y economía
Volver al cuaderno
Ecommerce Analytics

De Matches a Oportunidades: Costos, Márgenes y Precio Real

Este artículo técnico describe la evolución de un pipeline de matching entre mayoristas y retailers, enfocándose en el cálculo de la rentabilidad. Se rediseña el dashboard para mostrar oportunidades de ganancia neta, considerando costos de envío, embalaje y comisiones. Se implementa matching directo wholesale-retail con BM25 y cosine similarity, y se enriquece con datos de demanda de Mercado Libre y atributos extraídos con regex. El lector aprenderá sobre entity resolution, cálculo de márgenes y visualización de datos con React Flow y recharts.

El pipeline del post anterior ya identificaba matches correctos entre mayoristas y retail. Pero la pregunta que importa no es "¿estos productos son iguales?" sino "¿cuánto gano con cada uno?". Faltaba el modelo de costos.

Rediseño del dashboard: vista Oportunidades

El dashboard original mostraba matches agrupados por producto mayorista con todos sus posibles pares. Útil para debugging, inútil para decisiones. Lo reemplacé por una vista de oportunidades que calcula ganancia neta:

net_revenue = rt_price * (1 - commission / 100)
net_profit = net_revenue - ws_price - shipping - packaging

Los parámetros son configurables por el usuario: comisión (default 15%), envío (3.500)yembalaje(3.500) y embalaje (1.000). Cambiar cualquiera recalcula todo en tiempo real. El frontend envía estos valores como query params y el backend hace las cuentas.

La tabla resultante muestra nombre del producto, precio wholesale, precio retail, ganancia neta y porcentaje de margen, ordenada por ganancia descendente. Los matches sin validación LLM quedan excluidos del feed — no quiero decisiones basadas en matches inciertos.

Matching directo wholesale ↔ retail

El matching original triangulaba por un referente del mercado (ML): mayorista → ML → retail. Funcionaba, pero dependía de que el producto existiera en ML. Muchos perfumes de nicho no aparecen en el marketplace.

Implementé matching directo W↔R con el mismo motor de entity resolution (BM25 + cosine + scoring 5 componentes), pero comparando directamente los catálogos wholesale y retail entre sí. El modelo WholesaleRetailMatch guarda el par:

class WholesaleRetailMatch(models.Model):
    wholesale_product = models.ForeignKey(SupplierProduct, ...)
    retail_product = models.ForeignKey(SupplierProduct, ...)
    similarity_score = models.FloatField()
    match_type = models.CharField(choices=[('identical', 'Idéntico'), ('similar', 'Similar')])
    margin_pct = models.FloatField(null=True)
    net_profit = models.FloatField(null=True)
    ml_product_id = models.CharField(blank=True)  # demanda ML

El campo ml_product_id conecta opcionalmente con ML para agregar señal de demanda. Si el producto tiene un match en ML, puedo saber su ranking de búsqueda.

El comando match_local_hybrid corre en ~30 segundos para 500 productos wholesale × 400 retail, sin llamar a ninguna API. Todo local.

Historial de precios expandible

Una oportunidad con buen margen hoy puede no serlo mañana si el precio retail baja. Agregué un historial de precios expandible en la tabla W↔R — click en una fila despliega un LineChart de recharts con la evolución del precio wholesale y retail en el tiempo:

class WRPriceHistoryView(APIView):
    def get(self, request):
        # JOIN SupplierProductSnapshot para wholesale y retail
        # Merge por snapshot_date, retornar series temporales

El frontend usa el mismo patrón Collapse + LineChart que uso en otros componentes. Dos líneas: azul (wholesale) y rojo (retail), con tooltip mostrando margen en cada fecha. Si las líneas se cruzan, algo cambió.

Señal de demanda ML + atributos regex

El último paso fue enriquecer cada match con contexto. Dos adiciones:

Demanda ML: el comando map_ml_demand busca cada producto wholesale en el catálogo de ML usando embedding similarity, y si encuentra un match, guarda el ml_product_id y su ranking de búsqueda. En la tabla, un ícono verde indica que el producto tiene demanda confirmada en el marketplace.

Atributos regex: el comando extract_attributes_regex parsea nombre y descripción de cada producto con expresiones regulares para extraer concentración (EDP, EDT), tamaño (80ml), género, tipo (Set, Tester, Mini) y si está sellado. Zero API cost — puro regex:

SIZE_PATTERN = re.compile(r'(\d+)\s*(ml|oz)', re.IGNORECASE)
CONCENTRATION_PATTERN = re.compile(r'\b(EDP|EDT|EDC|Parfum|Cologne)\b', re.IGNORECASE)

El campo is_tester merece mención especial. Los testers se venden más baratos pero el matching los confundía con el producto sellado. Ahora el pipeline strip "tester/probador" del nombre antes de comparar, pero lo marca con is_tester=True. En el frontend aparece como un chip "T" naranja junto al nombre.

El diagrama ER interactivo

Con 15 modelos interconectados, necesitaba visualizar la arquitectura. Construí un explorador de esquemas con React Flow que renderiza diagramas ER interactivos por app Django. Cada nodo es una tabla con sus campos y tipos, las aristas son ForeignKeys (línea sólida) o M2M (línea punteada). Click en una app expande sus modelos.

Agregué también diagramas de algoritmos — flujos que muestran el pipeline de matching paso a paso, con nodos coloreados por status (implementado/pendiente).

Estado actual

El sistema ahora responde la pregunta que importa: "¿qué puedo comprar barato y vender con margen?". Tengo 15 scrapers alimentando un pipeline de entity resolution que produce matches con margen calculado, señal de demanda y historial de precios. Pero las señales de demanda vienen solo de MercadoLibre.

En el próximo post: cómo integré cuatro fuentes de demanda independientes en un solo ranking.