feat(medusa): handle product categories in import/export strategies (#3842)
**What** - add ProductCategories to import and export strategies - refactor ProductCategoriesService methods to use "retrieve_" pattern --- RESOLVES CORE-1275
This commit is contained in:
@@ -91,10 +91,49 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic retrieve for fining product categories by different attributes.
|
||||
*
|
||||
* @param config - the config of the product category to retrieve.
|
||||
* @param selector
|
||||
* @param treeSelector
|
||||
* @return the product category.
|
||||
*/
|
||||
protected async retrieve_(
|
||||
config: FindConfig<ProductCategory> = {},
|
||||
selector: Selector<ProductCategory> = {},
|
||||
treeSelector: QuerySelector<ProductCategory> = {}
|
||||
) {
|
||||
const productCategoryRepo = this.activeManager_.withRepository(
|
||||
this.productCategoryRepo_
|
||||
)
|
||||
|
||||
const query = buildQuery(selector, config)
|
||||
const productCategory = await productCategoryRepo.findOneWithDescendants(
|
||||
query,
|
||||
treeSelector
|
||||
)
|
||||
|
||||
if (!productCategory) {
|
||||
const selectorConstraints = Object.entries(selector)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(", ")
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`ProductCategory with ${selectorConstraints} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return productCategory
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a product category by id.
|
||||
* @param productCategoryId - the id of the product category to retrieve.
|
||||
* @param config - the config of the product category to retrieve.
|
||||
* @param selector
|
||||
* @param treeSelector
|
||||
* @return the product category.
|
||||
*/
|
||||
async retrieve(
|
||||
@@ -111,24 +150,33 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
}
|
||||
|
||||
const selectors = Object.assign({ id: productCategoryId }, selector)
|
||||
const query = buildQuery(selectors, config)
|
||||
const productCategoryRepo = this.activeManager_.withRepository(
|
||||
this.productCategoryRepo_
|
||||
)
|
||||
return this.retrieve_(config, selectors, treeSelector)
|
||||
}
|
||||
|
||||
const productCategory = await productCategoryRepo.findOneWithDescendants(
|
||||
query,
|
||||
treeSelector
|
||||
)
|
||||
|
||||
if (!productCategory) {
|
||||
/**
|
||||
* Retrieves a product category by handle.
|
||||
*
|
||||
* @param handle - the handle of the category
|
||||
* @param config - the config of the product category to retrieve.
|
||||
* @param selector
|
||||
* @param treeSelector
|
||||
* @return the product category.
|
||||
*/
|
||||
async retrieveByHandle(
|
||||
handle: string,
|
||||
config: FindConfig<ProductCategory> = {},
|
||||
selector: Selector<ProductCategory> = {},
|
||||
treeSelector: QuerySelector<ProductCategory> = {}
|
||||
) {
|
||||
if (!isDefined(handle)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`ProductCategory with id: ${productCategoryId} was not found`
|
||||
`"handle" must be defined`
|
||||
)
|
||||
}
|
||||
|
||||
return productCategory
|
||||
const selectors = Object.assign({ handle }, selector)
|
||||
return this.retrieve_(config, selectors, treeSelector)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,7 +68,7 @@ class SalesChannelService extends TransactionBaseService {
|
||||
|
||||
if (!salesChannel) {
|
||||
const selectorConstraints = Object.entries(selector)
|
||||
.map((key, value) => `${key}: ${value}`)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(", ")
|
||||
|
||||
throw new MedusaError(
|
||||
|
||||
@@ -20,9 +20,11 @@ import { FlagRouter } from "../../../utils/flag-router"
|
||||
import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels"
|
||||
import { csvCellContentFormatter } from "../../../utils"
|
||||
import {
|
||||
productCategoriesColumnsDefinition,
|
||||
productColumnsDefinition,
|
||||
productSalesChannelColumnsDefinition,
|
||||
} from "./types/columns-definition"
|
||||
import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories"
|
||||
|
||||
export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
public static identifier = "product-export-strategy"
|
||||
@@ -52,6 +54,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
...productSalesChannelColumnsDefinition,
|
||||
}
|
||||
|
||||
protected readonly productCategoriesColumnDefinitions = {
|
||||
...productCategoriesColumnsDefinition,
|
||||
}
|
||||
|
||||
private readonly NEWLINE_ = "\r\n"
|
||||
private readonly DELIMITER_ = ";"
|
||||
private readonly DEFAULT_LIMIT = 50
|
||||
@@ -80,6 +86,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
|
||||
this.defaultRelations_.push("sales_channels")
|
||||
}
|
||||
|
||||
if (featureFlagRouter.isFeatureEnabled(ProductCategoryFeatureFlag.key)) {
|
||||
this.defaultRelations_.push("categories")
|
||||
}
|
||||
}
|
||||
|
||||
async buildTemplate(): Promise<string> {
|
||||
@@ -147,6 +157,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
let dynamicOptionColumnCount = 0
|
||||
let dynamicImageColumnCount = 0
|
||||
let dynamicSalesChannelsColumnCount = 0
|
||||
let dynamicProductCategoriesColumnCount = 0
|
||||
let pricesData = new Set<string>()
|
||||
|
||||
while (offset < productCount) {
|
||||
@@ -173,6 +184,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
shapeData.salesChannelsColumnCount,
|
||||
dynamicSalesChannelsColumnCount
|
||||
)
|
||||
dynamicProductCategoriesColumnCount = Math.max(
|
||||
shapeData.productCategoriesColumnCount,
|
||||
dynamicProductCategoriesColumnCount
|
||||
)
|
||||
pricesData = new Set([...pricesData, ...shapeData.pricesData])
|
||||
|
||||
offset += products.length
|
||||
@@ -187,6 +202,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
dynamicImageColumnCount,
|
||||
dynamicOptionColumnCount,
|
||||
dynamicSalesChannelsColumnCount,
|
||||
dynamicProductCategoriesColumnCount,
|
||||
prices: [...pricesData].map((stringifyData) =>
|
||||
JSON.parse(stringifyData)
|
||||
),
|
||||
@@ -317,12 +333,14 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
dynamicImageColumnCount,
|
||||
dynamicOptionColumnCount,
|
||||
dynamicSalesChannelsColumnCount,
|
||||
dynamicProductCategoriesColumnCount,
|
||||
} = batchJob?.context?.shape ?? {}
|
||||
|
||||
this.appendMoneyAmountDescriptors(prices)
|
||||
this.appendOptionsDescriptors(dynamicOptionColumnCount)
|
||||
this.appendImagesDescriptors(dynamicImageColumnCount)
|
||||
this.appendSalesChannelsDescriptors(dynamicSalesChannelsColumnCount)
|
||||
this.appendProductCategoriesDescriptors(dynamicProductCategoriesColumnCount)
|
||||
|
||||
const exportedColumns = Object.values(this.columnsDefinition)
|
||||
.map(
|
||||
@@ -406,6 +424,56 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
private appendProductCategoriesDescriptors(maxCategoriesCount): void {
|
||||
const columnNameHandleBuilder = (this.productCategoriesColumnDefinitions[
|
||||
"Product Category Handle"
|
||||
]!.exportDescriptor as DynamicProductExportDescriptor)!
|
||||
.buildDynamicColumnName
|
||||
|
||||
const columnNameNameBuilder = (this.productCategoriesColumnDefinitions[
|
||||
"Product Category Name"
|
||||
]!.exportDescriptor as DynamicProductExportDescriptor)!
|
||||
.buildDynamicColumnName
|
||||
|
||||
const columnNameDescriptionBuilder = (this
|
||||
.productCategoriesColumnDefinitions["Product Category Description"]!
|
||||
.exportDescriptor as DynamicProductExportDescriptor)!
|
||||
.buildDynamicColumnName
|
||||
|
||||
for (let i = 0; i < maxCategoriesCount; ++i) {
|
||||
let columnNameId = columnNameHandleBuilder(i)
|
||||
|
||||
this.columnsDefinition[columnNameId] = {
|
||||
name: columnNameId,
|
||||
exportDescriptor: {
|
||||
accessor: (product: Product) => product?.categories[i]?.handle ?? "",
|
||||
entityName: "product",
|
||||
},
|
||||
}
|
||||
|
||||
columnNameId = columnNameNameBuilder(i)
|
||||
|
||||
this.columnsDefinition[columnNameId] = {
|
||||
name: columnNameId,
|
||||
exportDescriptor: {
|
||||
accessor: (product: Product) => product?.categories[i]?.name ?? "",
|
||||
entityName: "product",
|
||||
},
|
||||
}
|
||||
|
||||
columnNameId = columnNameDescriptionBuilder(i)
|
||||
|
||||
this.columnsDefinition[columnNameId] = {
|
||||
name: columnNameId,
|
||||
exportDescriptor: {
|
||||
accessor: (product: Product) =>
|
||||
product?.categories[i]?.description ?? "",
|
||||
entityName: "product",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private appendOptionsDescriptors(maxOptionsCount: number): void {
|
||||
for (let i = 0; i < maxOptionsCount; ++i) {
|
||||
const columnNameNameBuilder = (this.columnsDefinition["Option Name"]!
|
||||
@@ -575,11 +643,13 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
optionColumnCount: number
|
||||
imageColumnCount: number
|
||||
salesChannelsColumnCount: number
|
||||
productCategoriesColumnCount: number
|
||||
pricesData: Set<string>
|
||||
} {
|
||||
let optionColumnCount = 0
|
||||
let imageColumnCount = 0
|
||||
let salesChannelsColumnCount = 0
|
||||
let productCategoriesColumnCount = 0
|
||||
const pricesData = new Set<string>()
|
||||
|
||||
// Retrieve the highest count of each object to build the dynamic columns later
|
||||
@@ -600,6 +670,16 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
this.featureFlagRouter_.isFeatureEnabled(ProductCategoryFeatureFlag.key)
|
||||
) {
|
||||
const categoriesCount = product?.categories?.length ?? 0
|
||||
productCategoriesColumnCount = Math.max(
|
||||
productCategoriesColumnCount,
|
||||
categoriesCount
|
||||
)
|
||||
}
|
||||
|
||||
for (const variant of product?.variants ?? []) {
|
||||
if (variant.prices?.length) {
|
||||
variant.prices.forEach((price) => {
|
||||
@@ -624,6 +704,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
|
||||
optionColumnCount,
|
||||
imageColumnCount,
|
||||
salesChannelsColumnCount,
|
||||
productCategoriesColumnCount,
|
||||
pricesData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channe
|
||||
import { BatchJob, SalesChannel } from "../../../models"
|
||||
import {
|
||||
BatchJobService,
|
||||
ProductCategoryService,
|
||||
ProductCollectionService,
|
||||
ProductService,
|
||||
ProductVariantService,
|
||||
@@ -29,8 +30,10 @@ import {
|
||||
import {
|
||||
productImportColumnsDefinition,
|
||||
productImportSalesChannelsColumnsDefinition,
|
||||
productImportProductCategoriesColumnsDefinition,
|
||||
} from "./types/columns-definition"
|
||||
import { transformProductData, transformVariantData } from "./utils"
|
||||
import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories"
|
||||
|
||||
/**
|
||||
* Process this many variant rows before reporting progress.
|
||||
@@ -61,6 +64,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
protected readonly salesChannelService_: SalesChannelService
|
||||
protected readonly productVariantService_: ProductVariantService
|
||||
protected readonly shippingProfileService_: ShippingProfileService
|
||||
protected readonly productCategoryService_: ProductCategoryService
|
||||
|
||||
protected readonly csvParser_: CsvParser<
|
||||
ProductImportCsvSchema,
|
||||
@@ -77,6 +81,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
regionService,
|
||||
fileService,
|
||||
productCollectionService,
|
||||
productCategoryService,
|
||||
manager,
|
||||
featureFlagRouter,
|
||||
}: ProductImportInjectedProps) {
|
||||
@@ -87,12 +92,19 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
SalesChannelFeatureFlag.key
|
||||
)
|
||||
|
||||
const isProductCategoriesFeatureOn = featureFlagRouter.isFeatureEnabled(
|
||||
ProductCategoryFeatureFlag.key
|
||||
)
|
||||
|
||||
this.csvParser_ = new CsvParser({
|
||||
columns: [
|
||||
...productImportColumnsDefinition.columns,
|
||||
...(isSalesChannelsFeatureOn
|
||||
? productImportSalesChannelsColumnsDefinition.columns
|
||||
: []),
|
||||
...(isProductCategoriesFeatureOn
|
||||
? productImportProductCategoriesColumnsDefinition.columns
|
||||
: []),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -107,6 +119,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
this.shippingProfileService_ = shippingProfileService
|
||||
this.regionService_ = regionService
|
||||
this.productCollectionService_ = productCollectionService
|
||||
this.productCategoryService_ = productCategoryService
|
||||
}
|
||||
|
||||
async buildTemplate(): Promise<string> {
|
||||
@@ -367,6 +380,33 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
return salesChannels
|
||||
}
|
||||
|
||||
/**
|
||||
* Method retrieves product categories from handles provided in the CSV.
|
||||
*
|
||||
* @param data array of product category handles
|
||||
*/
|
||||
private async processCategories(
|
||||
data: { handle: string }[]
|
||||
): Promise<{ id: string }[]> {
|
||||
const retIds: { id: string }[] = []
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
const productCategoryService =
|
||||
this.productCategoryService_.withTransaction(transactionManager)
|
||||
|
||||
for (const category of data) {
|
||||
const categoryPartial = (await productCategoryService.retrieveByHandle(
|
||||
category.handle,
|
||||
{
|
||||
select: ["id"],
|
||||
}
|
||||
)) as { id: string }
|
||||
|
||||
retIds.push(categoryPartial)
|
||||
}
|
||||
|
||||
return retIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Method creates products using `ProductService` and parsed data from a CSV row.
|
||||
*
|
||||
@@ -393,6 +433,9 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
SalesChannelFeatureFlag.key
|
||||
)
|
||||
|
||||
const isProductCategoriesFeatureOn =
|
||||
this.featureFlagRouter_.isFeatureEnabled(ProductCategoryFeatureFlag.key)
|
||||
|
||||
for (const productOp of productOps) {
|
||||
const productData = transformProductData(productOp)
|
||||
|
||||
@@ -419,6 +462,12 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
delete productData.collection
|
||||
}
|
||||
|
||||
if (isProductCategoriesFeatureOn && productOp["product.categories"]) {
|
||||
productData["categories"] = await this.processCategories(
|
||||
productOp["product.categories"] as { handle: string }[]
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData
|
||||
await productServiceTx.create(
|
||||
productData as unknown as CreateProductInput
|
||||
@@ -456,6 +505,9 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
SalesChannelFeatureFlag.key
|
||||
)
|
||||
|
||||
const isProductCategoriesFeatureOn =
|
||||
this.featureFlagRouter_.isFeatureEnabled(ProductCategoryFeatureFlag.key)
|
||||
|
||||
for (const productOp of productOps) {
|
||||
const productData = transformProductData(productOp)
|
||||
try {
|
||||
@@ -483,6 +535,12 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
delete productData.collection
|
||||
}
|
||||
|
||||
if (isProductCategoriesFeatureOn && productOp["product.categories"]) {
|
||||
productData["categories"] = await this.processCategories(
|
||||
productOp["product.categories"] as { handle: string }[]
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: we should only pass the expected data. Here we are passing everything contained in productData
|
||||
await productServiceTx.update(
|
||||
productOp["product.id"] as string,
|
||||
|
||||
@@ -714,6 +714,65 @@ export const productSalesChannelColumnsDefinition: ProductColumnDefinition = {
|
||||
},
|
||||
}
|
||||
|
||||
export const productCategoriesColumnsDefinition: ProductColumnDefinition = {
|
||||
"Product Category Handle": {
|
||||
name: "Product Category Handle",
|
||||
importDescriptor: {
|
||||
match: /Product Category \d+ Handle/,
|
||||
reducer: (builtLine, key, value): TBuiltProductImportLine => {
|
||||
builtLine["product.categories"] = builtLine["product.categories"] || []
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return builtLine
|
||||
}
|
||||
|
||||
const categories = builtLine["product.categories"] as Record<
|
||||
string,
|
||||
string | number
|
||||
>[]
|
||||
|
||||
categories.push({
|
||||
handle: value,
|
||||
})
|
||||
|
||||
return builtLine
|
||||
},
|
||||
},
|
||||
exportDescriptor: {
|
||||
isDynamic: true,
|
||||
buildDynamicColumnName: (index: number) => {
|
||||
return `Product Category ${index + 1} Handle`
|
||||
},
|
||||
},
|
||||
},
|
||||
"Product Category Name": {
|
||||
name: "Product Category Name",
|
||||
importDescriptor: {
|
||||
match: /Product Category \d+ Name/,
|
||||
reducer: (builtLine) => builtLine,
|
||||
},
|
||||
exportDescriptor: {
|
||||
isDynamic: true,
|
||||
buildDynamicColumnName: (index: number) => {
|
||||
return `Product Category ${index + 1} Name`
|
||||
},
|
||||
},
|
||||
},
|
||||
"Product Category Description": {
|
||||
name: "Product Category Description",
|
||||
importDescriptor: {
|
||||
match: /Product Category \d+ Description/,
|
||||
reducer: (builtLine) => builtLine,
|
||||
},
|
||||
exportDescriptor: {
|
||||
isDynamic: true,
|
||||
buildDynamicColumnName: (index: number) => {
|
||||
return `Product Category ${index + 1} Description`
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const productImportColumnsDefinition: CsvSchema<
|
||||
TParsedProductImportRowData,
|
||||
TBuiltProductImportLine
|
||||
@@ -753,3 +812,23 @@ export const productImportSalesChannelsColumnsDefinition: CsvSchema<
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
export const productImportProductCategoriesColumnsDefinition: CsvSchema<
|
||||
TParsedProductImportRowData,
|
||||
TBuiltProductImportLine
|
||||
> = {
|
||||
columns: Object.entries(productCategoriesColumnsDefinition)
|
||||
.map(([name, def]) => {
|
||||
return def.importDescriptor && { name, ...def.importDescriptor }
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
v
|
||||
): v is CsvSchemaColumn<
|
||||
TParsedProductImportRowData,
|
||||
TBuiltProductImportLine
|
||||
> => {
|
||||
return !!v
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Selector } from "../../../../types/common"
|
||||
import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser"
|
||||
import {
|
||||
BatchJobService,
|
||||
ProductCategoryService,
|
||||
ProductCollectionService,
|
||||
ProductService,
|
||||
ProductVariantService,
|
||||
@@ -37,6 +38,7 @@ export type ProductExportBatchJobContext = {
|
||||
dynamicOptionColumnCount: number
|
||||
dynamicImageColumnCount: number
|
||||
dynamicSalesChannelsColumnCount: number
|
||||
dynamicProductCategoriesColumnCount: number
|
||||
}
|
||||
list_config?: {
|
||||
select?: string[]
|
||||
@@ -82,6 +84,7 @@ export type ProductImportInjectedProps = {
|
||||
salesChannelService: SalesChannelService
|
||||
regionService: RegionService
|
||||
productCollectionService: ProductCollectionService
|
||||
productCategoryService: ProductCategoryService
|
||||
fileService: typeof FileService
|
||||
|
||||
featureFlagRouter: FlagRouter
|
||||
|
||||
Reference in New Issue
Block a user