feat: Add basic support for importing products (#8266)
This commit is contained in:
1 parent
a26b7cf253
commit
444a244eab
10 files changed
+520
-115
No files matched your search
@@ -234,118 +234,6 @@ medusaIntegrationTestRunner({
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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],
|
|
||||||
// }),
|
|
||||||
// ])
|
|
||||||
// )
|
|
||||||
// })
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -60,7 +60,6 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
getProductFixture({
|
getProductFixture({
|
||||||
title: "Base product",
|
title: "Base product",
|
||||||
type_id: baseType.id,
|
|
||||||
}),
|
}),
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
@@ -72,7 +71,7 @@ medusaIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /admin/products/export", () => {
|
describe("POST /admin/products/export", () => {
|
||||||
it("should import a products CSV file", async () => {
|
it("should import a previously exported products CSV file", async () => {
|
||||||
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
||||||
"notification.notification.created",
|
"notification.notification.created",
|
||||||
eventBus
|
eventBus
|
||||||
@@ -83,6 +82,17 @@ medusaIntegrationTestRunner({
|
|||||||
{ encoding: "utf-8" }
|
{ encoding: "utf-8" }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fileContent = fileContent.replace(
|
||||||
|
/prod_01J3CRPNVGRZ01A8GH8FQYK10Z/g,
|
||||||
|
baseProduct.id
|
||||||
|
)
|
||||||
|
fileContent = fileContent.replace(
|
||||||
|
/variant_01J3CRPNW5J6EBVVQP1TN33A58/g,
|
||||||
|
baseProduct.variants[0].id
|
||||||
|
)
|
||||||
|
fileContent = fileContent.replace(/pcol_\w*\d*/g, baseCollection.id)
|
||||||
|
fileContent = fileContent.replace(/ptyp_\w*\d*/g, baseType.id)
|
||||||
|
|
||||||
const { form, meta } = getUploadReq({
|
const { form, meta } = getUploadReq({
|
||||||
name: "test.csv",
|
name: "test.csv",
|
||||||
content: fileContent,
|
content: fileContent,
|
||||||
@@ -108,7 +118,189 @@ medusaIntegrationTestRunner({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const dbProducts = (await api.get("/admin/products", adminHeaders)).data
|
||||||
|
.products
|
||||||
|
|
||||||
|
expect(dbProducts).toHaveLength(2)
|
||||||
|
expect(dbProducts).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: baseProduct.id,
|
||||||
|
handle: "base-product",
|
||||||
|
is_giftcard: false,
|
||||||
|
thumbnail: "test-image.png",
|
||||||
|
status: "draft",
|
||||||
|
description: "test-product-description\ntest line 2",
|
||||||
|
options: [
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "size",
|
||||||
|
values: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "small",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "large",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "color",
|
||||||
|
values: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "green",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
images: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "test-image.png",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "test-image-2.png",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
tags: [
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "123",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "456",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
type: expect.objectContaining({
|
||||||
|
id: baseType.id,
|
||||||
|
}),
|
||||||
|
collection: expect.objectContaining({
|
||||||
|
id: baseCollection.id,
|
||||||
|
}),
|
||||||
|
variants: [
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Test variant",
|
||||||
|
allow_backorder: false,
|
||||||
|
manage_inventory: true,
|
||||||
|
prices: [
|
||||||
|
expect.objectContaining({
|
||||||
|
currency_code: "usd",
|
||||||
|
amount: 100,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
currency_code: "eur",
|
||||||
|
amount: 45,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
currency_code: "dkk",
|
||||||
|
amount: 30,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "large",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "green",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Test variant 2",
|
||||||
|
allow_backorder: false,
|
||||||
|
manage_inventory: true,
|
||||||
|
// TODO: Since we are doing a product update, there won't be any prices created for the variant
|
||||||
|
options: [
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "small",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "green",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
created_at: expect.any(String),
|
||||||
|
updated_at: expect.any(String),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(String),
|
||||||
|
handle: "proposed-product",
|
||||||
|
is_giftcard: false,
|
||||||
|
thumbnail: "test-image.png",
|
||||||
|
status: "proposed",
|
||||||
|
description: "test-product-description",
|
||||||
|
options: [
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "size",
|
||||||
|
values: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "large",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "color",
|
||||||
|
values: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "green",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
images: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "test-image.png",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "test-image-2.png",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
tags: [
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "new-tag",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
type: expect.objectContaining({
|
||||||
|
id: baseType.id,
|
||||||
|
}),
|
||||||
|
collection: null,
|
||||||
|
variants: [
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Test variant",
|
||||||
|
allow_backorder: false,
|
||||||
|
manage_inventory: true,
|
||||||
|
prices: [
|
||||||
|
expect.objectContaining({
|
||||||
|
currency_code: "usd",
|
||||||
|
amount: 100,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
currency_code: "eur",
|
||||||
|
amount: 45,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
currency_code: "dkk",
|
||||||
|
amount: 30,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "large",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "green",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
created_at: expect.any(String),
|
||||||
|
updated_at: expect.any(String),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should fail on invalid prices being present in the CSV", async () => {})
|
||||||
|
it("should fail on non-existent fields being present in the CSV", async () => {})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { MedusaError, lowerCaseFirst } from "@medusajs/utils"
|
||||||
|
|
||||||
|
// We want to convert the csv data format to a standard DTO format.
|
||||||
|
export const normalizeForImport = (
|
||||||
|
rawProducts: object[]
|
||||||
|
): HttpTypes.AdminCreateProduct[] => {
|
||||||
|
const productMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
product: HttpTypes.AdminCreateProduct
|
||||||
|
variants: HttpTypes.AdminCreateProductVariant[]
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
rawProducts.forEach((rawProduct) => {
|
||||||
|
const productInMap = productMap.get(rawProduct["Product Handle"])
|
||||||
|
if (!productInMap) {
|
||||||
|
productMap.set(rawProduct["Product Handle"], {
|
||||||
|
product: normalizeProductForImport(rawProduct),
|
||||||
|
variants: [normalizeVariantForImport(rawProduct)],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
productMap.set(rawProduct["Product Handle"], {
|
||||||
|
product: productInMap.product,
|
||||||
|
variants: [
|
||||||
|
...productInMap.variants,
|
||||||
|
normalizeVariantForImport(rawProduct),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(productMap.values()).map((p) => {
|
||||||
|
const options = p.variants.reduce(
|
||||||
|
(agg: Record<string, Set<string>>, variant) => {
|
||||||
|
Object.entries(variant.options ?? {}).forEach(([key, value]) => {
|
||||||
|
if (!agg[key]) {
|
||||||
|
agg[key] = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
agg[key].add(value as string)
|
||||||
|
})
|
||||||
|
|
||||||
|
return agg
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p.product,
|
||||||
|
options: Object.entries(options).map(([key, value]) => ({
|
||||||
|
title: key,
|
||||||
|
values: Array.from(value),
|
||||||
|
})),
|
||||||
|
variants: p.variants,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const productFieldsToOmit = new Map()
|
||||||
|
const variantFieldsToOmit = new Map([["variant_product_id", true]])
|
||||||
|
|
||||||
|
// We use an array here as we do a substring matching as a check.
|
||||||
|
// These fields can have a numeric value, but they are stored as string in the DB so we need to normalize them
|
||||||
|
const stringFields = [
|
||||||
|
"product_tag_",
|
||||||
|
"variant_barcode",
|
||||||
|
"variant_sku",
|
||||||
|
"variant_ean",
|
||||||
|
"variant_upc",
|
||||||
|
"variant_hs_code",
|
||||||
|
"variant_mid_code",
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalizeProductForImport = (
|
||||||
|
rawProduct: object
|
||||||
|
): HttpTypes.AdminCreateProduct => {
|
||||||
|
const response = {}
|
||||||
|
|
||||||
|
Object.entries(rawProduct).forEach(([key, value]) => {
|
||||||
|
const normalizedKey = snakecaseKey(key)
|
||||||
|
const normalizedValue = getNormalizedValue(normalizedKey, value)
|
||||||
|
|
||||||
|
// We have no way of telling if a field is set as an empty string or it was undefined, so we completely omit empty fields.
|
||||||
|
if (normalizedValue === "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.startsWith("product_image_")) {
|
||||||
|
response["images"] = [
|
||||||
|
...(response["images"] || []),
|
||||||
|
{ url: normalizedValue },
|
||||||
|
]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.startsWith("product_tag_")) {
|
||||||
|
response["tags"] = [
|
||||||
|
...(response["tags"] || []),
|
||||||
|
{ value: normalizedValue },
|
||||||
|
]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.startsWith("product_sales_channel_")) {
|
||||||
|
response["sales_channels"] = [
|
||||||
|
...(response["sales_channels"] || []),
|
||||||
|
{ id: normalizedValue },
|
||||||
|
]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedKey.startsWith("product_") &&
|
||||||
|
!productFieldsToOmit.has(normalizedKey)
|
||||||
|
) {
|
||||||
|
response[normalizedKey.replace("product_", "")] = normalizedValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return response as HttpTypes.AdminCreateProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeVariantForImport = (
|
||||||
|
rawProduct: object
|
||||||
|
): HttpTypes.AdminCreateProductVariant => {
|
||||||
|
const response = {}
|
||||||
|
const options = new Map<number, { name?: string; value?: string }>()
|
||||||
|
|
||||||
|
Object.entries(rawProduct).forEach(([key, value]) => {
|
||||||
|
const normalizedKey = snakecaseKey(key)
|
||||||
|
const normalizedValue = getNormalizedValue(normalizedKey, value)
|
||||||
|
|
||||||
|
// We have no way of telling if a field is set as an empty string or it was undefined, so we completely omit empty fields.
|
||||||
|
if (normalizedValue === "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.startsWith("variant_price_")) {
|
||||||
|
const priceKey = normalizedKey.replace("variant_price_", "")
|
||||||
|
// Note: If we start using the region name instead of ID, this check might not always work.
|
||||||
|
if (priceKey.length === 3) {
|
||||||
|
response["prices"] = [
|
||||||
|
...(response["prices"] || []),
|
||||||
|
{ currency_code: priceKey.toLowerCase(), amount: normalizedValue },
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
response["prices"] = [
|
||||||
|
...(response["prices"] || []),
|
||||||
|
{
|
||||||
|
amount: normalizedValue,
|
||||||
|
rules: { region_id: priceKey },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.startsWith("variant_option_")) {
|
||||||
|
const keyBase = normalizedKey.replace("variant_option_", "")
|
||||||
|
const optionIndex = parseInt(keyBase.split("_")[0])
|
||||||
|
const optionType = keyBase.split("_")[1]
|
||||||
|
|
||||||
|
options.set(optionIndex, {
|
||||||
|
...options.get(optionIndex),
|
||||||
|
[optionType]: normalizedValue,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedKey.startsWith("variant_") &&
|
||||||
|
!variantFieldsToOmit.has(normalizedKey)
|
||||||
|
) {
|
||||||
|
response[normalizedKey.replace("variant_", "")] = normalizedValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response["options"] = Array.from(options.values()).reduce(
|
||||||
|
(agg: Record<string, string>, option) => {
|
||||||
|
if (!option.name) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Missing option name for product with handle ${rawProduct["Product Handle"]}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!option.value) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Missing option value for product with handle ${rawProduct["Product Handle"]} and option ${option.name}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
agg[option.name] = option.value
|
||||||
|
return agg
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response as HttpTypes.AdminCreateProductVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNormalizedValue = (key: string, value: any): any => {
|
||||||
|
return stringFields.some((field) => key.startsWith(field))
|
||||||
|
? value.toString()
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
|
||||||
|
const snakecaseKey = (key: string): string => {
|
||||||
|
return key.split(" ").map(lowerCaseFirst).join("_")
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { HttpTypes, IProductModuleService, ProductTypes } from "@medusajs/types"
|
||||||
|
import { MedusaError, ModuleRegistrationName } from "@medusajs/utils"
|
||||||
|
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
export const groupProductsForBatchStepId = "group-products-for-batch"
|
||||||
|
export const groupProductsForBatchStep = createStep(
|
||||||
|
groupProductsForBatchStepId,
|
||||||
|
async (data: HttpTypes.AdminCreateProduct[], { container }) => {
|
||||||
|
const service = container.resolve<IProductModuleService>(
|
||||||
|
ModuleRegistrationName.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingProducts = await service.listProducts(
|
||||||
|
{
|
||||||
|
// We already validate that there is handle in a previous step
|
||||||
|
handle: data.map((product) => product.handle) as string[],
|
||||||
|
},
|
||||||
|
{ take: null, select: ["handle"] }
|
||||||
|
)
|
||||||
|
const existingProductsMap = new Map(
|
||||||
|
existingProducts.map((p) => [p.handle, true])
|
||||||
|
)
|
||||||
|
|
||||||
|
const { toUpdate, toCreate } = data.reduce(
|
||||||
|
(
|
||||||
|
acc: {
|
||||||
|
toUpdate: (HttpTypes.AdminUpdateProduct & { id: string })[]
|
||||||
|
toCreate: HttpTypes.AdminCreateProduct[]
|
||||||
|
},
|
||||||
|
product
|
||||||
|
) => {
|
||||||
|
// There are few data normalizations to do if we are dealing with an update.
|
||||||
|
if (existingProductsMap.has(product.handle!)) {
|
||||||
|
if (!(product as any).id) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
"Product id is required when updating products in import"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Currently the update product workflow doesn't update variant pricing, but we should probably add support for it.
|
||||||
|
product.variants?.forEach((variant: any) => {
|
||||||
|
delete variant.prices
|
||||||
|
})
|
||||||
|
|
||||||
|
acc.toUpdate.push(
|
||||||
|
product as HttpTypes.AdminUpdateProduct & { id: string }
|
||||||
|
)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New products will be created with a new ID, even if there is one present in the CSV.
|
||||||
|
// To add support for creating with predefined IDs we will need to do changes to the upsert method.
|
||||||
|
delete (product as any).id
|
||||||
|
acc.toCreate.push(product)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ toUpdate: [], toCreate: [] }
|
||||||
|
)
|
||||||
|
|
||||||
|
return new StepResponse({ create: toCreate, update: toUpdate })
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -21,3 +21,5 @@ export * from "./create-product-tags"
|
|||||||
export * from "./update-product-tags"
|
export * from "./update-product-tags"
|
||||||
export * from "./delete-product-tags"
|
export * from "./delete-product-tags"
|
||||||
export * from "./generate-product-csv"
|
export * from "./generate-product-csv"
|
||||||
|
export * from "./parse-product-csv"
|
||||||
|
export * from "./group-products-for-batch"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MedusaError, convertCsvToJson } from "@medusajs/utils"
|
||||||
|
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||||
|
import { normalizeForImport } from "../helpers/normalize-for-import"
|
||||||
|
|
||||||
|
export const parseProductCsvStepId = "parse-product-csv"
|
||||||
|
export const parseProductCsvStep = createStep(
|
||||||
|
parseProductCsvStepId,
|
||||||
|
async (fileContent: string) => {
|
||||||
|
const csvProducts = convertCsvToJson(fileContent)
|
||||||
|
|
||||||
|
csvProducts.forEach((product: any) => {
|
||||||
|
if (!product["Product Handle"]) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
"Product handle is required when importing products"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedData = normalizeForImport(csvProducts)
|
||||||
|
return new StepResponse(normalizedData)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
} from "@medusajs/workflows-sdk"
|
} from "@medusajs/workflows-sdk"
|
||||||
import { WorkflowTypes } from "@medusajs/types"
|
import { WorkflowTypes } from "@medusajs/types"
|
||||||
import { sendNotificationsStep } from "../../notification"
|
import { sendNotificationsStep } from "../../notification"
|
||||||
|
import { groupProductsForBatchStep, parseProductCsvStep } from "../steps"
|
||||||
|
import { batchProductsWorkflow } from "./batch-products"
|
||||||
|
|
||||||
export const importProductsWorkflowId = "import-products"
|
export const importProductsWorkflowId = "import-products"
|
||||||
export const importProductsWorkflow = createWorkflow(
|
export const importProductsWorkflow = createWorkflow(
|
||||||
@@ -12,7 +14,12 @@ export const importProductsWorkflow = createWorkflow(
|
|||||||
(
|
(
|
||||||
input: WorkflowData<WorkflowTypes.ProductWorkflow.ImportProductsDTO>
|
input: WorkflowData<WorkflowTypes.ProductWorkflow.ImportProductsDTO>
|
||||||
): WorkflowData<void> => {
|
): WorkflowData<void> => {
|
||||||
// validateImportCsvStep(input.fileContent)
|
const products = parseProductCsvStep(input.fileContent)
|
||||||
|
const batchRequest = groupProductsForBatchStep(products)
|
||||||
|
|
||||||
|
// TODO: Add async confirmation step here
|
||||||
|
|
||||||
|
batchProductsWorkflow.runAsStep({ input: batchRequest })
|
||||||
|
|
||||||
const notifications = transform({ input }, (data) => {
|
const notifications = transform({ input }, (data) => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { csv2json } from "json-2-csv"
|
||||||
|
|
||||||
|
export interface ConvertCsvToJsonOptions<T> {}
|
||||||
|
|
||||||
|
export const convertCsvToJson = <T extends object>(
|
||||||
|
data: string,
|
||||||
|
options?: ConvertCsvToJsonOptions<T>
|
||||||
|
): T[] => {
|
||||||
|
return csv2json(data, {
|
||||||
|
preventCsvInjection: true,
|
||||||
|
}) as T[]
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./jsontocsv"
|
export * from "./jsontocsv"
|
||||||
|
export * from "./csvtojson"
|
||||||
@@ -12,6 +12,7 @@ export const convertJsonToCsv = <T extends object>(
|
|||||||
expandNestedObjects: true,
|
expandNestedObjects: true,
|
||||||
expandArrayObjects: true,
|
expandArrayObjects: true,
|
||||||
unwindArrays: false,
|
unwindArrays: false,
|
||||||
|
preventCsvInjection: true,
|
||||||
emptyFieldValue: "",
|
emptyFieldValue: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user