Saltar a contenido

⚠ Vigencia por confirmar. Especificación del proveedor de fulfillment de MC Group. El módulo src/modules/mc-fulfillment no existe en las ramas publicadas (14749d6 al 2026-08-19). El texto habla de Medusa 2.11; el repo está en 2.15.5.

Prompt: Implementación del Fulfillment Provider MC Group

Contexto del Proyecto

Se está desarrollando un sistema de fulfillment para un e-commerce en Paraguay. Este documento describe la implementación del Fulfillment Provider para MC Group, una transportadora nacional con precios diferenciados por zona geográfica y tamaño de caja.

El sistema utiliza Medusa.js v2.11.


Infraestructura Ya Implementada

1. Gestión de GeoZones (Ciudades)

Cada ciudad está configurada como un GeoZone dentro de una ServiceZone (Zona 0, 1, 2, 3, AEX).

// GeoZone.metadata
{
  "city_code": "PY001",
  "clx_available": true,
  "clx_hours": 24,
  "mc_available": true,    // ← Indica si MC cubre esta ciudad
  "mc_hours": 48,          // ← Tiempo de entrega en horas
  "aex_available": false,
  "aex_hours": null
}

2. Tipos de Caja (Global)

Los tipos de caja están almacenados en el FulfillmentSet y son compartidos por todas las zonas.

// FulfillmentSet.metadata.box_types
[
  {
    "code": "sobre",
    "name": "Sobre",
    "max_length": 30,    // cm
    "max_width": 20,     // cm
    "max_height": 5,     // cm
    "max_weight": 1,     // kg
    "sort_order": 1      // Prioridad de selección (menor = más pequeño)
  },
  {
    "code": "caja_1",
    "name": "Caja 1",
    "max_length": 40,
    "max_width": 30,
    "max_height": 20,
    "max_weight": 5,
    "sort_order": 2
  },
  // ... más tipos de caja
]

3. Precios por Zona (ShippingOption)

Cada ShippingOption de MC tiene sus precios configurados por tipo de caja y categorías especiales.

// ShippingOption.metadata
{
  "prices": {
    "sobre": 25000,      // Precio en guaraníes
    "caja_1": 30000,
    "caja_2": 35000,
    "caja_3": 40000,
    "caja_4": 50000,
    "caja_5": 60000,
    "caja_6": 65000,
    "caja_7": 70000
  },
  "special_categories": [
    {
      "category_id": "pcat_01ABC123",
      "category_name": "Sillas Gamer",
      "price": 50000
    },
    {
      "category_id": "pcat_02DEF456",
      "category_name": "Colchones",
      "price": 60000
    }
  ]
}

4. Jerarquía de Datos

StockLocation
└── FulfillmentSet (type: "shipping")
    ├── metadata.box_types[]              ← Tipos de caja (global)
    │
    └── ServiceZone (Zona 0, 1, 2, 3)
        ├── GeoZone[]                      ← Ciudades
        │   └── metadata.mc_available      ← Disponibilidad MC
        │   └── metadata.mc_hours          ← Tiempo de entrega
        │
        └── ShippingOption: "MC Group"
            └── metadata.prices{}          ← Precios por caja
            └── metadata.special_categories[] ← Precios especiales

Requerimiento: Fulfillment Provider MC

Identificador

class MCFulfillmentService extends AbstractFulfillmentProviderService {
  static identifier = "mc-group"
  // ...
}

Métodos a Implementar

El provider debe implementar los métodos requeridos por Medusa v2:

Método Propósito
canCalculate() Determina si el provider puede calcular precio para el contexto dado
calculatePrice() Calcula el precio de envío
validateFulfillmentData() Valida datos antes de crear fulfillment
createFulfillment() Crea el fulfillment (puede ser stub por ahora)
cancelFulfillment() Cancela el fulfillment (puede ser stub por ahora)

Lógica de Cálculo de Precio

Flujo Principal

calculatePrice(optionData, data, context):

1. VERIFICAR DISPONIBILIDAD
   - Obtener dirección de envío del cart/context
   - Buscar GeoZone correspondiente a la ciudad
   - Verificar que mc_available === true
   - Si no disponible → lanzar error o retornar null

2. OBTENER CONFIGURACIÓN
   - Cargar box_types desde FulfillmentSet.metadata
   - Cargar prices y special_categories desde ShippingOption.metadata

3. SEPARAR PRODUCTOS DEL CARRITO
   - productos_especiales[] → productos cuya categoría está en special_categories
   - productos_normales[] → el resto

4. CALCULAR PRECIO DE PRODUCTOS ESPECIALES
   Para cada producto en productos_especiales:
     - Buscar su categoría en special_categories
     - Sumar el precio especial al total

   precio_especial = Σ (precio de cada categoría especial)

