Notas de construcción · Comercio y Arquitectura PIM Edge
Construyendo un PIM Master Enterprise Nativo para WhatsApp en Comercio de Commodities Preciosos
La mayoría de los sistemas de gestión de información de productos (PIM) asumen algo muy simple: el precio de un producto es un número estático ($P = \text{importe_fijo}$). En el comercio al por menor genérico esa suposición funciona. En el comercio de metales preciosos (joyería, lingotes y fabricación a medida) se rompe desde el primer día.
En el comercio de metales preciosos, los precios varían continuamente según las tasas del oro y la plata en vivo, las fracciones de pureza Karat, los costes de hechura, las mermas y las tasas impositivas dinámicas HSN/GST. Cuando un comerciante que gestiona 50.000 referencias intenta operar en plataformas tradicionales como Shopify, WooCommerce o PIMs SaaS genéricos, se ve obligado a usar aplicaciones de terceros, frágiles reindexaciones nocturnas o anulaciones manuales silenciosas.
Esta nota documenta cómo diseñamos el PIM Master de Klaros de forma nativa sobre primitivas serverless en el edge (Cloudflare Workers, D1, R2). Detallamos nuestro motor de fórmulas de precios en tiempo real con interruptores de circuito a las 36 horas, migraciones de esquemas matriciales padre-hijo, ingestión de hojas binarias de 4 pilares resistente a fallos, sindicación de feeds canónicos multicanal y tablas de catálogo virtualizadas en el DOM a 60 FPS.
RESUMEN EJECUTIVO (TL;DR)
Precios de Metales Preciosos: Precios en tiempo real impulsados por fórmulas actualizadas dinámicamente desde tasas de metales al contado con interruptor de circuito a las 36h en rate-pricing.mjs.
Matriz Padre-Hijo y Seguridad en D1: Migración de base de datos 0223 introduciendo entidades matriciales catalog_products y funciones chunkIds(90) para respetar el límite de 100 parámetros de Cloudflare D1.
Ingestión Binaria y Transmisión de Imágenes: Decodificación de archivos binarios .xlsx con SheetJS y worker de imágenes remotas de 4 pilares (HEAD pre-flight, tiempo límite de 15s, 3 reintentos exponenciales y transmisión a R2).
Feeds Multicanal y DOM Virtual: Feed XML RSS 2.0 de Google Merchant Center con etiquetas de espacio de nombres y desplazamiento virtualizado en tablas DOM a 60 FPS en product-library.js.
Índice de contenidos
- 1. La ventaja vertical: Motor dinámico de precios de metales e interruptor por tasa obsoleta
- 2. Modelo de datos: Matrices de variantes padre-hijo y seguridad de parámetros en D1
- 3. Ingestión resistente: Decodificación binaria de hojas de cálculo y transmisión de imágenes
- 4. Proyecciones canónicas neutras por canal y feeds RSS para Google Merchant Center
- 5. Virtualización de tablas DOM a 60 FPS sin dependencias externas
- 6. Matriz de evaluación arquitectónica de la plataforma
- Preguntas frecuentes
1. La ventaja vertical: Motor dinámico de precios de metales e interruptor por tasa obsoleta
Los motores de comercio electrónico estándar almacenan un único valor flotante para el precio de un producto. Cuando el oro fluctúa 40 $ por onza, una joyería debe recalcular y publicar actualizaciones para cada SKU individual. Si un catálogo contiene 20.000 artículos con variaciones de peso de oro (por ejemplo, 3,4 g de 18K frente a 7,1 g de 22K), la reindexación masiva desencadena límites de frecuencia, colas de API y precios desactualizados en la tienda.
En rate-pricing.mjs y pricing-mode.mjs, reemplazamos los campos de precio estáticos por un motor dinámico de precios basado en fórmulas:
Fórmula: Cálculo Dinámico de Precios de Metales con Desglose de Impuestos
// Cálculo de precios dinámicos en rate-pricing.mjs
export function computeRateLinkedPrice(item, metalRate) {
if (!metalRate || !metalRate.ratePerGram) {
throw new Error('MISSING_METAL_RATE');
}
// Comprobación del interruptor de circuito por tasa obsoleta (36 Horas)
const ageInHours = (Date.now() - new Date(metalRate.updatedAt).getTime()) / (1000 * 60 * 60);
if (ageInHours > 36) {
return { status: 'STALE_RATE_WITHHELD', usable: false };
}
const purityFraction = (item.purityKarat || 24) / 24;
const rawMetalCost = item.metalWeightGrams * metalRate.ratePerGram * purityFraction;
const netMakingCharges = (item.makingChargePerGram || 0) * item.metalWeightGrams + (item.makingChargeFixed || 0);
const subtotalBeforeTax = rawMetalCost + netMakingCharges + (item.wastageCost || 0);
const taxRate = item.taxRatePercent ?? 3.0; // GST por defecto del 3% para metales preciosos
const taxBreakdown = calculateTaxBreakdown(subtotalBeforeTax, taxRate, item.taxInclusive);
return {
usable: true,
finalPrice: taxBreakdown.finalPrice,
basePrice: taxBreakdown.basePrice,
taxAmount: taxBreakdown.taxAmount,
metalCost: Math.round(rawMetalCost * 100) / 100,
makingCharges: Math.round(netMakingCharges * 100) / 100,
};
}
El interruptor de circuito por tasa obsoleta a las 36 horas: Si el proceso de ingestión de tasas de metales no actualiza las cotizaciones en tiempo real (debido al cierre de mercados o interrupciones de la API), el sistema se niega a servir precios calculados con tasas de más de 36 horas de antigüedad. En lugar de vender una cadena de oro de 22K al precio más bajo de ayer, el proceso de pago devuelve el estado explícito STALE_RATE_WITHHELD, protegiendo el margen del comerciante.
2. Modelo de datos: Matrices de variantes padre-hijo y seguridad de parámetros en D1
Los catálogos de referencia única aplanan las variaciones en filas desconectadas. Un anillo disponible en 5 tamaños y 3 colores de oro se convierte en 15 productos independientes con títulos y descripciones duplicadas.
En la migración 0223_product_variants_and_tax.sql, introdujimos `catalog_products` como entidad matriz vinculada a las filas de variantes de `catalog_items`:
Esquema de Base de Datos: Matriz de Producto Padre y Variantes SKU
-- Tabla Matriz de Productos Padre
CREATE TABLE IF NOT EXISTS catalog_products (
id TEXT PRIMARY KEY,
creator_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
category TEXT,
vendor TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Tabla Extendida de Artículos de Catálogo con Índices Compuestos
ALTER TABLE catalog_items ADD COLUMN parent_product_id TEXT REFERENCES catalog_products(id);
ALTER TABLE catalog_items ADD COLUMN hsn_code TEXT;
ALTER TABLE catalog_items ADD COLUMN tax_rate_percent REAL DEFAULT 3.0;
ALTER TABLE catalog_items ADD COLUMN tax_inclusive INTEGER DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_catalog_items_parent_product
ON catalog_items(creator_id, parent_product_id);
CREATE INDEX IF NOT EXISTS idx_catalog_items_hsn_code
ON catalog_items(creator_id, hsn_code);
Límite de parámetros en D1 (chunkIds(90)): Cloudflare D1 opera sobre SQLite bajo un límite estricto de 100 parámetros vinculados por consulta SQL. Ejecutar SELECT * FROM catalog_items WHERE id IN (...) con 150 IDs provoca un error de vinculación de parámetros.
Resolvemos este problema de forma sistemática agrupando las consultas por lotes de IDs:
Seguridad de Parámetros: Agrupación de Consultas Seguras en product-library-routes.mjs
// Agrupación de arrays para ejecución segura en D1
export function chunkIds(ids, chunkSize = 90) {
const chunks = [];
for (let i = 0; i < ids.length; i += chunkSize) {
chunks.push(ids.slice(i, i + chunkSize));
}
return chunks;
}
// Ejemplo de uso en recuperación masiva
for (const chunk of chunkIds(requestedItemIds, 90)) {
const placeholders = chunk.map(() => '?').join(',');
const statement = db.prepare(`SELECT * FROM catalog_items WHERE creator_id = ? AND id IN (${placeholders})`);
const batchResults = await statement.bind(creatorId, ...chunk).all();
results.push(...batchResults.results);
}
3. Ingestión resistente: Decodificación binaria de hojas de cálculo y transmisión de imágenes
La importación de catálogos en entornos empresariales suele incluir archivos binarios legados de Excel (.xlsx, .xls) codificados con marcas de orden de bytes (BOM) UTF-8 o UTF-16, con enlaces a imágenes alojadas en CDNs lentos de terceros.
En csv.mjs, integramos la decodificación SheetJS con eliminación automática de BOM, mientras que catalog-import.mjs implementa un motor de imágenes remotas de 4 pilares:
Arquitectura de Ingestión: Worker de Imágenes Remotas de 4 Pilares
// Motor de Imágenes Remotas Resistente a Fallos en catalog-import.mjs
export async function processRemoteImageJob(env, creatorId, itemId, imageUrl) {
// Pilar 1: Validación HEAD Previa
const headRes = await fetch(imageUrl, { method: 'HEAD' });
if (!headRes.ok || !headRes.headers.get('content-type')?.startsWith('image/')) {
throw new Error('INVALID_REMOTE_IMAGE_HEADER');
}
// Pilar 2: Controlador AbortController por Tiempo Límite (15 segundos)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
// Pilar 3: Reintentos con Retraso Exponencial (3 Intentos)
let response;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
response = await fetch(imageUrl, { signal: controller.signal });
if (response.ok) break;
} catch (err) {
if (attempt === 3) throw err;
await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 500 + Math.random() * 200));
}
}
clearTimeout(timeoutId);
// Pilar 4: Transmisión de Búfer de Bytes a Cloudflare R2 y Enlace Atómico en D1
const imageBuffer = await response.arrayBuffer();
const r2Key = `catalogs/${creatorId}/${itemId}/${crypto.randomUUID()}.jpg`;
await env.CATALOG_BUCKET.put(r2Key, imageBuffer, {
httpMetadata: { contentType: response.headers.get('content-type') || 'image/jpeg' }
});
await updateItemMediaLink(env.DB, creatorId, itemId, r2Key);
}
4. Proyecciones canónicas neutras por canal y feeds RSS para Google Merchant Center
Conectar un catálogo a canales externos (Meta Commerce, Google Merchant Center, Catálogos de WhatsApp) suele requerir múltiples adaptadores de sincronización. Si los formatos cambian, los adaptadores se rompen.
En canonical.mjs, establecimos una capa de transformación canónica neutra (toCanonicalProduct). Todas las tiendas y endpoints de publicación consumen este modelo canónico:
Sindicación de Feeds: Motor XML RSS 2.0 para Google Merchant Center
// feeds-routes.mjs: Generador XML RSS 2.0 para Google Merchant Center
router.get('/feeds/gmc/:creatorId/:feedToken/products.xml', async (c) => {
const { creatorId, feedToken } = c.req.param();
// Verificación de Token de Autenticación Rotativo
const valid = await verifyFeedToken(c.env.DB, creatorId, feedToken);
if (!valid) return c.text('UNAUTHORIZED_FEED_ACCESS', 401);
const items = await fetchActiveCatalogItems(c.env.DB, creatorId);
const xmlItems = items.map(item => {
const canonical = toCanonicalProduct(item);
return `
-
${canonical.id}
${canonical.url}
${canonical.imageUrl}
${canonical.price} INR
${canonical.inStock ? 'in_stock' : 'out_of_stock'}
${canonical.variantGroupId || ''}
${canonical.hsnCode || ''}
`;
}).join('');
const xmlFeed = `
Feed de Catálogo
https://www.tryklaros.com
${xmlItems}
`;
return c.text(xmlFeed, 200, { 'Content-Type': 'application/xml; charset=utf-8' });
});
5. Virtualización de tablas DOM a 60 FPS sin dependencias externas
Renderizar 50.000 filas de productos en una tabla HTML estándar congela el DOM del navegador. Las soluciones tradicionales introducen pesadas librerías de interfaz como React o Vue que añaden megabytes de peso JavaScript al paquete final.
En product-library.js, construimos un motor de virtualización DOM sin dependencias utilizando Vanilla JS puro:
Virtualización DOM: Cálculo de Desplazamiento en product-library.js
// Virtualización DOM en Vanilla JS en product-library.js
function renderVirtualGrid(container, items, scrollTop) {
const ROW_HEIGHT = 48; // Altura fija por fila de tabla en píxeles
const VIEWPORT_HEIGHT = container.clientHeight || 600;
const BUFFER_COUNT = 5;
const totalCount = items.length;
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_COUNT);
const endIndex = Math.min(totalCount - 1, Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ROW_HEIGHT) + BUFFER_COUNT);
const topPadding = startIndex * ROW_HEIGHT;
const bottomPadding = (totalCount - 1 - endIndex) * ROW_HEIGHT;
const visibleSlice = items.slice(startIndex, endIndex + 1);
// Renderizar DOM de la Tabla con Filas Espaciadoras
const rowsHtml = visibleSlice.map(item => renderRowHtml(item, gridEdits[item.id])).join('');
container.querySelector('tbody').innerHTML = `
${rowsHtml}
`;
}
6. Matriz de evaluación arquitectónica de la plataforma
Evaluación del PIM Master de Klaros
| Dimensión | Puntuación | Resumen técnico |
|---|---|---|
| Precios de Metales Preciosos | 9,8 / 10 | Fórmula de tasa al contado, fracciones Karat e interruptor a las 36h. |
| Modelo de Datos y Seguridad en D1 | 9,2 / 10 | Matriz padre-hijo con agrupar en lotes chunkIds(90) en D1. |
| Ingestión de Hojas e Imágenes | 9,0 / 10 | Analizador binario SheetJS .xlsx y worker de imágenes de 4 pilares. |
| Sindicación de Feeds y Seguridad | 8,8 / 10 | Feed XML RSS 2.0 para Google Merchant Center con tokens rotativos. |
| Rendimiento de la Interfaz y DOM | 9,4 / 10 | Desplazamiento virtualizado a 60 FPS para +50.000 artículos en Vanilla JS. |
Preguntas frecuentes sobre el PIM Master Enterprise de Klaros
¿Por qué fallan las plataformas PIM genéricas como Shopify o Akeneo en el comercio de metales preciosos?
Los PIM genéricos asumen precios estáticos (precio = importe_fijo). En joyería y metales preciosos, los precios varían continuamente según las tasas del oro/plata en vivo, fracciones de pureza Karat, costes de hechura e impuestos. Klaros calcula precios en tiempo real mediante fórmulas dinámicas con un interruptor de circuito por tasa obsoleta a las 36 horas.
¿Cómo evita Klaros los bloqueos por límite de parámetros en Cloudflare D1 en consultas masivas?
Cloudflare D1 limita los parámetros SQL a 100 por sentencia. Al ejecutar actualizaciones masivas o consultas matriciales, Klaros agrupa las búsquedas mediante la función chunkIds(90), procesando los parámetros SQL en lotes seguros contra inyecciones.
¿Cómo gestiona el motor de ingestión de imágenes de 4 pilares los fallos de red?
El worker processRemoteImageJob realiza una validación HEAD previa, aplica un tiempo límite de aborto adaptativo de 15 segundos, ejecuta 3 reintentos con retraso exponencial, transmite los búferes de bytes directamente a Cloudflare R2 y actualiza los enlaces en D1 de forma atómica.
¿Cómo se protege el feed XML RSS de Google Merchant Center frente a descargas no autorizadas?
La ruta del feed /feeds/gmc/:creatorId/:feedToken/products.xml utiliza tokens rotativos generados criptográficamente. Los comerciantes pueden invalidar el acceso al feed de inmediato sin reautenticar la tienda ni comprometer credenciales de Meta.
¿Cómo renderiza la interfaz gráfica más de 50.000 productos sin ralentizar el navegador?
La tabla de catálogo en product-library.js utiliza virtualización DOM en Vanilla JS puro. Calcula los recortes de índices visibles (S_index, E_index) según el desplazamiento scrollTop del contenedor, renderiza filas espaciadoras <tr> superiores e inferiores y conserva los cambios de edición en la cuadrícula sin sobrecarga de frameworks.
Relacionado: Construyendo un catálogo de WhatsApp que dice la verdad
Construye tu catálogo de commodities sobre infraestructura en el edge
Despliega el PIM Master de Klaros con tasas de metales al contado en tiempo real, sindicación multicanal y gestión de catálogos de alto rendimiento.