diff --git a/workloads/commerce-backend/src/admin/routes/importacion/page.tsx b/workloads/commerce-backend/src/admin/routes/importacion/page.tsx new file mode 100644 index 0000000..4da4f8d --- /dev/null +++ b/workloads/commerce-backend/src/admin/routes/importacion/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(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 ( + +
+
+ Importación de productos + + Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio. + +
+
+ + + { + const file = event.target.files?.[0]; + if (file) handleFile(file); + }} + /> +
+
+ +
+ + Columnas: titulo, descripcion, precio, categoria, coleccion, cantidad, fotos. Varias fotos se + separan con ; dentro de la misma celda. categoria/coleccion 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). + +
+ + {results && ( +
+ + + + + + + + + + {results.map((result) => ( + + + + + + + ))} + +
+ FilaProductoDetalle
+ {result.ok ? ( + + ) : ( + + )} + {result.row}{result.title} + {result.ok ? ( + Importado + ) : ( + {result.error} + )} +
+
+ )} +
+ ); +}; + +export const config = defineRouteConfig({ + label: "Importación", + icon: ArrowUpTray, +}); + +export default ProductImportPanel; diff --git a/workloads/commerce-backend/src/api/admin/product-import/route.ts b/workloads/commerce-backend/src/api/admin/product-import/route.ts new file mode 100644 index 0000000..7602c3f --- /dev/null +++ b/workloads/commerce-backend/src/api/admin/product-import/route.ts @@ -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(Modules.STOCK_LOCATION); + const fulfillmentService = req.scope.resolve(Modules.FULFILLMENT); + const salesChannelService = req.scope.resolve(Modules.SALES_CHANNEL); + const productService = req.scope.resolve(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 }); +} diff --git a/workloads/commerce-backend/src/lib/csv.ts b/workloads/commerce-backend/src/lib/csv.ts new file mode 100644 index 0000000..bacc9c6 --- /dev/null +++ b/workloads/commerce-backend/src/lib/csv.ts @@ -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[] { + const [header, ...body] = rows; + if (!header) return []; + const keys = header.map((h) => h.trim().toLowerCase()); + return body.map((cols) => { + const obj: Record = {}; + keys.forEach((key, index) => { + obj[key] = (cols[index] ?? "").trim(); + }); + return obj; + }); +}