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:
1 parent
1dccd0aa6a
commit
1cd1078cdf
3 files changed
+435
No files matched your search
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||||
|
import { ArrowDownTray, ArrowUpTray, CheckCircleSolid, XCircleSolid } from "@medusajs/icons";
|
||||||
|
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { sdk } from "../../lib/sdk";
|
||||||
|
|
||||||
|
// Importador masivo via CSV -- pensado para cargar varios productos de
|
||||||
|
// una sola vez (comprados en Amazon/eBay/etc, no dropshipping
|
||||||
|
// automatizado -- ver conversacion sobre por que se descarto raspar
|
||||||
|
// tiendas de terceros). Las fotos se descargan del origen y se re-alojan
|
||||||
|
// en el mismo MinIO/S3 del NAS que ya usa el resto del catalogo.
|
||||||
|
|
||||||
|
type RowResult = {
|
||||||
|
row: number;
|
||||||
|
title: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function downloadTemplate() {
|
||||||
|
window.open("/admin/product-import", "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductImportPanel = () => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<RowResult[] | null>(null);
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
const csv = await file.text();
|
||||||
|
setLoading(true);
|
||||||
|
setResults(null);
|
||||||
|
try {
|
||||||
|
const data = await sdk.client.fetch<{ results?: RowResult[]; error?: string }>("/admin/product-import", {
|
||||||
|
method: "post",
|
||||||
|
body: { csv },
|
||||||
|
});
|
||||||
|
if (data.error) {
|
||||||
|
toast.error("No se pudo procesar el archivo", { description: data.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResults(data.results ?? []);
|
||||||
|
const okCount = data.results?.filter((r) => r.ok).length ?? 0;
|
||||||
|
const failCount = (data.results?.length ?? 0) - okCount;
|
||||||
|
if (failCount === 0) {
|
||||||
|
toast.success(`${okCount} productos importados`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`${okCount} importados, ${failCount} con error`, {
|
||||||
|
description: "Revisa el detalle abajo.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("No se pudo procesar el archivo", { description: (error as Error).message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2">Importación de productos</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="small" variant="secondary" onClick={downloadTemplate}>
|
||||||
|
<ArrowDownTray />
|
||||||
|
Descargar plantilla
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="primary" isLoading={loading} onClick={() => inputRef.current?.click()}>
|
||||||
|
<ArrowUpTray />
|
||||||
|
Subir CSV
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se
|
||||||
|
separan con <code>;</code> dentro de la misma celda. <code>categoria</code>/<code>coleccion</code> deben
|
||||||
|
escribirse igual que en la tienda o se dejan vacías. Cada foto se descarga y se guarda en el almacenamiento
|
||||||
|
propio de la tienda (no queda enlazada al sitio de origen).
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-ui-fg-subtle">
|
||||||
|
<th className="w-10 py-2" />
|
||||||
|
<th className="py-2 font-medium">Fila</th>
|
||||||
|
<th className="py-2 font-medium">Producto</th>
|
||||||
|
<th className="py-2 font-medium">Detalle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((result) => (
|
||||||
|
<tr key={result.row} className="border-b last:border-0">
|
||||||
|
<td className="py-2">
|
||||||
|
{result.ok ? (
|
||||||
|
<CheckCircleSolid className="text-ui-tag-green-icon" />
|
||||||
|
) : (
|
||||||
|
<XCircleSolid className="text-ui-tag-red-icon" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">{result.row}</td>
|
||||||
|
<td className="py-2">{result.title}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{result.ok ? (
|
||||||
|
<Badge color="green">Importado</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-rose-600">{result.error}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Importación",
|
||||||
|
icon: ArrowUpTray,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProductImportPanel;
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Parser CSV minimo (RFC4180: campos entre comillas, comillas escapadas
|
||||||
|
// como "", comas y saltos de linea dentro de campos citados) -- se evita
|
||||||
|
// sumar una dependencia nueva (papaparse/csv-parse) para un formato que
|
||||||
|
// nosotros mismos controlamos via la plantilla descargable.
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
let i = 0;
|
||||||
|
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
|
||||||
|
while (i < normalized.length) {
|
||||||
|
const char = normalized[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (normalized[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inQuotes = false;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
field += char;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ",") {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "\n") {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
field += char;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (field.length > 0 || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows.filter((r) => r.some((cell) => cell.trim().length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvRowsToObjects(rows: string[][]): Record<string, string>[] {
|
||||||
|
const [header, ...body] = rows;
|
||||||
|
if (!header) return [];
|
||||||
|
const keys = header.map((h) => h.trim().toLowerCase());
|
||||||
|
return body.map((cols) => {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
keys.forEach((key, index) => {
|
||||||
|
obj[key] = (cols[index] ?? "").trim();
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user