From 8d543f5eb1b95de2638e1d6750fcf1882dbae980 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sun, 6 Sep 2026 21:03:56 +0000 Subject: [PATCH] =?UTF-8?q?fix(product-import):=20no=20duplicar=20producto?= =?UTF-8?q?=20al=20re-importar=20el=20mismo=20t=C3=ADtulo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-subir la misma fila de CSV (ej. para agregar más fotos o actualizar la descripción de un producto ya cargado) creaba un producto nuevo con handle distinto -- confirmado en vivo re-importando "Hello Kitty & Friends" para sumar fotos: quedaron dos productos con el mismo título en la tienda. Ahora, si el título coincide (case-insensitive/trim) con un producto ya importado, se actualiza ese producto en vez de crear uno: las fotos nuevas se agregan a las que ya tenía (no se reemplazan) y la descripción se actualiza si la fila trae una. No toca precio/cantidad/categoría/colección del producto existente. El duplicado ya creado se fusionó en vivo (fotos movidas al original, duplicado borrado) sin esperar este deploy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR --- .../src/admin/routes/importacion/page.tsx | 11 ++++-- .../src/api/admin/product-import/route.ts | 39 ++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/workloads/commerce-backend/src/admin/routes/importacion/page.tsx b/workloads/commerce-backend/src/admin/routes/importacion/page.tsx index 4da4f8d..1a78b40 100644 --- a/workloads/commerce-backend/src/admin/routes/importacion/page.tsx +++ b/workloads/commerce-backend/src/admin/routes/importacion/page.tsx @@ -14,6 +14,7 @@ type RowResult = { row: number; title: string; ok: boolean; + action?: "created" | "updated"; error?: string; }; @@ -91,9 +92,11 @@ const ProductImportPanel = () => {
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). + separan con ; dentro de la misma celda. Si categoria/coleccion no + coinciden con una ya existente, se crean nuevas. Si titulo coincide con un producto ya + 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).
@@ -122,7 +125,7 @@ const ProductImportPanel = () => { {result.title} {result.ok ? ( - Importado + {result.action === "updated" ? "Actualizado (ya existía)" : "Importado"} ) : ( {result.error} )} diff --git a/workloads/commerce-backend/src/api/admin/product-import/route.ts b/workloads/commerce-backend/src/api/admin/product-import/route.ts index e1a46a4..b5b6031 100644 --- a/workloads/commerce-backend/src/api/admin/product-import/route.ts +++ b/workloads/commerce-backend/src/api/admin/product-import/route.ts @@ -11,6 +11,7 @@ import { createInventoryItemsWorkflow, createProductCategoriesWorkflow, createProductsWorkflow, + updateProductsWorkflow, uploadFilesWorkflow, } from "@medusajs/medusa/core-flows"; 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 // 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 // MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow -> // mismo File Module Provider de medusa-config.ts, ya corre en el NAS). @@ -61,6 +74,7 @@ type RowResult = { row: number; title: string; ok: boolean; + action?: "created" | "updated"; error?: string; }; @@ -150,7 +164,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo const salesChannelService = req.scope.resolve(Modules.SALES_CHANNEL); const productService = req.scope.resolve(Modules.PRODUCT); - const [locations, shippingProfiles, salesChannels, categories, collections] = await Promise.all([ + const [locations, shippingProfiles, salesChannels, categories, collections, existingProducts] = await Promise.all([ stockLocationService.listStockLocations({}), fulfillmentService.listShippingProfiles({}), salesChannelService.listSalesChannels({}), @@ -161,6 +175,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo // los trae completos). productService.listProductCategories({}, { select: ["id", "name"] }), productService.listProductCollections({}, { select: ["id", "title"] }), + productService.listProducts({}, { select: ["id", "title"], relations: ["images"] }), ]); const locationId = locations[0]?.id; @@ -180,6 +195,9 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo const collectionByName = new Map( 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[] = []; @@ -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 { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({ 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) { results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message }); }