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:
Frane Polić
2023-05-08 08:58:11 +00:00
committed by GitHub
parent 0c58ead6d8
commit a8e73942e6
10 changed files with 553 additions and 107 deletions
@@ -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