feat:Make product import v1 compatible (#8362)

This commit is contained in:
Stevche Radevski
2024-07-31 14:03:05 +03:00
committed by GitHub
parent 864bb0df05
commit 8a6e172dec
11 changed files with 734 additions and 269 deletions

View File

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

View File

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

View File

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

View File

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