diff --git a/.changeset/rotten-squids-arrive.md b/.changeset/rotten-squids-arrive.md new file mode 100644 index 0000000000000..0dec3ffd8c095 --- /dev/null +++ b/.changeset/rotten-squids-arrive.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": minor +--- + +feat(medusa): Align columns between product import/export, re visit the way the columns are defined and treated diff --git a/integration-tests/api/__tests__/batch-jobs/product/export.js b/integration-tests/api/__tests__/batch-jobs/product/export.js index 5e2d3c37bd3dd..6f568df5e3145 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/export.js +++ b/integration-tests/api/__tests__/batch-jobs/product/export.js @@ -1,6 +1,6 @@ const path = require("path") const fs = require("fs/promises") -import { sep, resolve } from "path" +import { resolve, sep } from "path" const setupServer = require("../../../../helpers/setup-server") const { useApi } = require("../../../../helpers/use-api") @@ -16,7 +16,7 @@ const adminReqConfig = { }, } -jest.setTimeout(1000000) +jest.setTimeout(100000000) describe("Batch job of product-export type", () => { let medusaProcess @@ -56,21 +56,26 @@ describe("Batch job of product-export type", () => { const db = useDb() await db.teardown() - const isFileExists = (await fs.stat(exportFilePath))?.isFile() + try { + const isFileExists = (await fs.stat(exportFilePath))?.isFile() - if (isFileExists) { - const [, relativeRoot] = exportFilePath.replace(__dirname, "").split(sep) + if (isFileExists) { + const [, relativeRoot] = exportFilePath + .replace(__dirname, "") + .split(sep) - if ((await fs.stat(resolve(__dirname, relativeRoot)))?.isDirectory()) { - topDir = relativeRoot - } + if ((await fs.stat(resolve(__dirname, relativeRoot)))?.isDirectory()) { + topDir = relativeRoot + } - await fs.unlink(exportFilePath) + await fs.unlink(exportFilePath) + } + } catch (e) { + console.log(e) } }) it("should export a csv file containing the expected products", async () => { - jest.setTimeout(1000000) const api = useApi() const productPayload = { @@ -174,7 +179,6 @@ describe("Batch job of product-export type", () => { }) it("should export a csv file containing the expected products including new line char in the cells", async () => { - jest.setTimeout(1000000) const api = useApi() const productPayload = { @@ -278,7 +282,6 @@ describe("Batch job of product-export type", () => { }) it("should export a csv file containing a limited number of products", async () => { - jest.setTimeout(1000000) const api = useApi() const batchPayload = { @@ -333,4 +336,113 @@ describe("Batch job of product-export type", () => { const csvLine = lines[0].split(";") expect(csvLine[0]).toBe("test-product") }) + + it("should be able to import an exported csv file", async () => { + const api = useApi() + + const batchPayload = { + type: "product-export", + context: { + batch_size: 1, + filterable_fields: { collection_id: "test-collection" }, + order: "created_at", + }, + } + + const batchJobRes = await api.post( + "/admin/batch-jobs", + batchPayload, + adminReqConfig + ) + let batchJobId = batchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + // Pull to check the status until it is completed + let batchJob + let shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + exportFilePath = path.resolve(__dirname, batchJob.result.file_key) + const isFileExists = (await fs.stat(exportFilePath)).isFile() + + expect(isFileExists).toBeTruthy() + + const data = (await fs.readFile(exportFilePath)).toString() + const [header, ...lines] = data.split("\r\n").filter((l) => l) + + expect(lines.length).toBe(4) + + const csvLine = lines[0].split(";") + expect(csvLine[0]).toBe("test-product") + expect(csvLine[2]).toBe("Test product") + + csvLine[2] = "Updated test product" + lines.splice(0, 1, csvLine.join(";")) + + await fs.writeFile(exportFilePath, [header, ...lines].join("\r\n")) + + const importBatchJobRes = await api.post( + "/admin/batch-jobs", + { + type: "product-import", + context: { + fileKey: exportFilePath, + }, + }, + adminReqConfig + ) + + batchJobId = importBatchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + const productsResponse = await api.get("/admin/products", adminReqConfig) + expect(productsResponse.data.count).toBe(5) + expect(productsResponse.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: csvLine[0], + handle: csvLine[1], + title: csvLine[2], + }), + ]) + ) + }) }) diff --git a/integration-tests/api/__tests__/batch-jobs/product/import.js b/integration-tests/api/__tests__/batch-jobs/product/import.js index e8d01c9936a20..f2ebab8248f10 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/import.js +++ b/integration-tests/api/__tests__/batch-jobs/product/import.js @@ -133,7 +133,7 @@ describe("Product import batch job", () => { expect(batchJob.status).toBe("completed") const productsResponse = await api.get("/admin/products", adminReqConfig) - expect(productsResponse.data.count).toBe(2) + expect(productsResponse.data.count).toBe(3) expect(productsResponse.data.products).toEqual( expect.arrayContaining([ // NEW PRODUCT @@ -200,6 +200,65 @@ describe("Product import batch job", () => { }), ], }), + expect.objectContaining({ + title: "Test product", + description: + "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", + handle: "test-product-product-1-1", + is_giftcard: false, + status: "draft", + thumbnail: "test-image.png", + variants: [ + // NEW VARIANT + expect.objectContaining({ + title: "Test variant", + sku: "test-sku-1-1", + barcode: "test-barcode-1-1", + ean: null, + upc: null, + inventory_quantity: 10, + prices: [ + expect.objectContaining({ + currency_code: "eur", + amount: 100, + region_id: "region-product-import-0", + }), + expect.objectContaining({ + currency_code: "usd", + amount: 110, + }), + expect.objectContaining({ + currency_code: "dkk", + amount: 130, + region_id: "region-product-import-1", + }), + ], + options: expect.arrayContaining([ + expect.objectContaining({ + value: "option 1 value red", + }), + expect.objectContaining({ + value: "option 2 value 1", + }), + ]), + }), + ], + type: null, + images: [ + expect.objectContaining({ + url: "test-image.png", + }), + ], + options: [ + expect.objectContaining({ + title: "test-option-1", + }), + expect.objectContaining({ + title: "test-option-2", + }), + ], + tags: [], + }), // UPDATED PRODUCT expect.objectContaining({ id: existingProductToBeUpdated.id, diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import-ss.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import-ss.csv index f2933ab938321..cf0c650a005bc 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/product-import-ss.csv +++ b/integration-tests/api/__tests__/batch-jobs/product/product-import-ss.csv @@ -1,2 +1,2 @@ -Product id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External ID,Variant id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow backorder,Variant Manage inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url,Sales Channel 1 Name,Sales Channel 2 Name,Sales Channel 1 Id,Sales Channel 2 Id +Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url,Sales Channel 1 Name,Sales Channel 2 Name,Sales Channel 1 Id,Sales Channel 2 Id ,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png,Import Sales Channel 1,Import Sales Channel 2,, \ No newline at end of file diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import.csv index 3b44cda26d158..072a23baeb822 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/product-import.csv +++ b/integration-tests/api/__tests__/batch-jobs/product/product-import.csv @@ -1,5 +1,6 @@ -Product id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External ID,Variant id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow backorder,Variant Manage inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url -,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,test-image.png,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,Size,Small,,,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,Size,Medium,,,test-image.png -existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,existing-variant-id,Test variant changed,test-sku-4,test-barcode-4,10,FALSE,TRUE,,,,,,,,,,,,,Size,Large,,,test-image.png +Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url +,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png +,test-product-product-1-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,,TRUE,,,Test variant,test-sku-1-1,test-barcode-1-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png +existing-product-id,test-product-product-2,Test product,,test-product-description,draft,test-image.png,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,Size,Small,,,test-image.png +existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,Size,Medium,,,test-image.png +existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,existing-variant-id,Test variant changed,test-sku-4,test-barcode-4,10,FALSE,TRUE,,,,,,,,,,,,,Size,Large,,,test-image.png \ No newline at end of file diff --git a/packages/medusa/src/interfaces/batch-job-strategy.ts b/packages/medusa/src/interfaces/batch-job-strategy.ts index 02609dd00a14f..80e0598b9efcc 100644 --- a/packages/medusa/src/interfaces/batch-job-strategy.ts +++ b/packages/medusa/src/interfaces/batch-job-strategy.ts @@ -1,6 +1,6 @@ import { TransactionBaseService } from "./transaction-base-service" import { BatchJobResultError, CreateBatchJobInput } from "../types/batch-job" -import { ProductExportBatchJob } from "../strategies/batch-jobs/product" +import { ProductExportBatchJob } from "../strategies/batch-jobs/product/types" import { BatchJobService } from "../services" import { BatchJob } from "../models" diff --git a/packages/medusa/src/interfaces/csv-parser.ts b/packages/medusa/src/interfaces/csv-parser.ts index 25c05a9990d94..1bc1864fb648e 100644 --- a/packages/medusa/src/interfaces/csv-parser.ts +++ b/packages/medusa/src/interfaces/csv-parser.ts @@ -40,21 +40,30 @@ export abstract class AbstractCsvValidator ): Promise } -export type CsvSchemaColumn = { - name: string +export type CsvSchemaColumn< + TCsvLine, + TBuiltLine, + NameAsOptional = false +> = (NameAsOptional extends false + ? { + name: string + } + : { + name?: string + }) & { required?: boolean validator?: AbstractCsvValidator } & ( - | { - mapTo?: string - transform?: ColumnTransformer - } - | { - match?: RegExp - reducer?: ColumnReducer - transform?: ColumnTransformer - } -) + | { + mapTo?: string + transform?: ColumnTransformer + } + | { + match?: RegExp + reducer?: ColumnReducer + transform?: ColumnTransformer + } + ) export type ColumnTransformer = ( value: string, diff --git a/packages/medusa/src/loaders/strategies.ts b/packages/medusa/src/loaders/strategies.ts index ba0da653ba347..ea0bd85462516 100644 --- a/packages/medusa/src/loaders/strategies.ts +++ b/packages/medusa/src/loaders/strategies.ts @@ -1,6 +1,6 @@ import glob from "glob" import path from "path" -import { asFunction, aliasTo } from "awilix" +import { aliasTo, asFunction } from "awilix" import formatRegistrationName from "../utils/format-registration-name" import { isBatchJobStrategy } from "../interfaces" @@ -36,6 +36,7 @@ export default ({ container, configModule, isTest }: LoaderOptions): void => { "**/utils.ts", "**/types.js", "**/types.ts", + "**/types/**", ], }) diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap b/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap index 1d6c2a442bfb6..257435f994842 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/product/__snapshots__/export.ts.snap @@ -2,9 +2,12 @@ exports[`Product export strategy should process the batch job and generate the appropriate output 1`] = ` Array [ - "Product id;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External ID;Product Profile Name;Product Profile Type;Variant id;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow backorder;Variant Manage inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price france [USD];Price USD;Price denmark [DKK];Price Denmark [DKK];Option 1 Name;Option 1 Value;Option 2 Name;Option 2 Value;Image 1 Url + "Product Id;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External Id;Product Profile Name;Product Profile Type;Variant Id;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow Backorder;Variant Manage Inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price france [USD];Price USD;Price denmark [DKK];Price Denmark [DKK];Option 1 Name;Option 1 Value;Option 2 Name;Option 2 Value;Image 1 Url ", - "product-export-strategy-product-1;test-product-product-1;Test product;;\\"test-product-description-1\ntest-product-description-1 second line\ntest-product-description-1 third line\nforth line\\";draft;;;;;;;;;;Test collection 1;test-collection1;test-type-1;123_1;true;;profile_1;profile_type_1;product-export-strategy-variant-1;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;100;110;130;;test-option-1;option 1 value 1;test-option-2;option 2 value 1;test-image.png + "product-export-strategy-product-1;test-product-product-1;Test product;;\\"test-product-description-1 +test-product-description-1 second line +test-product-description-1 third line +forth line\\";draft;;;;;;;;;;Test collection 1;test-collection1;test-type-1;123_1;true;;profile_1;profile_type_1;product-export-strategy-variant-1;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;100;110;130;;test-option-1;option 1 value 1;test-option-2;option 2 value 1;test-image.png ", "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-2;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;;;110;test-option;Option 1 value 1;;;test-image.png ", @@ -13,15 +16,21 @@ Array [ ] `; -exports[`Product export strategy with sales channels should process the batch job and generate the appropriate output 1`] = ` +exports[`Product export strategy with sales Channels should process the batch job and generate the appropriate output 1`] = ` Array [ - "Product id;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External ID;Product Profile Name;Product Profile Type;Variant id;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow backorder;Variant Manage inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price france [USD];Price USD;Price denmark [DKK];Price Denmark [DKK];Option 1 Name;Option 1 Value;Option 2 Name;Option 2 Value;Image 1 Url;Sales channel 1 Name;Sales channel 1 Description;Sales channel 2 Name;Sales channel 2 Description + "Product Id;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External Id;Product Profile Name;Product Profile Type;Variant Id;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow Backorder;Variant Manage Inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price france [USD];Price USD;Price denmark [DKK];Price Denmark [DKK];Option 1 Name;Option 1 Value;Option 2 Name;Option 2 Value;Image 1 Url;Sales Channel 1 Id;Sales Channel 1 Name;Sales Channel 1 Description;Sales Channel 2 Id;Sales Channel 2 Name;Sales Channel 2 Description ", - "product-export-strategy-product-1;test-product-product-1;Test product;;\\"test-product-description-1\ntest-product-description-1 second line\ntest-product-description-1 third line\nforth line\\";draft;;;;;;;;;;Test collection 1;test-collection1;test-type-1;123_1;true;;profile_1;profile_type_1;product-export-strategy-variant-1;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;100;110;130;;test-option-1;option 1 value 1;test-option-2;option 2 value 1;test-image.png;SC 1;\\"SC 1\nSC 1 second line\nSC 1 third line\nSC 1 forth line\\";; + "product-export-strategy-product-1;test-product-product-1;Test product;;\\"test-product-description-1 +test-product-description-1 second line +test-product-description-1 third line +forth line\\";draft;;;;;;;;;;Test collection 1;test-collection1;test-type-1;123_1;true;;profile_1;profile_type_1;product-export-strategy-variant-1;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;100;110;130;;test-option-1;option 1 value 1;test-option-2;option 2 value 1;test-image.png;SC 1;SC 1;\\"SC 1 +SC 1 second line +SC 1 third line +SC 1 forth line\\";;; ", - "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-2;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;;;110;test-option;Option 1 value 1;;;test-image.png;SC 1;SC 1;SC 2;SC 2 + "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-2;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;;;110;test-option;Option 1 value 1;;;test-image.png;SC 1;SC 1;SC 1;SC 2;SC 2;SC 2 ", - "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-3;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;120;;;test-option;Option 1 Value 1;;;test-image.png;SC 1;SC 1;SC 2;SC 2 + "product-export-strategy-product-2;test-product-product-2;Test product;;test-product-description;draft;;;;;;;;;;Test collection;test-collection2;test-type;123;true;;profile_2;profile_type_2;product-export-strategy-variant-3;Test variant;test-sku;test-barcode;10;false;true;;;;;;;;;;120;;;test-option;Option 1 Value 1;;;test-image.png;SC 1;SC 1;SC 1;SC 2;SC 2;SC 2 ", ] `; diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts b/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts index 49e76b7a94cb4..2112f9841c1f4 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts @@ -7,7 +7,7 @@ import { AdminPostBatchesReq, defaultAdminProductRelations, } from "../../../../api" -import { ProductExportBatchJob } from "../../../batch-jobs/product" +import { ProductExportBatchJob } from "../../../batch-jobs/product/types" import { Request } from "express" import { FlagRouter } from "../../../../utils/flag-router" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" @@ -129,7 +129,7 @@ describe("Product export strategy", () => { ) await productExportStrategy.preProcessBatchJob(fakeJob.id) const template = await productExportStrategy.buildHeader(fakeJob) - expect(template).toMatch(/.*Product id.*/) + expect(template).toMatch(/.*Product Id.*/) expect(template).toMatch(/.*Product Handle.*/) expect(template).toMatch(/.*Product Title.*/) expect(template).toMatch(/.*Product Subtitle.*/) @@ -149,17 +149,17 @@ describe("Product export strategy", () => { expect(template).toMatch(/.*Product Type.*/) expect(template).toMatch(/.*Product Tags.*/) expect(template).toMatch(/.*Product Discountable.*/) - expect(template).toMatch(/.*Product External ID.*/) + expect(template).toMatch(/.*Product External Id.*/) expect(template).toMatch(/.*Product Profile Name.*/) expect(template).toMatch(/.*Product Profile Type.*/) expect(template).toMatch(/.*Product Profile Type.*/) - expect(template).toMatch(/.*Variant id.*/) + expect(template).toMatch(/.*Variant Id.*/) expect(template).toMatch(/.*Variant Title.*/) expect(template).toMatch(/.*Variant SKU.*/) expect(template).toMatch(/.*Variant Barcode.*/) - expect(template).toMatch(/.*Variant Allow backorder.*/) - expect(template).toMatch(/.*Variant Manage inventory.*/) + expect(template).toMatch(/.*Variant Allow Backorder.*/) + expect(template).toMatch(/.*Variant Manage Inventory.*/) expect(template).toMatch(/.*Variant Weight.*/) expect(template).toMatch(/.*Variant Length.*/) expect(template).toMatch(/.*Variant Width.*/) @@ -174,10 +174,12 @@ describe("Product export strategy", () => { expect(template).toMatch(/.*Option 2 Name.*/) expect(template).toMatch(/.*Option 2 Value.*/) - expect(template).not.toMatch(/.*Sales channel 1 Name.*/) - expect(template).not.toMatch(/.*Sales channel 1 Description.*/) - expect(template).not.toMatch(/.*Sales channel 2 Name.*/) - expect(template).not.toMatch(/.*Sales channel 2 Description.*/) + expect(template).not.toMatch(/.*Sales Channel 1 Id.*/) + expect(template).not.toMatch(/.*Sales Channel 1 Name.*/) + expect(template).not.toMatch(/.*Sales Channel 1 Description.*/) + expect(template).not.toMatch(/.*Sales Channel 2 Id.*/) + expect(template).not.toMatch(/.*Sales Channel 2 Name.*/) + expect(template).not.toMatch(/.*Sales Channel 2 Description.*/) expect(template).toMatch(/.*Price USD.*/) expect(template).toMatch(/.*Price france \[USD\].*/) @@ -298,7 +300,7 @@ describe("Product export strategy", () => { }) }) -describe("Product export strategy with sales channels", () => { +describe("Product export strategy with sales Channels", () => { const outputDataStorage: string[] = [] const fileServiceMock = { delete: jest.fn(), @@ -394,7 +396,7 @@ describe("Product export strategy with sales channels", () => { ) await productExportStrategy.preProcessBatchJob(fakeJob.id) const template = await productExportStrategy.buildHeader(fakeJob) - expect(template).toMatch(/.*Product id.*/) + expect(template).toMatch(/.*Product Id.*/) expect(template).toMatch(/.*Product Handle.*/) expect(template).toMatch(/.*Product Title.*/) expect(template).toMatch(/.*Product Subtitle.*/) @@ -414,17 +416,17 @@ describe("Product export strategy with sales channels", () => { expect(template).toMatch(/.*Product Type.*/) expect(template).toMatch(/.*Product Tags.*/) expect(template).toMatch(/.*Product Discountable.*/) - expect(template).toMatch(/.*Product External ID.*/) + expect(template).toMatch(/.*Product External Id.*/) expect(template).toMatch(/.*Product Profile Name.*/) expect(template).toMatch(/.*Product Profile Type.*/) expect(template).toMatch(/.*Product Profile Type.*/) - expect(template).toMatch(/.*Variant id.*/) + expect(template).toMatch(/.*Variant Id.*/) expect(template).toMatch(/.*Variant Title.*/) expect(template).toMatch(/.*Variant SKU.*/) expect(template).toMatch(/.*Variant Barcode.*/) - expect(template).toMatch(/.*Variant Allow backorder.*/) - expect(template).toMatch(/.*Variant Manage inventory.*/) + expect(template).toMatch(/.*Variant Allow Backorder.*/) + expect(template).toMatch(/.*Variant Manage Inventory.*/) expect(template).toMatch(/.*Variant Weight.*/) expect(template).toMatch(/.*Variant Length.*/) expect(template).toMatch(/.*Variant Width.*/) @@ -444,10 +446,12 @@ describe("Product export strategy with sales channels", () => { expect(template).toMatch(/.*Price denmark \[DKK\].*/) expect(template).toMatch(/.*Price Denmark \[DKK\].*/) - expect(template).toMatch(/.*Sales channel 1 Name.*/) - expect(template).toMatch(/.*Sales channel 1 Description.*/) - expect(template).toMatch(/.*Sales channel 2 Name.*/) - expect(template).toMatch(/.*Sales channel 2 Description.*/) + expect(template).toMatch(/.*Sales Channel 1 Id.*/) + expect(template).toMatch(/.*Sales Channel 1 Name.*/) + expect(template).toMatch(/.*Sales Channel 1 Description.*/) + expect(template).toMatch(/.*Sales Channel 2 Id.*/) + expect(template).toMatch(/.*Sales Channel 2 Name.*/) + expect(template).toMatch(/.*Sales Channel 2 Description.*/) expect(template).toMatch(/.*Image 1 Url.*/) }) diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/product/import.ts b/packages/medusa/src/strategies/__tests__/batch-jobs/product/import.ts index 39af7bcff6718..3e27ef5b8795a 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/product/import.ts +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/product/import.ts @@ -15,7 +15,7 @@ import { import { BatchJobStatus } from "../../../../types/batch-job" import { FlagRouter } from "../../../../utils/flag-router" import ProductImportStrategy from "../../../batch-jobs/product/import" -import { InjectedProps } from "../../../batch-jobs/product/types" +import { ProductImportInjectedProps } from "../../../batch-jobs/product/types" let fakeJob = { id: IdMap.getId("product-import-job"), @@ -32,7 +32,7 @@ let fakeJob = { } async function* generateCSVDataForStream() { - yield "Product id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External ID,Variant id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow backorder,Variant Manage inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price france [USD],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url\n" + yield "Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price france [USD],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url\n" yield ",test-product-product-1,Test product,,test-product-description-1,draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,SebniWTDeC,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,100,110,130,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png\n" yield "5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,110,test-option,Option 1 value 1,,,test-image.png\n" yield "5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,3SS1MHGDEJ,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,120,,,test-option,Option 1 Value blue,,,test-image.png\n" @@ -147,7 +147,7 @@ describe("Product import strategy", () => { productVariantServiceMock as unknown as ProductVariantService, regionService: regionServiceMock as unknown as RegionService, featureFlagRouter: new FlagRouter({}), - } as unknown as InjectedProps) + } as unknown as ProductImportInjectedProps) it("`preProcessBatchJob` should generate import ops and upload them to a bucket using the file service", async () => { const getImportInstructionsSpy = jest.spyOn( diff --git a/packages/medusa/src/strategies/batch-jobs/product/export.ts b/packages/medusa/src/strategies/batch-jobs/product/export.ts index fba433d06eebf..c2a03933bd56b 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/export.ts @@ -6,24 +6,20 @@ import { BatchJobStatus, CreateBatchJobInput } from "../../../types/batch-job" import { defaultAdminProductRelations } from "../../../api" import { prepareListQuery } from "../../../utils/get-query-config" import { + DynamicProductExportDescriptor, ProductExportBatchJob, ProductExportBatchJobContext, - ProductExportColumnSchemaDescriptor, + ProductExportInjectedDependencies, ProductExportPriceData, - productExportSchemaDescriptors, -} from "./index" +} from "./types" import { FindProductConfig } from "../../../types/product" import { FlagRouter } from "../../../utils/flag-router" import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" import { csvCellContentFormatter } from "../../../utils" - -type InjectedDependencies = { - manager: EntityManager - batchJobService: BatchJobService - productService: ProductService - fileService: IFileService - featureFlagRouter: FlagRouter -} +import { + productColumnsDefinition, + productSalesChannelColumnsDefinition, +} from "./types/columns-definition" export default class ProductExportStrategy extends AbstractBatchJobStrategy { public static identifier = "product-export-strategy" @@ -48,10 +44,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { * column descriptors to this map. * */ - protected readonly columnDescriptors: Map< - string, - ProductExportColumnSchemaDescriptor - > = new Map(productExportSchemaDescriptors) + protected readonly columnsDefinition = { ...productColumnsDefinition } + protected readonly salesChannelsColumnsDefinition = { + ...productSalesChannelColumnsDefinition, + } private readonly NEWLINE_ = "\r\n" private readonly DELIMITER_ = ";" @@ -63,7 +59,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { productService, fileService, featureFlagRouter, - }: InjectedDependencies) { + }: ProductExportInjectedDependencies) { super({ manager, batchJobService, @@ -324,58 +320,136 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { this.appendImagesDescriptors(dynamicImageColumnCount) this.appendSalesChannelsDescriptors(dynamicSalesChannelsColumnCount) - return ( - [...this.columnDescriptors.keys()].join(this.DELIMITER_) + this.NEWLINE_ - ) + const exportedColumns = Object.values(this.columnsDefinition) + .map( + (descriptor) => + descriptor.exportDescriptor && + !("isDynamic" in descriptor.exportDescriptor) && + descriptor.name + ) + .filter((name): name is string => !!name) + + return exportedColumns.join(this.DELIMITER_) + this.NEWLINE_ } private appendImagesDescriptors(maxImagesCount: number): void { + const columnNameBuilder = (this.columnsDefinition["Image Url"]! + .exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + for (let i = 0; i < maxImagesCount; ++i) { - this.columnDescriptors.set(`Image ${i + 1} Url`, { - accessor: (product: Product) => product?.images[i]?.url ?? "", - entityName: "product", - }) + const columnName = columnNameBuilder(i) + + this.columnsDefinition[columnName] = { + name: columnName, + exportDescriptor: { + accessor: (product: Product) => product?.images[i]?.url ?? "", + entityName: "product", + }, + } } } private appendSalesChannelsDescriptors(maxScCount: number): void { + const columnNameIdBuilder = (this.salesChannelsColumnsDefinition[ + "Sales Channel Id" + ]!.exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + + const columnNameNameBuilder = (this.salesChannelsColumnsDefinition[ + "Sales Channel Name" + ]!.exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + + const columnNameDescriptionBuilder = (this.salesChannelsColumnsDefinition[ + "Sales Channel Description" + ]!.exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + for (let i = 0; i < maxScCount; ++i) { - this.columnDescriptors.set(`Sales channel ${i + 1} Name`, { - accessor: (product: Product) => product?.sales_channels[i]?.name ?? "", - entityName: "product", - }) - this.columnDescriptors.set(`Sales channel ${i + 1} Description`, { - accessor: (product: Product) => - product?.sales_channels[i]?.description ?? "", - entityName: "product", - }) + const columnNameId = columnNameIdBuilder(i) + + this.columnsDefinition[columnNameId] = { + name: columnNameId, + exportDescriptor: { + accessor: (product: Product) => + product?.sales_channels[i]?.name ?? "", + entityName: "product", + }, + } + + const columnNameName = columnNameNameBuilder(i) + + this.columnsDefinition[columnNameName] = { + name: columnNameName, + exportDescriptor: { + accessor: (product: Product) => + product?.sales_channels[i]?.name ?? "", + entityName: "product", + }, + } + + const columnNameDescription = columnNameDescriptionBuilder(i) + + this.columnsDefinition[columnNameDescription] = { + name: columnNameDescription, + exportDescriptor: { + accessor: (product: Product) => + product?.sales_channels[i]?.description ?? "", + entityName: "product", + }, + } } } private appendOptionsDescriptors(maxOptionsCount: number): void { for (let i = 0; i < maxOptionsCount; ++i) { - this.columnDescriptors - .set(`Option ${i + 1} Name`, { + const columnNameNameBuilder = (this.columnsDefinition["Option Name"]! + .exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + + const columnNameName = columnNameNameBuilder(i) + + this.columnsDefinition[columnNameName] = { + name: columnNameName, + exportDescriptor: { accessor: (productOption: Product) => productOption?.options[i]?.title ?? "", entityName: "product", - }) - .set(`Option ${i + 1} Value`, { + }, + } + + const columnNameValueBuilder = (this.columnsDefinition["Option Value"]! + .exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + + const columnNameNameValue = columnNameValueBuilder(i) + + this.columnsDefinition[columnNameNameValue] = { + name: columnNameNameValue, + exportDescriptor: { accessor: (variant: ProductVariant) => variant?.options[i]?.value ?? "", entityName: "variant", - }) + }, + } } } private appendMoneyAmountDescriptors( pricesData: ProductExportPriceData[] ): void { + const columnNameBuilder = (this.columnsDefinition["Price Currency"]! + .exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + for (const priceData of pricesData) { if (priceData.currency_code) { - this.columnDescriptors.set( - `Price ${priceData.currency_code?.toUpperCase()}`, - { + const columnName = columnNameBuilder(priceData) + + this.columnsDefinition[columnName] = { + name: columnName, + exportDescriptor: { accessor: (variant: ProductVariant) => { const price = variant.prices.find((variantPrice) => { return ( @@ -388,18 +462,19 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { return price?.amount?.toString() ?? "" }, entityName: "variant", - } - ) + }, + } } if (priceData.region) { - this.columnDescriptors.set( - `Price ${priceData.region.name} ${ - priceData.region?.currency_code - ? "[" + priceData.region?.currency_code.toUpperCase() + "]" - : "" - }`, - { + const columnNameBuilder = (this.columnsDefinition["Price Region"]! + .exportDescriptor as DynamicProductExportDescriptor)! + .buildDynamicColumnName + const columnName = columnNameBuilder(priceData) + + this.columnsDefinition[columnName] = { + name: columnName, + exportDescriptor: { accessor: (variant: ProductVariant) => { const price = variant.prices.find((variantPrice) => { return ( @@ -414,8 +489,8 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { return price?.amount?.toString() ?? "" }, entityName: "variant", - } - ) + }, + } } } } @@ -425,7 +500,11 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { for (const variant of product.variants) { const variantLineData: string[] = [] - for (const [, columnSchema] of this.columnDescriptors.entries()) { + for (const [, { exportDescriptor: columnSchema }] of Object.entries( + this.columnsDefinition + )) { + if (!columnSchema || "isDynamic" in columnSchema) continue + if (columnSchema.entityName === "product") { const formattedContent = csvCellContentFormatter( columnSchema.accessor(product) diff --git a/packages/medusa/src/strategies/batch-jobs/product/import.ts b/packages/medusa/src/strategies/batch-jobs/product/import.ts index 8cbdd448a30e9..a4a1175e0bc98 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/import.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/import.ts @@ -17,19 +17,22 @@ import { CreateProductVariantInput, UpdateProductVariantInput, } from "../../../types/product-variant" +import { BatchJob, SalesChannel } from "../../../models" +import { FlagRouter } from "../../../utils/flag-router" +import { transformProductData, transformVariantData } from "./utils" +import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" import { - ImportJobContext, - InjectedProps, OperationType, ProductImportBatchJob, ProductImportCsvSchema, - TBuiltProductImportLine, + ProductImportInjectedProps, + ProductImportJobContext, TParsedProductImportRowData, } from "./types" -import { BatchJob, Product, SalesChannel } from "../../../models" -import { FlagRouter } from "../../../utils/flag-router" -import { transformProductData, transformVariantData } from "./utils" -import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" +import { + productImportColumnsDefinition, + productImportSalesChannelsColumnsDefinition, +} from "./types/columns-definition" /** * Process this many variant rows before reporting progress. @@ -76,7 +79,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { fileService, manager, featureFlagRouter, - }: InjectedProps) { + }: ProductImportInjectedProps) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -85,10 +88,11 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { ) this.csvParser_ = new CsvParser({ - ...CSVSchema, columns: [ - ...CSVSchema.columns, - ...(isSalesChannelsFeatureOn ? SalesChannelsSchema.columns : []), + ...productImportColumnsDefinition.columns, + ...(isSalesChannelsFeatureOn + ? productImportSalesChannelsColumnsDefinition.columns + : []), ], }) @@ -235,7 +239,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { .withTransaction(transactionManager) .retrieve(batchJobId) - const csvFileKey = (batchJob.context as ImportJobContext).fileKey + const csvFileKey = (batchJob.context as ProductImportJobContext).fileKey const csvStream = await this.fileService_.getDownloadStream({ fileKey: csvFileKey, }) @@ -393,7 +397,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { productData["sales_channels"] = await this.processSalesChannels( productOp["product.sales_channels"] as Pick< SalesChannel, - "name" | "id" + "name" | "id" | "description" >[] ) } @@ -437,7 +441,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { productData["sales_channels"] = await this.processSalesChannels( productOp["product.sales_channels"] as Pick< SalesChannel, - "name" | "id" + "name" | "id" | "description" >[] ) } @@ -685,7 +689,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { result: { advancement_count: batchJob.result.count }, }) - const { fileKey } = batchJob.context as ImportJobContext + const { fileKey } = batchJob.context as ProductImportJobContext await this.fileService_ .withTransaction(transactionManager) @@ -728,258 +732,3 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { } export default ProductImportStrategy - -/** - * Schema definition for the CSV parser. - */ -const CSVSchema: ProductImportCsvSchema = { - columns: [ - // PRODUCT - { - name: "Product id", - mapTo: "product.id", - }, - { - name: "Product Handle", - mapTo: "product.handle", - required: true, - }, - { name: "Product Title", mapTo: "product.title" }, - { name: "Product Subtitle", mapTo: "product.subtitle" }, - { name: "Product Description", mapTo: "product.description" }, - { name: "Product Status", mapTo: "product.status" }, - { name: "Product Thumbnail", mapTo: "product.thumbnail" }, - { name: "Product Weight", mapTo: "product.weight" }, - { name: "Product Length", mapTo: "product.length" }, - { name: "Product Width", mapTo: "product.width" }, - { name: "Product Height", mapTo: "product.height" }, - { name: "Product HS Code", mapTo: "product.hs_code" }, - { name: "Product Origin Country", mapTo: "product.origin_country" }, - { name: "Product MID Code", mapTo: "product.mid_code" }, - { name: "Product Material", mapTo: "product.material" }, - // PRODUCT-COLLECTION - { name: "Product Collection Title", mapTo: "product.collection.title" }, - { name: "Product Collection Handle", mapTo: "product.collection.handle" }, - // PRODUCT-TYPE - { - name: "Product Type", - match: /Product Type/, - reducer: ( - builtLine: TParsedProductImportRowData, - key, - value - ): TBuiltProductImportLine => { - if (typeof value === "undefined" || value === null) { - builtLine["product.type"] = undefined - } else { - builtLine["product.type.value"] = value - } - - return builtLine - }, - }, - // PRODUCT-TAGS - { - name: "Product Tags", - mapTo: "product.tags", - transform: (value: string) => - `${value}`.split(",").map((v) => ({ value: v })), - }, - // - { name: "Product Discountable", mapTo: "product.discountable" }, - { name: "Product External ID", mapTo: "product.external_id" }, - - // VARIANTS - { - name: "Variant id", - mapTo: "variant.id", - }, - { name: "Variant Title", mapTo: "variant.title" }, - { name: "Variant SKU", mapTo: "variant.sku" }, - { name: "Variant Barcode", mapTo: "variant.barcode" }, - { name: "Variant Inventory Quantity", mapTo: "variant.inventory_quantity" }, - { name: "Variant Allow backorder", mapTo: "variant.allow_backorder" }, - { name: "Variant Manage inventory", mapTo: "variant.manage_inventory" }, - { name: "Variant Weight", mapTo: "variant.weight" }, - { name: "Variant Length", mapTo: "variant.length" }, - { name: "Variant Width", mapTo: "variant.width" }, - { name: "Variant Height", mapTo: "variant.height" }, - { name: "Variant HS Code", mapTo: "variant.hs_code" }, - { name: "Variant Origin Country", mapTo: "variant.origin_country" }, - { name: "Variant MID Code", mapTo: "variant.mid_code" }, - { name: "Variant Material", mapTo: "variant.material" }, - - // ==== DYNAMIC FIELDS ==== - - // PRODUCT_OPTIONS - { - name: "Option Name", - match: /Option \d+ Name/, - reducer: (builtLine, key, value): TBuiltProductImportLine => { - builtLine["product.options"] = builtLine["product.options"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - const options = builtLine["product.options"] as Record< - string, - string | number - >[] - - options.push({ title: value }) - - return builtLine - }, - }, - { - name: "Option Value", - match: /Option \d+ Value/, - reducer: ( - builtLine: TParsedProductImportRowData, - key: string, - value: string, - context: any - ): TBuiltProductImportLine => { - builtLine["variant.options"] = builtLine["variant.options"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - const options = builtLine["variant.options"] as Record< - string, - string | number - >[] - - options.push({ - value, - _title: context.line[key.slice(0, -6) + " Name"], - }) - - return builtLine - }, - }, - - // PRICES - { - name: "Price Region", - match: /Price (.*) \[([A-Z]{3})\]/, - reducer: ( - builtLine: TParsedProductImportRowData, - key, - value - ): TBuiltProductImportLine => { - builtLine["variant.prices"] = builtLine["variant.prices"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - const [, regionName] = - key.trim().match(/Price (.*) \[([A-Z]{3})\]/) || [] - ;( - builtLine["variant.prices"] as Record[] - ).push({ - amount: parseFloat(value), - regionName, - }) - - return builtLine - }, - }, - { - name: "Price Currency", - match: /Price [A-Z]{3}/, - reducer: ( - builtLine: TParsedProductImportRowData, - key, - value - ): TBuiltProductImportLine => { - builtLine["variant.prices"] = builtLine["variant.prices"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - const currency = key.trim().split(" ")[1] - - ;( - builtLine["variant.prices"] as Record[] - ).push({ - amount: parseFloat(value), - currency_code: currency, - }) - - return builtLine - }, - }, - // IMAGES - { - name: "Image Url", - match: /Image \d+ Url/, - reducer: (builtLine: any, key, value): TBuiltProductImportLine => { - builtLine["product.images"] = builtLine["product.images"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - builtLine["product.images"].push(value) - - return builtLine - }, - }, - ], -} - -const SalesChannelsSchema: ProductImportCsvSchema = { - columns: [ - { - name: "Sales Channel Name", - match: /Sales Channel \d+ Name/, - reducer: (builtLine, key, value): TBuiltProductImportLine => { - builtLine["product.sales_channels"] = - builtLine["product.sales_channels"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - const channels = builtLine["product.sales_channels"] as Record< - string, - string | number - >[] - - channels.push({ - name: value, - }) - - return builtLine - }, - }, - { - name: "Sales Channel Id", - match: /Sales Channel \d+ Id/, - reducer: (builtLine, key, value): TBuiltProductImportLine => { - builtLine["product.sales_channels"] = - builtLine["product.sales_channels"] || [] - - if (typeof value === "undefined" || value === null) { - return builtLine - } - - const channels = builtLine["product.sales_channels"] as Record< - string, - string | number - >[] - - channels.push({ - id: value, - }) - - return builtLine - }, - }, - ], -} diff --git a/packages/medusa/src/strategies/batch-jobs/product/index.ts b/packages/medusa/src/strategies/batch-jobs/product/index.ts deleted file mode 100644 index 28bcdacf2d1c0..0000000000000 --- a/packages/medusa/src/strategies/batch-jobs/product/index.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { BatchJob, Product, ProductVariant } from "../../../models" -import { Selector } from "../../../types/common" - -export type ProductExportBatchJobContext = { - retry_count?: number - max_retry?: number - offset?: number - limit?: number - batch_size?: number - order?: string - fields?: string - expand?: string - shape: { - prices: ProductExportPriceData[] - dynamicOptionColumnCount: number - dynamicImageColumnCount: number - dynamicSalesChannelsColumnCount: number - } - list_config?: { - select?: string[] - relations?: string[] - skip?: number - take?: number - order?: Record - } - filterable_fields?: Selector -} - -export type ProductExportPriceData = { - currency_code?: string - region?: { name: string; currency_code: string; id: string } -} - -export type ProductExportBatchJob = BatchJob & { - context: ProductExportBatchJobContext -} - -export type ProductExportColumnSchemaEntity = "product" | "variant" - -export type ProductExportColumnSchemaDescriptor = - | { - accessor: (product: Product) => string - entityName: Extract - } - | { - accessor: (variant: ProductVariant) => string - entityName: Extract - } - -export const productExportSchemaDescriptors = new Map< - string, - ProductExportColumnSchemaDescriptor ->([ - [ - "Product id", - { - accessor: (product: Product): string => product?.id ?? "", - entityName: "product", - }, - ], - [ - "Product Handle", - { - accessor: (product: Product): string => product?.handle ?? "", - entityName: "product", - }, - ], - [ - "Product Title", - { - accessor: (product: Product): string => product?.title ?? "", - entityName: "product", - }, - ], - [ - "Product Subtitle", - { - accessor: (product: Product): string => product?.subtitle ?? "", - entityName: "product", - }, - ], - [ - "Product Description", - { - accessor: (product: Product): string => product?.description ?? "", - entityName: "product", - }, - ], - [ - "Product Status", - { - accessor: (product: Product): string => product?.status ?? "", - entityName: "product", - }, - ], - [ - "Product Thumbnail", - { - accessor: (product: Product): string => product?.thumbnail ?? "", - entityName: "product", - }, - ], - [ - "Product Weight", - { - accessor: (product: Product): string => product?.weight?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product Length", - { - accessor: (product: Product): string => product?.length?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product Width", - { - accessor: (product: Product): string => product?.width?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product Height", - { - accessor: (product: Product): string => product?.height?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product HS Code", - { - accessor: (product: Product): string => - product?.hs_code?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product Origin Country", - { - accessor: (product: Product): string => - product?.origin_country?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product MID Code", - { - accessor: (product: Product): string => - product?.mid_code?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product Material", - { - accessor: (product: Product): string => - product?.material?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product Collection Title", - { - accessor: (product: Product): string => product?.collection?.title ?? "", - entityName: "product", - }, - ], - [ - "Product Collection Handle", - { - accessor: (product: Product): string => product?.collection?.handle ?? "", - entityName: "product", - }, - ], - [ - "Product Type", - { - accessor: (product: Product): string => product?.type?.value ?? "", - entityName: "product", - }, - ], - [ - "Product Tags", - { - accessor: (product: Product): string => - (product.tags.map((t) => t.value) ?? []).join(","), - entityName: "product", - }, - ], - [ - "Product Discountable", - { - accessor: (product: Product): string => - product?.discountable?.toString() ?? "", - entityName: "product", - }, - ], - [ - "Product External ID", - { - accessor: (product: Product): string => product?.external_id ?? "", - entityName: "product", - }, - ], - [ - "Product Profile Name", - { - accessor: (product: Product): string => product?.profile?.name ?? "", - entityName: "product", - }, - ], - [ - "Product Profile Type", - { - accessor: (product: Product): string => product?.profile?.type ?? "", - entityName: "product", - }, - ], - [ - "Variant id", - { - accessor: (variant: ProductVariant): string => variant?.id ?? "", - entityName: "variant", - }, - ], - [ - "Variant Title", - { - accessor: (variant: ProductVariant): string => variant?.title ?? "", - entityName: "variant", - }, - ], - [ - "Variant SKU", - { - accessor: (variant: ProductVariant): string => variant?.sku ?? "", - entityName: "variant", - }, - ], - [ - "Variant Barcode", - { - accessor: (variant: ProductVariant): string => variant?.barcode ?? "", - entityName: "variant", - }, - ], - [ - "Variant Inventory Quantity", - { - accessor: (variant: ProductVariant): string => - variant?.inventory_quantity?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Allow backorder", - { - accessor: (variant: ProductVariant): string => - variant?.allow_backorder?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Manage inventory", - { - accessor: (variant: ProductVariant): string => - variant?.manage_inventory?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Weight", - { - accessor: (variant: ProductVariant): string => - variant?.weight?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Length", - { - accessor: (variant: ProductVariant): string => - variant?.length?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Width", - { - accessor: (variant: ProductVariant): string => - variant?.width?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Height", - { - accessor: (variant: ProductVariant): string => - variant?.height?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant HS Code", - { - accessor: (variant: ProductVariant): string => - variant?.hs_code?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Origin Country", - { - accessor: (variant: ProductVariant): string => - variant?.origin_country?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant MID Code", - { - accessor: (variant: ProductVariant): string => - variant?.mid_code?.toString() ?? "", - entityName: "variant", - }, - ], - [ - "Variant Material", - { - accessor: (variant: ProductVariant): string => - variant?.material?.toString() ?? "", - entityName: "variant", - }, - ], -]) diff --git a/packages/medusa/src/strategies/batch-jobs/product/types.ts b/packages/medusa/src/strategies/batch-jobs/product/types.ts deleted file mode 100644 index 383fa20e5c5a2..0000000000000 --- a/packages/medusa/src/strategies/batch-jobs/product/types.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { EntityManager } from "typeorm" -import { FileService } from "medusa-interfaces" - -import { - BatchJobService, - ProductService, - ProductVariantService, - RegionService, - SalesChannelService, - ShippingProfileService, -} from "../../../services" -import { CsvSchema } from "../../../interfaces/csv-parser" -import { FlagRouter } from "../../../utils/flag-router" -import { BatchJob } from "../../../models" - -export type ProductImportBatchJob = BatchJob & { - result: Pick & { - operations: { - [K in keyof typeof OperationType]: number - } - } -} - -/** - * DI props for the Product import strategy - */ -export type InjectedProps = { - batchJobService: BatchJobService - productService: ProductService - productVariantService: ProductVariantService - shippingProfileService: ShippingProfileService - salesChannelService: SalesChannelService - regionService: RegionService - fileService: typeof FileService - - featureFlagRouter: FlagRouter - manager: EntityManager -} - -/** - * Data shape returned by the CSVParser. - */ -export type TParsedProductImportRowData = Record< - string, - string | number | object | undefined | (string | number | object)[] -> - -/** - * CSV parser's row reducer result data shape. - */ -export type TBuiltProductImportLine = Record - -/** - * Schema definition of for an import CSV file. - */ -export type ProductImportCsvSchema = CsvSchema< - TParsedProductImportRowData, - TBuiltProductImportLine -> - -/** - * Import Batch job context column type. - */ -export type ImportJobContext = { - total: number - fileKey: string -} - -/** - * Supported batch job import ops. - */ -export enum OperationType { - ProductCreate = "PRODUCT_CREATE", - ProductUpdate = "PRODUCT_UPDATE", - VariantCreate = "VARIANT_CREATE", - VariantUpdate = "VARIANT_UPDATE", -} diff --git a/packages/medusa/src/strategies/batch-jobs/product/types/columns-definition.ts b/packages/medusa/src/strategies/batch-jobs/product/types/columns-definition.ts new file mode 100644 index 0000000000000..29d99b5f85be4 --- /dev/null +++ b/packages/medusa/src/strategies/batch-jobs/product/types/columns-definition.ts @@ -0,0 +1,755 @@ +import { Product, ProductVariant } from "../../../../models" +import { + ProductColumnDefinition, + ProductExportPriceData, + TBuiltProductImportLine, + TParsedProductImportRowData, +} from "./index" +import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser" + +export const productColumnsDefinition: ProductColumnDefinition = { + "Product Id": { + name: "Product Id", + importDescriptor: { + mapTo: "product.id", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.id ?? "", + entityName: "product", + }, + }, + "Product Handle": { + name: "Product Handle", + importDescriptor: { + mapTo: "product.handle", + required: true, + }, + exportDescriptor: { + accessor: (product: Product): string => product?.handle ?? "", + entityName: "product", + }, + }, + "Product Title": { + name: "Product Title", + importDescriptor: { + mapTo: "product.title", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.title ?? "", + entityName: "product", + }, + }, + "Product Subtitle": { + name: "Product Subtitle", + importDescriptor: { + mapTo: "product.subtitle", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.subtitle ?? "", + entityName: "product", + }, + }, + "Product Description": { + name: "Product Description", + importDescriptor: { + mapTo: "product.description", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.description ?? "", + entityName: "product", + }, + }, + "Product Status": { + name: "Product Status", + importDescriptor: { + mapTo: "product.status", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.status ?? "", + entityName: "product", + }, + }, + "Product Thumbnail": { + name: "Product Thumbnail", + importDescriptor: { + mapTo: "product.thumbnail", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.thumbnail ?? "", + entityName: "product", + }, + }, + "Product Weight": { + name: "Product Weight", + importDescriptor: { + mapTo: "product.weight", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.weight?.toString() ?? "", + entityName: "product", + }, + }, + "Product Length": { + name: "Product Length", + importDescriptor: { + mapTo: "product.length", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.length?.toString() ?? "", + entityName: "product", + }, + }, + "Product Width": { + name: "Product Width", + importDescriptor: { + mapTo: "product.width", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.width?.toString() ?? "", + entityName: "product", + }, + }, + "Product Height": { + name: "Product Height", + importDescriptor: { + mapTo: "product.height", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.height?.toString() ?? "", + entityName: "product", + }, + }, + "Product HS Code": { + name: "Product HS Code", + importDescriptor: { + mapTo: "product.hs_code", + }, + exportDescriptor: { + accessor: (product: Product): string => + product?.hs_code?.toString() ?? "", + entityName: "product", + }, + }, + "Product Origin Country": { + name: "Product Origin Country", + importDescriptor: { + mapTo: "product.origin_country", + }, + exportDescriptor: { + accessor: (product: Product): string => + product?.origin_country?.toString() ?? "", + entityName: "product", + }, + }, + "Product MID Code": { + name: "Product MID Code", + importDescriptor: { + mapTo: "product.mid_code", + }, + exportDescriptor: { + accessor: (product: Product): string => + product?.mid_code?.toString() ?? "", + entityName: "product", + }, + }, + "Product Material": { + name: "Product Material", + importDescriptor: { + mapTo: "product.material", + }, + exportDescriptor: { + accessor: (product: Product): string => + product?.material?.toString() ?? "", + entityName: "product", + }, + }, + + // PRODUCT-COLLECTION + + "Product Collection Title": { + name: "Product Collection Title", + importDescriptor: { + mapTo: "product.collection.title", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.collection?.title ?? "", + entityName: "product", + }, + }, + "Product Collection Handle": { + name: "Product Collection Handle", + importDescriptor: { + mapTo: "product.collection.handle", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.collection?.handle ?? "", + entityName: "product", + }, + }, + + // PRODUCT-TYPE + + "Product Type": { + name: "Product Type", + importDescriptor: { + match: /Product Type/, + reducer: ( + builtLine: TParsedProductImportRowData, + key, + value + ): TBuiltProductImportLine => { + if (typeof value === "undefined" || value === null) { + builtLine["product.type"] = undefined + } else { + builtLine["product.type.value"] = value + } + + return builtLine + }, + }, + exportDescriptor: { + accessor: (product: Product): string => product?.type?.value ?? "", + entityName: "product", + }, + }, + + // PRODUCT-TAGS + + "Product Tags": { + name: "Product Tags", + importDescriptor: { + mapTo: "product.tags", + transform: (value: string) => { + return value && `${value}`.split(",").map((v) => ({ value: v })) + }, + }, + exportDescriptor: { + accessor: (product: Product): string => + (product.tags.map((t) => t.value) ?? []).join(","), + entityName: "product", + }, + }, + + // + + "Product Discountable": { + name: "Product Discountable", + importDescriptor: { + mapTo: "product.discountable", + }, + exportDescriptor: { + accessor: (product: Product): string => + product?.discountable?.toString() ?? "", + entityName: "product", + }, + }, + "Product External Id": { + name: "Product External Id", + importDescriptor: { + mapTo: "product.external_id", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.external_id ?? "", + entityName: "product", + }, + }, + + // PRODUCT-PROFILE + + "Product Profile Name": { + name: "Product Profile Name", + importDescriptor: { + mapTo: "__not_supported__", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.profile?.name ?? "", + entityName: "product", + }, + }, + + "Product Profile Type": { + name: "Product Profile Type", + importDescriptor: { + mapTo: "__not_supported__", + }, + exportDescriptor: { + accessor: (product: Product): string => product?.profile?.type ?? "", + entityName: "product", + }, + }, + + // VARIANTS + + "Variant Id": { + name: "Variant Id", + importDescriptor: { + mapTo: "variant.id", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => variant?.id ?? "", + entityName: "variant", + }, + }, + "Variant Title": { + name: "Variant Title", + + importDescriptor: { + mapTo: "variant.title", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => variant?.title ?? "", + entityName: "variant", + }, + }, + "Variant SKU": { + name: "Variant SKU", + + importDescriptor: { + mapTo: "variant.sku", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => variant?.sku ?? "", + entityName: "variant", + }, + }, + "Variant Barcode": { + name: "Variant Barcode", + + importDescriptor: { + mapTo: "variant.barcode", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => variant?.barcode ?? "", + entityName: "variant", + }, + }, + "Variant Inventory Quantity": { + name: "Variant Inventory Quantity", + + importDescriptor: { + mapTo: "variant.inventory_quantity", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.inventory_quantity?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Allow Backorder": { + name: "Variant Allow Backorder", + + importDescriptor: { + mapTo: "variant.allow_backorder", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.allow_backorder?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Manage Inventory": { + name: "Variant Manage Inventory", + importDescriptor: { + mapTo: "variant.manage_inventory", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.manage_inventory?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Weight": { + name: "Variant Weight", + + importDescriptor: { + mapTo: "variant.weight", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.weight?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Length": { + name: "Variant Length", + + importDescriptor: { + mapTo: "variant.length", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.length?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Width": { + name: "Variant Width", + importDescriptor: { + mapTo: "variant.width", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.width?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Height": { + name: "Variant Height", + importDescriptor: { + mapTo: "variant.height", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.height?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant HS Code": { + name: "Variant HS Code", + importDescriptor: { + mapTo: "variant.hs_code", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.hs_code?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Origin Country": { + name: "Variant Origin Country", + importDescriptor: { + mapTo: "variant.origin_country", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.origin_country?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant MID Code": { + name: "Variant MID Code", + importDescriptor: { + mapTo: "variant.mid_code", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.mid_code?.toString() ?? "", + entityName: "variant", + }, + }, + "Variant Material": { + name: "Variant Material", + importDescriptor: { + mapTo: "variant.material", + }, + exportDescriptor: { + accessor: (variant: ProductVariant): string => + variant?.material?.toString() ?? "", + entityName: "variant", + }, + }, + + // ==== DYNAMIC FIELDS ==== + + // PRODUCT_OPTIONS + + "Option Name": { + name: "Option Name", + importDescriptor: { + match: /Option \d+ Name/, + reducer: (builtLine, key, value): TBuiltProductImportLine => { + builtLine["product.options"] = builtLine["product.options"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const options = builtLine["product.options"] as Record< + string, + string | number + >[] + + options.push({ title: value }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (index: number) => { + return `Option ${index + 1} Name` + }, + }, + }, + "Option Value": { + name: "Option Value", + importDescriptor: { + match: /Option \d+ Value/, + reducer: ( + builtLine: TParsedProductImportRowData, + key: string, + value: string, + context: any + ): TBuiltProductImportLine => { + builtLine["variant.options"] = builtLine["variant.options"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const options = builtLine["variant.options"] as Record< + string, + string | number + >[] + + options.push({ + value, + _title: context.line[key.slice(0, -6) + " Name"], + }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (index: number) => { + return `Option ${index + 1} Value` + }, + }, + }, + + // PRICES + + "Price Region": { + name: "Price Region", + importDescriptor: { + match: /Price (.*) \[([A-Z]{3})\]/, + reducer: ( + builtLine: TParsedProductImportRowData, + key, + value + ): TBuiltProductImportLine => { + builtLine["variant.prices"] = builtLine["variant.prices"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const [, regionName] = + key.trim().match(/Price (.*) \[([A-Z]{3})\]/) || [] + ;( + builtLine["variant.prices"] as Record[] + ).push({ + amount: parseFloat(value), + regionName, + }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (data: ProductExportPriceData) => { + return `Price ${data.region?.name} ${ + data.region?.currency_code + ? "[" + data.region?.currency_code.toUpperCase() + "]" + : "" + }` + }, + }, + }, + "Price Currency": { + name: "Price Currency", + importDescriptor: { + match: /Price [A-Z]{3}/, + reducer: ( + builtLine: TParsedProductImportRowData, + key, + value + ): TBuiltProductImportLine => { + builtLine["variant.prices"] = builtLine["variant.prices"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const currency = key.trim().split(" ")[1] + + ;( + builtLine["variant.prices"] as Record[] + ).push({ + amount: parseFloat(value), + currency_code: currency, + }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (data: ProductExportPriceData) => { + return `Price ${data.currency_code?.toUpperCase()}` + }, + }, + }, + // IMAGES + "Image Url": { + name: "Image Url", + importDescriptor: { + match: /Image \d+ Url/, + reducer: (builtLine: any, key, value): TBuiltProductImportLine => { + builtLine["product.images"] = builtLine["product.images"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + builtLine["product.images"].push(value) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (index: number) => { + return `Image ${index + 1} Url` + }, + }, + }, +} + +export const productSalesChannelColumnsDefinition: ProductColumnDefinition = { + "Sales Channel Name": { + name: "Sales Channel Name", + importDescriptor: { + match: /Sales Channel \d+ Name/, + reducer: (builtLine, key, value): TBuiltProductImportLine => { + builtLine["product.sales_channels"] = + builtLine["product.sales_channels"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const channels = builtLine["product.sales_channels"] as Record< + string, + string | number + >[] + + channels.push({ + name: value, + }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (index: number) => { + return `Sales Channel ${index + 1} Name` + }, + }, + }, + "Sales Channel Description": { + name: "Sales Channel Description", + importDescriptor: { + match: /Sales Channel \d+ Description/, + reducer: (builtLine, key, value): TBuiltProductImportLine => { + builtLine["product.sales_channels"] = + builtLine["product.sales_channels"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const channels = builtLine["product.sales_channels"] as Record< + string, + string | number + >[] + + channels.push({ + description: value, + }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (index: number) => { + return `Sales Channel ${index + 1} Description` + }, + }, + }, + "Sales Channel Id": { + name: "Sales Channel Id", + importDescriptor: { + match: /Sales Channel \d+ Id/, + reducer: (builtLine, key, value): TBuiltProductImportLine => { + builtLine["product.sales_channels"] = + builtLine["product.sales_channels"] || [] + + if (typeof value === "undefined" || value === null) { + return builtLine + } + + const channels = builtLine["product.sales_channels"] as Record< + string, + string | number + >[] + + channels.push({ + id: value, + }) + + return builtLine + }, + }, + exportDescriptor: { + isDynamic: true, + buildDynamicColumnName: (index: number) => { + return `Sales Channel ${index + 1} Id` + }, + }, + }, +} + +export const productImportColumnsDefinition: CsvSchema< + TParsedProductImportRowData, + TBuiltProductImportLine +> = { + columns: Object.entries(productColumnsDefinition) + .map(([name, def]) => { + return def.importDescriptor && { name, ...def.importDescriptor } + }) + .filter( + ( + v + ): v is CsvSchemaColumn< + TParsedProductImportRowData, + TBuiltProductImportLine + > => { + return !!v + } + ), +} + +export const productImportSalesChannelsColumnsDefinition: CsvSchema< + TParsedProductImportRowData, + TBuiltProductImportLine +> = { + columns: Object.entries(productSalesChannelColumnsDefinition) + .map(([name, def]) => { + return def.importDescriptor && { name, ...def.importDescriptor } + }) + .filter( + ( + v + ): v is CsvSchemaColumn< + TParsedProductImportRowData, + TBuiltProductImportLine + > => { + return !!v + } + ), +} diff --git a/packages/medusa/src/strategies/batch-jobs/product/types/index.ts b/packages/medusa/src/strategies/batch-jobs/product/types/index.ts new file mode 100644 index 0000000000000..9d2b1940ba267 --- /dev/null +++ b/packages/medusa/src/strategies/batch-jobs/product/types/index.ts @@ -0,0 +1,148 @@ +import { BatchJob, Product, ProductVariant } from "../../../../models" +import { Selector } from "../../../../types/common" +import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser" +import { + BatchJobService, + ProductService, + ProductVariantService, + RegionService, + SalesChannelService, + ShippingProfileService, +} from "../../../../services" +import { FileService } from "medusa-interfaces" +import { FlagRouter } from "../../../../utils/flag-router" +import { EntityManager } from "typeorm" +import { IFileService } from "../../../../interfaces" + +export type ProductExportInjectedDependencies = { + manager: EntityManager + batchJobService: BatchJobService + productService: ProductService + fileService: IFileService + featureFlagRouter: FlagRouter +} + +export type ProductExportBatchJobContext = { + retry_count?: number + max_retry?: number + offset?: number + limit?: number + batch_size?: number + order?: string + fields?: string + expand?: string + shape: { + prices: ProductExportPriceData[] + dynamicOptionColumnCount: number + dynamicImageColumnCount: number + dynamicSalesChannelsColumnCount: number + } + list_config?: { + select?: string[] + relations?: string[] + skip?: number + take?: number + order?: Record + } + filterable_fields?: Selector +} + +export type ProductExportBatchJob = BatchJob & { + context: ProductExportBatchJobContext +} + +export type ProductExportPriceData = { + currency_code?: string + region?: { name: string; currency_code: string; id: string } +} + +export type ProductExportColumnSchemaEntity = "product" | "variant" + +export type DynamicProductExportDescriptor = { + isDynamic: true + buildDynamicColumnName: (dataOrIndex: any) => string +} + +export type ProductExportDescriptor = + | { + accessor: (product: Product) => string + entityName: Extract + } + | { + accessor: (variant: ProductVariant) => string + entityName: Extract + } + +export type ProductImportInjectedProps = { + batchJobService: BatchJobService + productService: ProductService + productVariantService: ProductVariantService + shippingProfileService: ShippingProfileService + salesChannelService: SalesChannelService + regionService: RegionService + fileService: typeof FileService + + featureFlagRouter: FlagRouter + manager: EntityManager +} + +/** + * Import Batch job context column type. + */ +export type ProductImportJobContext = { + total: number + fileKey: string +} + +export type ProductImportBatchJob = BatchJob & { + result: Pick & { + operations: { + [K in keyof typeof OperationType]: number + } + } +} + +/** + * Schema definition of for an import CSV file. + */ +export type ProductImportCsvSchema = CsvSchema< + TParsedProductImportRowData, + TBuiltProductImportLine +> + +/** + * Supported batch job import ops. + */ +export enum OperationType { + ProductCreate = "PRODUCT_CREATE", + ProductUpdate = "PRODUCT_UPDATE", + VariantCreate = "VARIANT_CREATE", + VariantUpdate = "VARIANT_UPDATE", +} + +/** + * Data shape returned by the CSVParser. + */ +export type TParsedProductImportRowData = Record< + string, + string | number | object | undefined | (string | number | object)[] +> + +/** + * CSV parser's row reducer result data shape. + */ +export type TBuiltProductImportLine = Record + +export type ProductImportDescriptor = CsvSchemaColumn< + TParsedProductImportRowData, + TBuiltProductImportLine, + true +> + +export type ProductColumnDefinition = { + [key: string]: { + name: string + importDescriptor?: ProductImportDescriptor + exportDescriptor?: ProductExportDescriptor | DynamicProductExportDescriptor + } +}