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; });