5. CALCULAR PRECIO DE PRODUCTOS NORMALES
   Si hay productos_normales:
     - Encontrar el producto MÁS GRANDE (por dimensiones)
     - Buscar la caja más pequeña que lo contenga:
       - Ordenar box_types por sort_order (ascendente)
       - Para cada caja, verificar si el producto cabe:
         producto.length <= caja.max_length AND
         producto.width <= caja.max_width AND
         producto.height <= caja.max_height AND
         producto.weight <= caja.max_weight
       - Usar la primera caja que cumpla
     - Obtener precio de la caja: prices[caja.code]

   precio_normal = prices[caja_seleccionada.code]

6. RETORNAR RESULTADO
   {
     calculated_amount: precio_especial + precio_normal,
     is_calculated_price_tax_inclusive: true  // IVA incluido
   }

Pseudocódigo

async calculatePrice(optionData, data, context) {
  // 1. Obtener datos necesarios
  const shippingAddress = context.cart.shipping_address;
  const geoZone = await this.findGeoZone(shippingAddress.city);

  // 2. Verificar disponibilidad
  if (!geoZone?.metadata?.mc_available) {
    throw new Error("MC Group no disponible para esta ciudad");
  }

  // 3. Cargar configuración
  const boxTypes = await this.getBoxTypes(context.fulfillmentSetId);
  const { prices, special_categories } = await this.getShippingOptionMetadata(optionData.id);

  // 4. Obtener items del carrito
  const items = context.cart.items;

  // 5. Separar productos
  const specialItems = [];
  const normalItems = [];

  for (const item of items) {
    const product = await this.getProduct(item.variant_id);
    const categoryId = product.category_id; // o product.categories[0]?.id

    const specialCategory = special_categories?.find(
      sc => sc.category_id === categoryId
    );

    if (specialCategory) {
      specialItems.push({ item, specialCategory });
    } else {
      normalItems.push({ item, product });
    }
  }

  // 6. Calcular precio de especiales
  let totalPrice = 0;

  for (const { specialCategory } of specialItems) {
    totalPrice += specialCategory.price;
  }

  // 7. Calcular precio de normales (producto más grande)
  if (normalItems.length > 0) {
    // Encontrar producto más grande
    const largestProduct = this.findLargestProduct(normalItems);

    // Encontrar caja adecuada
    const suitableBox = this.findSuitableBox(largestProduct, boxTypes);

    if (!suitableBox) {
      throw new Error("No hay caja disponible para las dimensiones del producto");
    }

    // Obtener precio
    const boxPrice = prices[suitableBox.code];
    if (boxPrice === undefined) {
      throw new Error(`Precio no configurado para caja: ${suitableBox.code}`);
    }

    totalPrice += boxPrice;
  }

  return {
    calculated_amount: totalPrice,
    is_calculated_price_tax_inclusive: true
  };
}

// Helpers
findLargestProduct(items) {
  return items.reduce((largest, current) => {
    const currentVolume = 
      current.product.length * 
      current.product.width * 
      current.product.height;
    const largestVolume = 
      largest.product.length * 
      largest.product.width * 
      largest.product.height;

    return currentVolume > largestVolume ? current : largest;
  }).product;
}

findSuitableBox(product, boxTypes) {
  // Ordenar por sort_order (más pequeña primero)
  const sortedBoxes = [...boxTypes].sort((a, b) => a.sort_order - b.sort_order);

  for (const box of sortedBoxes) {
    if (
      product.length <= box.max_length &&
      product.width <= box.max_width &&
      product.height <= box.max_height &&
      product.weight <= box.max_weight
    ) {
      return box;
    }
  }

  return null; // No hay caja que sirva
}

Ejemplo de Cálculo

Escenario 1: Solo productos normales

Carrito: - Mouse (15x10x5 cm, 0.2 kg) - Teclado (45x15x3 cm, 0.8 kg)

Cálculo: 1. Producto más grande: Teclado (45x15x3) 2. Buscar caja: "caja_1" (40x30x20) NO cabe, "caja_2" (50x40x30) SÍ cabe 3. Precio: prices["caja_2"] = 35.000 Gs.

Total: 35.000 Gs.


Escenario 2: Producto con categoría especial

Carrito: - Silla Gamer (categoría especial) - Mouse (15x10x5 cm)

Cálculo: 1. Separar: Silla → especial, Mouse → normal 2. Precio especial: 50.000 Gs. 3. Producto normal más grande: Mouse → caja "sobre" → 25.000 Gs.

Total: 75.000 Gs.


Escenario 3: Múltiples productos especiales

