feat(medusa): Align product import and export (#2471)

**What**

Create a data structure that facilitate the addition of new column descriptor for both export and import and ensure that the column name is shared between bother import and export to facilitate the import with an exported file. 

**Tests**

Add an additional integration tests that export a file, update the data, and re import the same file

FIXES CORE-716
FIXES CORE-713
This commit is contained in:
Adrien de Peretti
2022-10-20 16:48:34 +02:00
committed by GitHub
parent 13611e3e53
commit 299c4ae7f5
17 changed files with 1319 additions and 802 deletions

View File

@@ -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

View File

@@ -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],
}),
])
)
})
})

View File

@@ -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,

View File

@@ -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,,
1 Product 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 Product External Id Variant id Variant Id Variant Title Variant SKU Variant Barcode Variant Inventory Quantity Variant Allow backorder Variant Allow Backorder Variant Manage inventory 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
2 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 FALSE TRUE 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

View File

@@ -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
1 Product 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 Product External Id Variant id Variant Id Variant Title Variant SKU Variant Barcode Variant Inventory Quantity Variant Allow backorder Variant Allow Backorder Variant Manage inventory 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
2 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 FALSE TRUE TRUE 1.00 1.10 1.30 test-option-1 option 1 value red test-option-2 option 2 value 1 test-image.png
3 existing-product-id test-product-product-2 test-product-product-1-1 Test product 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. draft test-image.png Test collection Test collection 1 test-collection2 test-collection1 test-type 123 TRUE Test variant test-sku-2 test-sku-1-1 test-barcode-2 test-barcode-1-1 10 FALSE FALSE TRUE TRUE 1.00 1.10 1.30 1.10 Size test-option-1 Small option 1 value red test-option-2 option 2 value 1 test-image.png
4 existing-product-id 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-3 test-sku-2 test-barcode-3 test-barcode-2 10 FALSE FALSE TRUE TRUE 1.20 1.10 Size Medium Small test-image.png
5 existing-product-id 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 variant test-sku-4 test-sku-3 test-barcode-4 test-barcode-3 10 FALSE FALSE TRUE TRUE 1.20 Size Large Medium test-image.png
6 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

View File

@@ -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"

View File

@@ -40,21 +40,30 @@ export abstract class AbstractCsvValidator<TCsvLine, TBuiltLine>
): Promise<boolean | never>
}
export type CsvSchemaColumn<TCsvLine, TBuiltLine> = {
name: string
export type CsvSchemaColumn<
TCsvLine,
TBuiltLine,
NameAsOptional = false
> = (NameAsOptional extends false
? {
name: string
}
: {
name?: string
}) & {
required?: boolean
validator?: AbstractCsvValidator<TCsvLine, TBuiltLine>
} & (
| {
mapTo?: string
transform?: ColumnTransformer<TCsvLine>
}
| {
match?: RegExp
reducer?: ColumnReducer<TCsvLine, TBuiltLine>
transform?: ColumnTransformer<TCsvLine>
}
)
| {
mapTo?: string
transform?: ColumnTransformer<TCsvLine>
}
| {
match?: RegExp
reducer?: ColumnReducer<TCsvLine, TBuiltLine>
transform?: ColumnTransformer<TCsvLine>
}
)
export type ColumnTransformer<TCsvLine> = (
value: string,

View File

@@ -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/**",
],
})

View File

@@ -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
",
]
`;

View File

@@ -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.*/)
})

View File

@@ -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(

View File

@@ -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)

View File

@@ -17,19 +17,22 @@ import {
CreateProductVariantInput,
UpdateProductVariantInput,
} from "../../../types/product-variant"
import {
ImportJobContext,
InjectedProps,
OperationType,
ProductImportBatchJob,
ProductImportCsvSchema,
TBuiltProductImportLine,
TParsedProductImportRowData,
} from "./types"
import { BatchJob, Product, SalesChannel } from "../../../models"
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 {
OperationType,
ProductImportBatchJob,
ProductImportCsvSchema,
ProductImportInjectedProps,
ProductImportJobContext,
TParsedProductImportRowData,
} from "./types"
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<string, string | number>[]
).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<string, string | number>[]
).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
},
},
],
}

View File

@@ -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<string, "ASC" | "DESC">
}
filterable_fields?: Selector<unknown>
}
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<ProductExportColumnSchemaEntity, "product">
}
| {
accessor: (variant: ProductVariant) => string
entityName: Extract<ProductExportColumnSchemaEntity, "variant">
}
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",
},
],
])

View File

@@ -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<BatchJob, "result"> & {
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<string, any>
/**
* 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",
}

View File

@@ -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<string, string | number>[]
).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<string, string | number>[]
).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
}
),
}

View File

@@ -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<string, "ASC" | "DESC">
}
filterable_fields?: Selector<unknown>
}
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<ProductExportColumnSchemaEntity, "product">
}
| {
accessor: (variant: ProductVariant) => string
entityName: Extract<ProductExportColumnSchemaEntity, "variant">
}
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<BatchJob, "result"> & {
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<string, any>
export type ProductImportDescriptor = CsvSchemaColumn<
TParsedProductImportRowData,
TBuiltProductImportLine,
true
>
export type ProductColumnDefinition = {
[key: string]: {
name: string
importDescriptor?: ProductImportDescriptor
exportDescriptor?: ProductExportDescriptor | DynamicProductExportDescriptor
}
}