feat(commerce-backend): importador masivo de productos via CSV

Nueva pagina "Importacion" en Medusa Admin para cargar varios productos
de una sola vez (comprados en Amazon/eBay/etc, no dropshipping
automatizado -- se evaluo y descarto raspar catalogos de terceros sin
autorizacion por riesgo de copyright/ToS).

src/api/admin/product-import/route.ts: GET sirve la plantilla CSV
descargable, POST procesa fila por fila (una foto rota o categoria mal
escrita no tumba el resto del archivo). Cada foto se descarga del origen
y se re-aloja en el mismo MinIO/S3 del NAS que ya usa el catalogo
(prefijo import-products/, separado de ari-products/), se crea el
registro de inventario con la cantidad indicada y el producto queda
vinculado con el mismo patron "Presentacion"/"Estandar" que usa el resto
del catalogo -- el storefront no necesita cambios.

src/lib/csv.ts: parser CSV propio (RFC4180), sin sumar dependencia nueva.

Workflows y servicios de modulo (uploadFilesWorkflow,
createInventoryItemsWorkflow, createProductsWorkflow, stock-location/
fulfillment/sales-channel/product module services) verificados contra
los .d.ts reales instalados, no solo contra documentacion.
This commit is contained in:
devops committed 2026-09-01 03:47:32 +00:00
1 parent 1dccd0aa6a
commit 1cd1078cdf
3 files changed
+435

No files matched your search

