feat:Make product import v1 compatible (#8362)
This commit is contained in:
@@ -13,7 +13,9 @@ export const normalizeForImport = (
|
||||
variants: HttpTypes.AdminCreateProductVariant[]
|
||||
}
|
||||
>()
|
||||
const regionsMap = new Map(regions.map((r) => [r.id, r]))
|
||||
|
||||
// Currently region names are treated as case-insensitive.
|
||||
const regionsMap = new Map(regions.map((r) => [r.name.toLowerCase(), r]))
|
||||
|
||||
rawProducts.forEach((rawProduct) => {
|
||||
const productInMap = productMap.get(rawProduct["Product Handle"])
|
||||
@@ -144,18 +146,19 @@ const normalizeVariantForImport = (
|
||||
|
||||
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) {
|
||||
// Note: Region prices should always have the currency in brackets, eg. "variant_price_region_name_[EUR]"
|
||||
if (!priceKey.endsWith("]")) {
|
||||
response["prices"] = [
|
||||
...(response["prices"] || []),
|
||||
{ currency_code: priceKey.toLowerCase(), amount: normalizedValue },
|
||||
]
|
||||
} else {
|
||||
const region = regionsMap.get(priceKey)
|
||||
const regionName = priceKey.split("_").slice(0, -1).join(" ")
|
||||
const region = regionsMap.get(regionName)
|
||||
if (!region) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Region with ID ${priceKey} not found`
|
||||
`Region with name ${regionName} not found`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,7 +167,7 @@ const normalizeVariantForImport = (
|
||||
{
|
||||
amount: normalizedValue,
|
||||
currency_code: region.currency_code,
|
||||
rules: { region_id: priceKey },
|
||||
rules: { region_id: region.id },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -219,7 +222,7 @@ const normalizeVariantForImport = (
|
||||
|
||||
const getNormalizedValue = (key: string, value: any): any => {
|
||||
return stringFields.some((field) => key.startsWith(field))
|
||||
? value.toString()
|
||||
? value?.toString()
|
||||
: value
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ProductTypes, SalesChannelTypes } from "@medusajs/types"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
const basicFieldsToOmit = [
|
||||
// Fields with slightly different naming
|
||||
"Product MID Code",
|
||||
"Product HS Code",
|
||||
"Variant MID Code",
|
||||
"Variant HS Code",
|
||||
"Variant EAN",
|
||||
"Variant UPC",
|
||||
"Variant SKU",
|
||||
|
||||
// Fields no longer present in v2
|
||||
"Variant Inventory Quantity",
|
||||
"Product Profile Name",
|
||||
"Product Profile Type",
|
||||
|
||||
// Fields that are remapped
|
||||
"Product Collection Handle",
|
||||
"Product Collection Title",
|
||||
"Product Type",
|
||||
"Product Tags",
|
||||
]
|
||||
|
||||
// This is primarily to have backwards compatibility with v1 exports
|
||||
// Although it also makes v2 import template more dynamic
|
||||
// it's better to not expose eg. "Product MID Code" as an available public API so we can remove this code at some point.
|
||||
export const normalizeV1Products = (
|
||||
rawProducts: object[],
|
||||
supportingData: {
|
||||
productTypes: ProductTypes.ProductTypeDTO[]
|
||||
productCollections: ProductTypes.ProductCollectionDTO[]
|
||||
salesChannels: SalesChannelTypes.SalesChannelDTO[]
|
||||
}
|
||||
): object[] => {
|
||||
const productTypesMap = new Map(
|
||||
supportingData.productTypes.map((pt) => [pt.value, pt.id])
|
||||
)
|
||||
const productCollectionsMap = new Map(
|
||||
supportingData.productCollections.map((pc) => [pc.handle, pc.id])
|
||||
)
|
||||
const salesChannelsMap = new Map(
|
||||
supportingData.salesChannels.map((sc) => [sc.name, sc.id])
|
||||
)
|
||||
|
||||
return rawProducts.map((product) => {
|
||||
let finalRes = {
|
||||
...product,
|
||||
"Product Mid Code":
|
||||
product["Product MID Code"] ?? product["Product Mid Code"],
|
||||
"Product Hs Code":
|
||||
product["Product HS Code"] ?? product["Product Hs Code"],
|
||||
"Variant MID Code":
|
||||
product["Variant MID Code"] ?? product["Variant Mid Code"],
|
||||
"Variant Hs Code":
|
||||
product["Variant HS Code"] ?? product["Variant Hs Code"],
|
||||
"Variant Ean": product["Variant EAN"] ?? product["Variant Ean"],
|
||||
"Variant Upc": product["Variant UPC"] ?? product["Variant Upc"],
|
||||
"Variant Sku": product["Variant SKU"] ?? product["Variant Sku"],
|
||||
}
|
||||
|
||||
basicFieldsToOmit.forEach((field) => {
|
||||
delete finalRes[field]
|
||||
})
|
||||
|
||||
// You can either pass "Product Tags" or "Product Tag <IDX>", but not both
|
||||
const tags = product["Product Tags"]?.toString()?.split(",")
|
||||
if (tags) {
|
||||
finalRes = {
|
||||
...finalRes,
|
||||
...tags.reduce((agg, tag, i) => {
|
||||
agg[`Product Tag ${i + 1}`] = tag
|
||||
return agg
|
||||
}, {}),
|
||||
}
|
||||
}
|
||||
|
||||
const productTypeValue = product["Product Type"]
|
||||
if (productTypeValue) {
|
||||
if (!productTypesMap.has(productTypeValue)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Product type with value '${productTypeValue}' does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
finalRes["Product Type Id"] = productTypesMap.get(productTypeValue)
|
||||
}
|
||||
|
||||
const productCollectionHandle = product["Product Collection Handle"]
|
||||
if (productCollectionHandle) {
|
||||
if (!productCollectionsMap.has(productCollectionHandle)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Product collection with handle '${productCollectionHandle}' does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
finalRes["Product Collection Id"] = productCollectionsMap.get(
|
||||
productCollectionHandle
|
||||
)
|
||||
}
|
||||
|
||||
// We have to iterate over all fields for the ones that are index-based
|
||||
Object.entries(finalRes).forEach(([key, value]) => {
|
||||
if (key.startsWith("Price")) {
|
||||
delete finalRes[key]
|
||||
finalRes[`Variant ${key}`] = value
|
||||
}
|
||||
|
||||
if (key.startsWith("Option")) {
|
||||
delete finalRes[key]
|
||||
finalRes[`Variant ${key}`] = value
|
||||
}
|
||||
|
||||
if (key.startsWith("Image")) {
|
||||
delete finalRes[key]
|
||||
finalRes[`Product Image ${key.split(" ")[1]}`] = value
|
||||
}
|
||||
|
||||
if (key.startsWith("Sales Channel")) {
|
||||
delete finalRes[key]
|
||||
if (key.endsWith("Id")) {
|
||||
if (!salesChannelsMap.has(value)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Sales channel with name '${value}' does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
finalRes[`Product Sales Channel ${key.split(" ")[2]}`] =
|
||||
salesChannelsMap.get(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Product categories from v1 are not imported to v2
|
||||
})
|
||||
|
||||
return finalRes
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import { HttpTypes, IProductModuleService, ProductTypes } from "@medusajs/types"
|
||||
import { MedusaError, ModuleRegistrationName } from "@medusajs/utils"
|
||||
import { HttpTypes, IProductModuleService } from "@medusajs/types"
|
||||
import { 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 }) => {
|
||||
async (
|
||||
data: (HttpTypes.AdminCreateProduct & { id?: string })[],
|
||||
{ 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[],
|
||||
// We use the ID to do product updates
|
||||
id: data.map((product) => product.id).filter(Boolean) as string[],
|
||||
},
|
||||
{ take: null, select: ["handle"] }
|
||||
)
|
||||
const existingProductsMap = new Map(
|
||||
existingProducts.map((p) => [p.handle, true])
|
||||
)
|
||||
const existingProductsSet = new Set(existingProducts.map((p) => p.id))
|
||||
|
||||
const { toUpdate, toCreate } = data.reduce(
|
||||
(
|
||||
@@ -30,14 +31,7 @@ export const groupProductsForBatchStep = createStep(
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
if (product.id && existingProductsSet.has(product.id)) {
|
||||
acc.toUpdate.push(
|
||||
product as HttpTypes.AdminUpdateProduct & { id: string }
|
||||
)
|
||||
@@ -46,7 +40,7 @@ export const groupProductsForBatchStep = createStep(
|
||||
|
||||
// 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
|
||||
delete product.id
|
||||
acc.toCreate.push(product)
|
||||
return acc
|
||||
},
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
} from "@medusajs/utils"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
import { normalizeForImport } from "../helpers/normalize-for-import"
|
||||
import { IRegionModuleService } from "@medusajs/types"
|
||||
import {
|
||||
IProductModuleService,
|
||||
IRegionModuleService,
|
||||
ISalesChannelModuleService,
|
||||
} from "@medusajs/types"
|
||||
import { normalizeV1Products } from "../helpers/normalize-v1-import"
|
||||
|
||||
export const parseProductCsvStepId = "parse-product-csv"
|
||||
export const parseProductCsvStep = createStep(
|
||||
@@ -14,9 +19,31 @@ export const parseProductCsvStep = createStep(
|
||||
const regionService = container.resolve<IRegionModuleService>(
|
||||
ModuleRegistrationName.REGION
|
||||
)
|
||||
const productService = container.resolve<IProductModuleService>(
|
||||
ModuleRegistrationName.PRODUCT
|
||||
)
|
||||
const salesChannelService = container.resolve<ISalesChannelModuleService>(
|
||||
ModuleRegistrationName.SALES_CHANNEL
|
||||
)
|
||||
|
||||
const csvProducts = convertCsvToJson(fileContent)
|
||||
|
||||
csvProducts.forEach((product: any) => {
|
||||
const [productTypes, productCollections, salesChannels] = await Promise.all(
|
||||
[
|
||||
productService.listProductTypes({}, { take: null }),
|
||||
productService.listProductCollections({}, { take: null }),
|
||||
salesChannelService.listSalesChannels({}, { take: null }),
|
||||
]
|
||||
)
|
||||
|
||||
const v1Normalized = normalizeV1Products(csvProducts, {
|
||||
productTypes,
|
||||
productCollections,
|
||||
salesChannels,
|
||||
})
|
||||
|
||||
// We use the handle to group products and variants correctly.
|
||||
v1Normalized.forEach((product: any) => {
|
||||
if (!product["Product Handle"]) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
@@ -27,10 +54,10 @@ export const parseProductCsvStep = createStep(
|
||||
|
||||
const allRegions = await regionService.listRegions(
|
||||
{},
|
||||
{ select: ["id", "currency_code"], take: null }
|
||||
{ select: ["id", "name", "currency_code"], take: null }
|
||||
)
|
||||
|
||||
const normalizedData = normalizeForImport(csvProducts, allRegions)
|
||||
const normalizedData = normalizeForImport(v1Normalized, allRegions)
|
||||
return new StepResponse(normalizedData)
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user