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;
|
||||
Reference in new issue
Block a user