⚠ Vigencia por confirmar. Especificación del proveedor de fulfillment de MC Group. El módulo
src/modules/mc-fulfillmentno existe en las ramas publicadas (14749d6al 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¶
- Producto normal pequeño → debe seleccionar caja más pequeña
- Producto normal grande → debe seleccionar caja adecuada
- Producto con categoría especial → debe usar precio especial
- Mix de especial + normal → debe sumar ambos precios
- Producto demasiado grande → debe fallar con error claro
- Ciudad sin cobertura MC → debe retornar no disponible
- Caja sin precio configurado → debe fallar con error claro
Entregables Esperados¶
- Fulfillment Provider funcional registrado en Medusa
- Lógica de cálculo según especificación
- Manejo de errores apropiado
- Tipos TypeScript completos
- Documentación de uso y configuración