Carrito: - Silla Gamer (categoría especial: 50.000) - Colchón (categoría especial: 60.000)

Cálculo: 1. Ambos son especiales 2. Suma: 50.000 + 60.000 = 110.000 Gs.

Total: 110.000 Gs.


Puntos a Investigar/Confirmar

1. Ubicación de dimensiones del producto

Verificar dónde están almacenadas las dimensiones en Medusa v2:

// ¿En ProductVariant?
variant.length
variant.width
variant.height
variant.weight

// ¿En Product?
product.length
product.width
product.height
product.weight

// ¿En metadata?
product.metadata.dimensions

2. Relación producto-categoría

Verificar cómo obtener la categoría de un producto:

// ¿Propiedad directa?
product.category_id

// ¿Array de categorías?
product.categories[0].id

// ¿A través de variant?
variant.product.categories

3. Acceso al contexto en calculatePrice

Verificar qué datos están disponibles en el contexto:

// ¿Cómo acceder al cart?
context.cart
context.data.cart

// ¿Cómo acceder al fulfillment set?
context.fulfillmentSet
optionData.service_zone.fulfillment_set

4. Método canCalculate

Determinar cuándo retornar true:

async canCalculate(data) {
  // ¿Solo verificar que existe la ciudad?
  // ¿O también verificar mc_available aquí?
}

Estructura de Archivos Sugerida

src/
└── modules/
    └── mc-fulfillment/
        ├── index.ts                    # Registro del módulo
        ├── service.ts                  # MCFulfillmentService
        ├── types.ts                    # Interfaces y tipos
        └── utils/
            ├── box-calculator.ts       # Lógica de selección de caja
            ├── price-calculator.ts     # Lógica de cálculo de precio
            └── geo-zone-resolver.ts    # Búsqueda de GeoZone por ciudad

Configuración del Módulo

// medusa-config.ts
module.exports = defineConfig({
  // ...
  modules: [
    {
      resolve: "@medusajs/medusa/fulfillment",
      options: {
        providers: [
          {
            resolve: "./src/modules/mc-fulfillment",
            id: "mc-group",
            options: {}
          }
        ]
      }
    }
  ]
});

Tipos TypeScript

// types.ts

export interface BoxType {
  code: string;
  name: string;
  max_length: number;
  max_width: number;
  max_height: number;
  max_weight: number;
  sort_order: number;
}

export interface SpecialCategory {
  category_id: string;
  category_name: string;
  price: number;
}

export interface ShippingPrices {
  [boxCode: string]: number;
}

export interface ShippingOptionMetadata {
  prices: ShippingPrices;
  special_categories?: SpecialCategory[];
}

export interface GeoZoneMetadata {
  city_code?: string;
  mc_available: boolean;
  mc_hours: number;
  clx_available?: boolean;
  clx_hours?: number;
  aex_available?: boolean;
  aex_hours?: number;
}

export interface ProductDimensions {
  length: number;
  width: number;
  height: number;
  weight: number;
}

export interface CalculatePriceResult {
  calculated_amount: number;
  is_calculated_price_tax_inclusive: boolean;
}

Manejo de Errores

Situación Comportamiento
Ciudad no encontrada Lanzar error o retornar no disponible
mc_available = false Retornar no disponible
Producto sin dimensiones Lanzar error con mensaje claro
No hay caja que sirva Lanzar error indicando límites excedidos
Precio no configurado Lanzar error indicando caja sin precio

Consideraciones de Rendimiento

Caché Sugerido (Opcional)

Para evitar múltiples queries a la base de datos:

Redis Keys:
- mc:box_types → array de box_types (TTL: 1 hora)
- mc:prices:{service_zone_id} → objeto de precios (TTL: 1 hora)
- mc:geo_zones:{city_name} → metadata de la ciudad (TTL: 1 hora)

Invalidación:
- Al actualizar box_types desde admin
- Al actualizar precios desde admin
- Al actualizar geo_zones desde admin

Testing

Casos de Prueba

  1. Producto normal pequeño → debe seleccionar caja más pequeña
  2. Producto normal grande → debe seleccionar caja adecuada
  3. Producto con categoría especial → debe usar precio especial
  4. Mix de especial + normal → debe sumar ambos precios
  5. Producto demasiado grande → debe fallar con error claro
  6. Ciudad sin cobertura MC → debe retornar no disponible
  7. Caja sin precio configurado → debe fallar con error claro

Entregables Esperados

  1. Fulfillment Provider funcional registrado en Medusa
  2. Lógica de cálculo según especificación
  3. Manejo de errores apropiado
  4. Tipos TypeScript completos
  5. Documentación de uso y configuración

Referencias