From aa494fd8e24b0aabcc890655370a15358f9e6755 Mon Sep 17 00:00:00 2001 From: ertopogo Date: Thu, 5 Feb 2026 23:48:50 +0100 Subject: WIP: prepare import csv --- backend/data/products-import.csv | 2 + backend/package.json | 2 + backend/scripts/import-products.js | 250 +++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 backend/data/products-import.csv create mode 100644 backend/scripts/import-products.js (limited to 'backend') diff --git a/backend/data/products-import.csv b/backend/data/products-import.csv new file mode 100644 index 0000000..29c61c8 --- /dev/null +++ b/backend/data/products-import.csv @@ -0,0 +1,2 @@ +external_id,title,handle,description,thumbnail,option_title,option_value,variant_title,variant_sku,price_amount,currency_code,inventory_quantity,manage_inventory +prod-001,Savon Lavande,savon-lavande,"Savon artisanal a la lavande.",https://via.placeholder.com/600x600.png?text=Savon+Lavande,Taille,100g,100g,SKU-SAV-100,650,eur,100,true diff --git a/backend/package.json b/backend/package.json index caf9258..5d087f7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "start": "sh -c \"if [ -d src ]; then npm run build; else echo 'Skipping build: no src/ directory'; fi && medusa start\"", "start:custom": "sh -c \"if [ -d src ]; then npm run build; else echo 'Skipping build: no src/ directory'; fi && node --preserve-symlinks index.js\"", "dev": "sh -c \"if [ -d src ]; then npm run build; else echo 'Skipping build: no src/ directory'; fi && medusa develop\"", + "import:products": "node scripts/import-products.js --file ./data/products-import.csv --report ./data/import-report.json", "seed": "medusa seed -f ./data/seed.json", "install:cli": "npm install -g @medusajs/medusa-cli", "postinstall": "node scripts/patch-medusa.js" @@ -29,6 +30,7 @@ "@medusajs/cache-redis": "^1.8.9", "@medusajs/event-bus-local": "^1.9.7", "@medusajs/event-bus-redis": "^1.8.10", + "csv-parse": "^5.5.6", "@medusajs/file-local": "^1.0.2", "@medusajs/admin": "^7.1.14", "body-parser": "^1.19.0", diff --git a/backend/scripts/import-products.js b/backend/scripts/import-products.js new file mode 100644 index 0000000..04b24aa --- /dev/null +++ b/backend/scripts/import-products.js @@ -0,0 +1,250 @@ +const fs = require("fs"); +const path = require("path"); +const express = require("express"); +const { parse } = require("csv-parse/sync"); +const loaders = require("@medusajs/medusa/dist/loaders/index").default; + +class CsvProductImporter { + constructor({ filePath, reportPath, dryRun, defaultCurrency }) { + this.filePath = filePath; + this.reportPath = reportPath; + this.dryRun = dryRun; + this.defaultCurrency = defaultCurrency; + this.report = { + file: filePath, + dryRun, + startedAt: new Date().toISOString(), + finishedAt: null, + totals: { + processed: 0, + created: 0, + updated: 0, + failed: 0, + }, + errors: [], + }; + } + + async run() { + this.assertFileExists(); + const rows = this.parseCsv(); + const productGroups = this.groupByExternalId(rows); + + const { container, dbConnection } = await this.initMedusa(); + const productService = container.resolve("productService"); + + for (const productData of productGroups) { + try { + await this.upsertProduct(productService, productData); + this.report.totals.processed += 1; + } catch (error) { + this.report.totals.failed += 1; + this.report.errors.push({ + external_id: productData.external_id, + message: error.message, + }); + } + } + + this.report.finishedAt = new Date().toISOString(); + this.writeReport(); + await dbConnection.close(); + } + + assertFileExists() { + if (!fs.existsSync(this.filePath)) { + throw new Error(`CSV introuvable: ${this.filePath}`); + } + } + + parseCsv() { + const input = fs.readFileSync(this.filePath, "utf-8"); + return parse(input, { + columns: true, + skip_empty_lines: true, + trim: true, + }).map((row) => this.normalizeRow(row)); + } + + normalizeRow(row) { + const toBool = (value, defaultValue) => { + if (value === undefined || value === "") { + return defaultValue; + } + const normalized = String(value).toLowerCase(); + return ["true", "1", "yes", "y"].includes(normalized); + }; + + return { + external_id: row.external_id || "", + title: row.title || "", + handle: row.handle || this.slugify(row.title || ""), + description: row.description || "", + thumbnail: row.thumbnail || "", + option_title: row.option_title || "Taille", + option_value: row.option_value || row.variant_title || "Default", + variant_title: row.variant_title || row.option_value || "Default", + variant_sku: row.variant_sku || "", + price_amount: Number.parseInt(row.price_amount, 10) || 0, + currency_code: row.currency_code || this.defaultCurrency, + inventory_quantity: Number.parseInt(row.inventory_quantity, 10) || 0, + manage_inventory: toBool(row.manage_inventory, true), + }; + } + + groupByExternalId(rows) { + const groups = new Map(); + + for (const row of rows) { + if (!row.external_id) { + throw new Error("Champ requis manquant: external_id"); + } + if (!row.title) { + throw new Error(`Champ requis manquant: title (external_id=${row.external_id})`); + } + + const existing = groups.get(row.external_id); + const variant = { + title: row.variant_title, + sku: row.variant_sku || undefined, + prices: [ + { + currency_code: row.currency_code, + amount: row.price_amount, + }, + ], + options: [ + { + value: row.option_value, + }, + ], + inventory_quantity: row.inventory_quantity, + manage_inventory: row.manage_inventory, + }; + + if (!existing) { + groups.set(row.external_id, { + external_id: row.external_id, + title: row.title, + handle: row.handle, + description: row.description, + thumbnail: row.thumbnail || undefined, + options: [{ title: row.option_title }], + variants: [variant], + }); + } else { + if (existing.options[0].title !== row.option_title) { + throw new Error( + `option_title incoherent pour external_id=${row.external_id} (${existing.options[0].title} vs ${row.option_title})` + ); + } + existing.variants.push(variant); + } + } + + return Array.from(groups.values()); + } + + async initMedusa() { + const app = express(); + const directory = path.resolve(__dirname, ".."); + return loaders({ directory, expressApp: app }); + } + + async upsertProduct(productService, productData) { + const existing = await this.findByExternalId(productService, productData.external_id); + const action = existing ? "update" : "create"; + + if (this.dryRun) { + console.log(`[dry-run] ${action} product external_id=${productData.external_id}`); + if (existing) { + this.report.totals.updated += 1; + } else { + this.report.totals.created += 1; + } + return; + } + + if (existing) { + await productService.update(existing.id, productData); + this.report.totals.updated += 1; + return; + } + + await productService.create(productData); + this.report.totals.created += 1; + } + + async findByExternalId(productService, externalId) { + const results = await productService.list({ external_id: externalId }, { take: 1 }); + return results[0] || null; + } + + writeReport() { + if (!this.reportPath) { + return; + } + fs.writeFileSync(this.reportPath, JSON.stringify(this.report, null, 2), "utf-8"); + } + + slugify(value) { + return String(value) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + } +} + +function parseArgs(argv) { + const args = { + file: null, + report: null, + dryRun: false, + currency: "eur", + }; + + for (let i = 0; i < argv.length; i += 1) { + const current = argv[i]; + if (current === "--file") { + args.file = argv[i + 1]; + i += 1; + } else if (current === "--report") { + args.report = argv[i + 1]; + i += 1; + } else if (current === "--dry-run") { + args.dryRun = true; + } else if (current === "--currency") { + args.currency = argv[i + 1]; + i += 1; + } + } + + return args; +} + +async function main() { + const directory = path.resolve(__dirname, ".."); + const args = parseArgs(process.argv.slice(2)); + const filePath = args.file + ? path.resolve(directory, args.file) + : path.resolve(directory, "data", "products-import.csv"); + const reportPath = args.report + ? path.resolve(directory, args.report) + : path.resolve(directory, "data", "import-report.json"); + + const importer = new CsvProductImporter({ + filePath, + reportPath, + dryRun: args.dryRun, + defaultCurrency: args.currency, + }); + + await importer.run(); + console.log("Import termine."); +} + +main().catch((error) => { + console.error("Echec import:", error); + process.exitCode = 1; +}); -- cgit v1.2.3