feat: Add basic support for importing products (#8266)
This commit is contained in:
@@ -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 "./delete-product-tags"
|
||||
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"
|
||||
import { WorkflowTypes } from "@medusajs/types"
|
||||
import { sendNotificationsStep } from "../../notification"
|
||||
import { groupProductsForBatchStep, parseProductCsvStep } from "../steps"
|
||||
import { batchProductsWorkflow } from "./batch-products"
|
||||
|
||||
export const importProductsWorkflowId = "import-products"
|
||||
export const importProductsWorkflow = createWorkflow(
|
||||
@@ -12,7 +14,12 @@ export const importProductsWorkflow = createWorkflow(
|
||||
(
|
||||
input: WorkflowData<WorkflowTypes.ProductWorkflow.ImportProductsDTO>
|
||||
): 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) => {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user