@@ -0,0 +1,220 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { Modules } from "@medusajs/framework/utils";
import type {
IFulfillmentModuleService,
IProductModuleService,
ISalesChannelModuleService,
IStockLocationService,
} from "@medusajs/framework/types";
import {
createInventoryItemsWorkflow,
createProductsWorkflow,
uploadFilesWorkflow,
} from "@medusajs/medusa/core-flows";
import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// Importador masivo de productos via CSV -- para alguien que compra
// producto fisico en Amazon/eBay/etc. y quiere cargarlo rapido a la
// tienda, no para dropshipping automatizado (ver conversacion: se evaluo
// y se descarto raspar catalogos de terceros sin autorizacion). Cada
// fila se procesa independiente (una foto rota o categoria mal escrita
// no tumba el resto del archivo).
//
// Las fotos se descargan del origen y se re-alojan en el mismo
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow ->
// mismo File Module Provider de medusa-config.ts, ya corre en el NAS),
// bajo el prefijo import-products/ para separarlas de ari-products/
// (subidas manuales desde el Admin).
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const IMAGE_FETCH_TIMEOUT_MS = 15000;
const OPTION_TITLE = "Presentación";
const DEFAULT_VARIANT_VALUE = "Estandar";
type ImportRow = {
titulo: string;
descripcion: string;
precio: string;
categoria: string;
coleccion: string;
cantidad: string;
fotos: string;
};
type RowResult = {
row: number;
title: string;
ok: boolean;
error?: string;
};
function extensionFromContentType(contentType: string | null, url: string): string {
if (contentType?.includes("png")) return "png";
if (contentType?.includes("webp")) return "webp";
if (contentType?.includes("gif")) return "gif";
if (contentType?.includes("jpeg") || contentType?.includes("jpg")) return "jpg";
const match = url.match(/\.(jpe?g|png|webp|gif)(?:\?|$)/i);
return match ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
}
async function downloadImageAsBase64(url: string): Promise<{ content: string; mimeType: string; extension: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`No se pudo descargar la imagen (${response.status})`);
}
const contentType = response.headers.get("content-type");
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_BYTES) {
throw new Error(`Imagen supera ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);
}
const extension = extensionFromContentType(contentType, url);
return {
content: buffer.toString("base64"),
mimeType: contentType?.split(";")[0] ?? `image/${extension === "jpg" ? "jpeg" : extension}`,
extension,
};
} finally {
clearTimeout(timeout);
}
}
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Plantilla descargable -- misma fuente de verdad que valida el POST,
// asi que las columnas nunca quedan desincronizadas entre lo que se
// ofrece descargar y lo que realmente se procesa.
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
const example =
'"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de accion","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
const csv = `${header}\n${example}\n`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
res.send(csv);
}
export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaResponse) {
const csvText = req.body?.csv;
if (!csvText || typeof csvText !== "string") {
res.status(400).json({ error: "Falta el contenido CSV" });
return;
}
const rows = csvRowsToObjects(parseCsv(csvText)) as unknown as ImportRow[];
if (rows.length === 0) {
res.status(400).json({ error: "El archivo no tiene filas de datos" });
return;
}
const stockLocationService = req.scope.resolve<IStockLocationService>(Modules.STOCK_LOCATION);
const fulfillmentService = req.scope.resolve<IFulfillmentModuleService>(Modules.FULFILLMENT);
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
const [locations, shippingProfiles, salesChannels, categories, collections] = await Promise.all([
stockLocationService.listStockLocations({}),
fulfillmentService.listShippingProfiles({}),
salesChannelService.listSalesChannels({}),
productService.listProductCategories({}),
productService.listProductCollections({}),
]);
const locationId = locations[0]?.id;
const shippingProfileId = shippingProfiles.find((p) => p.type === "default")?.id ?? shippingProfiles[0]?.id;
const salesChannelId = salesChannels[0]?.id;
if (!locationId || !shippingProfileId || !salesChannelId) {
res.status(400).json({
error: "Faltan datos base de la tienda (bodega, perfil de envío o canal de venta)",
});
return;
}
const categoryByName = new Map(categories.map((c) => [c.name.toLowerCase().trim(), c.id]));
const collectionByName = new Map(collections.map((c) => [c.title.toLowerCase().trim(), c.id]));
const results: RowResult[] = [];
for (let index = 0; index < rows.length; index++) {
const rowNumber = index + 2; // +1 header, +1 base 1
const row = rows[index];
const title = row.titulo?.trim();
try {
if (!title) throw new Error("Falta el título");
const price = Number(row.precio);
if (!Number.isFinite(price) || price <= 0) throw new Error("Precio inválido");
const imageUrls = (row.fotos ?? "")
.split(";")
.map((u) => u.trim())
.filter(Boolean);
if (imageUrls.length === 0) throw new Error("Falta al menos una foto");
const downloaded = await Promise.all(imageUrls.map((url) => downloadImageAsBase64(url)));
const { result: uploadedFiles } = await uploadFilesWorkflow(req.scope).run({
input: {
files: downloaded.map((file, i) => ({
filename: `import-products/${Date.now()}-${index}-${i}.${file.extension}`,
mimeType: file.mimeType,
content: file.content,
access: "public" as const,
})),
},
});
const quantity = Number(row.cantidad);
const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({
input: {
items: [
{
title,
location_levels: [
{ location_id: locationId, stocked_quantity: Number.isFinite(quantity) && quantity >= 0 ? quantity : 1 },
],
},
],
},
});
const inventoryItemId = inventoryItems[0]?.id;
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryId = row.categoria ? categoryByName.get(row.categoria.toLowerCase().trim()) : undefined;
const collectionId = row.coleccion ? collectionByName.get(row.coleccion.toLowerCase().trim()) : undefined;
await createProductsWorkflow(req.scope).run({
input: {
products: [
{
title,
description: row.descripcion || undefined,
status: "published",
thumbnail: uploadedFiles[0]?.url,
images: uploadedFiles.map((f) => ({ url: f.url })),
category_ids: categoryId ? [categoryId] : undefined,
collection_id: collectionId,
shipping_profile_id: shippingProfileId,
sales_channels: [{ id: salesChannelId }],
options: [{ title: OPTION_TITLE, values: [DEFAULT_VARIANT_VALUE] }],
variants: [
{
title: DEFAULT_VARIANT_VALUE,
options: { [OPTION_TITLE]: DEFAULT_VARIANT_VALUE },
manage_inventory: true,
inventory_items: [{ inventory_item_id: inventoryItemId }],
prices: [{ amount: price, currency_code: "cop" }],
},
],
},
],
},
});
results.push({ row: rowNumber, title, ok: true });
} catch (error) {
results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message });
}
}
res.json({ results });
}