Merge fix/product-import-dedupe-por-titulo into main

This commit is contained in:
devops committed 2026-09-06 21:04:23 +00:00
commit 9d08169092
2 files changed
+44 -6

No files matched your search

@@ -14,6 +14,7 @@ type RowResult = {
row: number; row: number;
title: string; title: string;
ok: boolean; ok: boolean;
action?: "created" | "updated";
error?: string; error?: string;
}; };
@@ -91,9 +92,11 @@ const ProductImportPanel = () => {
<div className="px-6 py-4"> <div className="px-6 py-4">
<Text size="small" className="text-ui-fg-subtle"> <Text size="small" className="text-ui-fg-subtle">
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se 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 separan con <code>;</code> dentro de la misma celda. Si <code>categoria</code>/<code>coleccion</code> no
escribirse igual que en la tienda o se dejan vacías. Cada foto se descarga y se guarda en el almacenamiento coinciden con una ya existente, se crean nuevas. Si <code>titulo</code> coincide con un producto ya
propio de la tienda (no queda enlazada al sitio de origen). importado, no se duplica: se le agregan las fotos nuevas y se actualiza la descripción si la fila trae una.
Cada foto se descarga y se guarda en el almacenamiento propio de la tienda (no queda enlazada al sitio de
origen).
</Text> </Text>
</div> </div>
@@ -122,7 +125,7 @@ const ProductImportPanel = () => {
<td className="py-2">{result.title}</td> <td className="py-2">{result.title}</td>
<td className="py-2"> <td className="py-2">
{result.ok ? ( {result.ok ? (
<Badge color="green">Importado</Badge> <Badge color="green">{result.action === "updated" ? "Actualizado (ya existía)" : "Importado"}</Badge>
) : ( ) : (
<span className="text-rose-600">{result.error}</span> <span className="text-rose-600">{result.error}</span>
)} )}
@@ -11,6 +11,7 @@ import {
createInventoryItemsWorkflow, createInventoryItemsWorkflow,
createProductCategoriesWorkflow, createProductCategoriesWorkflow,
createProductsWorkflow, createProductsWorkflow,
updateProductsWorkflow,
uploadFilesWorkflow, uploadFilesWorkflow,
} from "@medusajs/medusa/core-flows"; } from "@medusajs/medusa/core-flows";
import { csvRowsToObjects, parseCsv } from "../../../lib/csv"; import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
@@ -31,6 +32,18 @@ import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// handle del producto: el default de Medusa no quita tildes/enies y // handle del producto: el default de Medusa no quita tildes/enies y
// rompe la ruta publica. // rompe la ruta publica.
// //
// Re-importar el mismo titulo (ej. para agregar mas fotos o actualizar
// la descripcion de un producto ya cargado) actualiza el producto
// existente en vez de crear uno nuevo -- antes cada re-importacion
// generaba un producto duplicado con handle distinto (confirmado en
// vivo con "Hello Kitty & Friends": misma fila re-subida para sumar
// fotos, dos productos con el mismo titulo en la tienda). El match es
// por titulo exacto (case-insensitive/trim), igual que categoria y
// coleccion; en un match, las fotos nuevas se agregan a las que ya
// tenia (no se reemplazan) y la descripcion se actualiza si la fila
// trae una. No toca precio/cantidad/categoria/coleccion del producto
// existente -- eso se edita a mano en el admin si hace falta.
//
// Las fotos se descargan del origen y se re-alojan en el mismo // Las fotos se descargan del origen y se re-alojan en el mismo
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow -> // MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow ->
// mismo File Module Provider de medusa-config.ts, ya corre en el NAS). // mismo File Module Provider de medusa-config.ts, ya corre en el NAS).
@@ -61,6 +74,7 @@ type RowResult = {
row: number; row: number;
title: string; title: string;
ok: boolean; ok: boolean;
action?: "created" | "updated";
error?: string; error?: string;
}; };
@@ -150,7 +164,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL); const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT); const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
const [locations, shippingProfiles, salesChannels, categories, collections] = await Promise.all([ const [locations, shippingProfiles, salesChannels, categories, collections, existingProducts] = await Promise.all([
stockLocationService.listStockLocations({}), stockLocationService.listStockLocations({}),
fulfillmentService.listShippingProfiles({}), fulfillmentService.listShippingProfiles({}),
salesChannelService.listSalesChannels({}), salesChannelService.listSalesChannels({}),
@@ -161,6 +175,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
// los trae completos). // los trae completos).
productService.listProductCategories({}, { select: ["id", "name"] }), productService.listProductCategories({}, { select: ["id", "name"] }),
productService.listProductCollections({}, { select: ["id", "title"] }), productService.listProductCollections({}, { select: ["id", "title"] }),
productService.listProducts({}, { select: ["id", "title"], relations: ["images"] }),
]); ]);
const locationId = locations[0]?.id; const locationId = locations[0]?.id;
@@ -180,6 +195,9 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
const collectionByName = new Map( const collectionByName = new Map(
collections.filter((c) => c.title).map((c) => [c.title.toLowerCase().trim(), c.id]), collections.filter((c) => c.title).map((c) => [c.title.toLowerCase().trim(), c.id]),
); );
const productByTitle = new Map(
existingProducts.filter((p) => p.title).map((p) => [p.title.toLowerCase().trim(), p]),
);
const results: RowResult[] = []; const results: RowResult[] = [];
@@ -210,6 +228,23 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}, },
}); });
const existingProduct = productByTitle.get(title.toLowerCase());
if (existingProduct) {
await updateProductsWorkflow(req.scope).run({
input: {
products: [
{
id: existingProduct.id,
description: row.descripcion?.trim() || undefined,
images: [...(existingProduct.images ?? []).map((image) => ({ url: image.url })), ...uploadedFiles.map((f) => ({ url: f.url }))],
},
],
},
});
results.push({ row: rowNumber, title, ok: true, action: "updated" });
continue;
}
const quantity = Number(row.cantidad); const quantity = Number(row.cantidad);
const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({ const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({
input: { input: {
@@ -283,7 +318,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}, },
}); });
results.push({ row: rowNumber, title, ok: true }); results.push({ row: rowNumber, title, ok: true, action: "created" });
} catch (error) { } catch (error) {
results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message }); results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message });
} }