feat: import strategy - sales channels support (#2124)
**What** - add support for specifying sales channel with import strategy - additional: - refactor SC service to use `retrieve_` pattern - fix: pass arguments from `startServerWithEnvironment` to setup server - fix: minio undefined resolve/reject calls - fix: csv parser - detect missing columns from schema only if the column is required **How** - extending schema to expect sales channels columns in an import CSV file RESOLVES CORE-304
This commit is contained in:
1 parent
d14a0398fb
commit
546a963f7b
12 files changed
+611
-143
No files matched your search
@@ -60,22 +60,26 @@ class MinioService extends AbstractFileService {
|
||||
}
|
||||
|
||||
return await Promise.all([
|
||||
s3.deleteObject({ ...params, Bucket: this.bucket_ }, (err, data) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(data)
|
||||
}),
|
||||
s3.deleteObject(
|
||||
{ ...params, Bucket: this.private_bucket_ },
|
||||
(err, data) => {
|
||||
new Promise((resolve, reject) =>
|
||||
s3.deleteObject({ ...params, Bucket: this.bucket_ }, (err, data) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
),
|
||||
new Promise((resolve, reject) =>
|
||||
s3.deleteObject(
|
||||
{ ...params, Bucket: this.private_bucket_ },
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(data)
|
||||
}
|
||||
)
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2,15 +2,64 @@ import {
|
||||
Brackets,
|
||||
DeleteResult,
|
||||
EntityRepository,
|
||||
FindManyOptions,
|
||||
In,
|
||||
Repository,
|
||||
} from "typeorm"
|
||||
import { SalesChannel } from "../models"
|
||||
import { ExtendedFindConfig, Selector } from "../types/common";
|
||||
import { ExtendedFindConfig, Selector } from "../types/common"
|
||||
import { flatten, groupBy, merge } from "lodash"
|
||||
|
||||
@EntityRepository(SalesChannel)
|
||||
export class SalesChannelRepository extends Repository<SalesChannel> {
|
||||
public async getFreeTextSearchResultsAndCount(
|
||||
public async findWithRelations(
|
||||
relations: (keyof SalesChannel | string)[] = [],
|
||||
idsOrOptionsWithoutRelations:
|
||||
| Omit<FindManyOptions<SalesChannel>, "relations">
|
||||
| string[] = {}
|
||||
): Promise<[SalesChannel[], number]> {
|
||||
let entities: SalesChannel[] = []
|
||||
let count = 0
|
||||
if (Array.isArray(idsOrOptionsWithoutRelations)) {
|
||||
entities = await this.findByIds(idsOrOptionsWithoutRelations)
|
||||
count = idsOrOptionsWithoutRelations.length
|
||||
} else {
|
||||
const [results, resultCount] = await this.findAndCount(
|
||||
idsOrOptionsWithoutRelations
|
||||
)
|
||||
entities = results
|
||||
count = resultCount
|
||||
}
|
||||
const entitiesIds = entities.map(({ id }) => id)
|
||||
|
||||
const groupedRelations = {}
|
||||
for (const rel of relations) {
|
||||
const [topLevel] = rel.split(".")
|
||||
if (groupedRelations[topLevel]) {
|
||||
groupedRelations[topLevel].push(rel)
|
||||
} else {
|
||||
groupedRelations[topLevel] = [rel]
|
||||
}
|
||||
}
|
||||
|
||||
const entitiesIdsWithRelations = await Promise.all(
|
||||
Object.entries(groupedRelations).map(([_, rels]) => {
|
||||
return this.findByIds(entitiesIds, {
|
||||
select: ["id"],
|
||||
relations: rels as string[],
|
||||
})
|
||||
})
|
||||
).then(flatten)
|
||||
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
|
||||
|
||||
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
|
||||
return [
|
||||
Object.values(entitiesAndRelationsById).map((v) => merge({}, ...v)),
|
||||
count,
|
||||
]
|
||||
}
|
||||
|
||||
public async getFreeTextSearchResultsAndCount(
|
||||
q: string,
|
||||
options: ExtendedFindConfig<SalesChannel, Selector<SalesChannel>> = {
|
||||
where: {},
|
||||
@@ -70,4 +119,21 @@ export class SalesChannelRepository extends Repository<SalesChannel> {
|
||||
.orIgnore()
|
||||
.execute()
|
||||
}
|
||||
|
||||
public async findOneWithRelations(
|
||||
relations: Array<keyof SalesChannel> = [],
|
||||
optionsWithoutRelations: Omit<
|
||||
FindManyOptions<SalesChannel>,
|
||||
"relations"
|
||||
> = {}
|
||||
): Promise<SalesChannel> {
|
||||
// Limit 1
|
||||
optionsWithoutRelations.take = 1
|
||||
|
||||
const [result] = await this.findWithRelations(
|
||||
relations,
|
||||
optionsWithoutRelations
|
||||
)
|
||||
return result[0]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
|
||||
import SalesChannelService from "../sales-channel"
|
||||
import { EventBusServiceMock } from "../__mocks__/event-bus"
|
||||
import { EventBusService, ProductService, StoreService } from "../index"
|
||||
import { FindConditions, FindOneOptions } from "typeorm"
|
||||
import { EventBusService, StoreService } from "../index"
|
||||
import { FindConditions, FindManyOptions, FindOneOptions } from "typeorm"
|
||||
import { SalesChannel } from "../../models"
|
||||
import { ProductServiceMock } from "../__mocks__/product";
|
||||
import { store, StoreServiceMock } from "../__mocks__/store";
|
||||
import { store, StoreServiceMock } from "../__mocks__/store"
|
||||
|
||||
describe("SalesChannelService", () => {
|
||||
const salesChannelData = {
|
||||
@@ -16,56 +15,79 @@ describe("SalesChannelService", () => {
|
||||
|
||||
const salesChannelRepositoryMock = {
|
||||
...MockRepository({
|
||||
findOneWithRelations: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
relations: Array<keyof SalesChannel> = [],
|
||||
optionsWithoutRelations: Omit<
|
||||
FindManyOptions<SalesChannel>,
|
||||
"relations"
|
||||
>
|
||||
): any => {
|
||||
return Promise.resolve({
|
||||
id:
|
||||
(optionsWithoutRelations?.where as FindConditions<SalesChannel>)
|
||||
?.id ?? IdMap.getId("sc_adjhlukiaeswhfae"),
|
||||
...salesChannelData,
|
||||
})
|
||||
}
|
||||
),
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(queryOrId: string | FindOneOptions<SalesChannel>): any => {
|
||||
return Promise.resolve({
|
||||
id:
|
||||
typeof queryOrId === "string"
|
||||
? queryOrId
|
||||
: (queryOrId?.where as FindConditions<SalesChannel>)?.id ??
|
||||
IdMap.getId("sc_adjhlukiaeswhfae"),
|
||||
...salesChannelData,
|
||||
})
|
||||
}
|
||||
),
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(queryOrId: string | FindOneOptions<SalesChannel>): any => {
|
||||
return Promise.resolve({
|
||||
id:
|
||||
typeof queryOrId === "string"
|
||||
? queryOrId
|
||||
: (queryOrId?.where as FindConditions<SalesChannel>)?.id ??
|
||||
IdMap.getId("sc_adjhlukiaeswhfae"),
|
||||
...salesChannelData,
|
||||
})
|
||||
}
|
||||
),
|
||||
findAndCount: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData
|
||||
...salesChannelData,
|
||||
},
|
||||
]),
|
||||
])
|
||||
),
|
||||
create: jest.fn().mockImplementation((data) => data),
|
||||
save: (salesChannel) => Promise.resolve({
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannel
|
||||
}),
|
||||
save: (salesChannel) =>
|
||||
Promise.resolve({
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannel,
|
||||
}),
|
||||
softRemove: jest.fn().mockImplementation((id: string): any => {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve()
|
||||
}),
|
||||
}),
|
||||
getFreeTextSearchResultsAndCount: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData
|
||||
...salesChannelData,
|
||||
},
|
||||
])
|
||||
),
|
||||
removeProducts: jest.fn().mockImplementation((id: string, productIds: string[]): any => {
|
||||
Promise.resolve([
|
||||
{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData
|
||||
},
|
||||
])
|
||||
}),
|
||||
addProducts: jest.fn().mockImplementation((id: string, productIds: string[]): any => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
removeProducts: jest
|
||||
.fn()
|
||||
.mockImplementation((id: string, productIds: string[]): any => {
|
||||
Promise.resolve([
|
||||
{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData,
|
||||
},
|
||||
])
|
||||
}),
|
||||
addProducts: jest
|
||||
.fn()
|
||||
.mockImplementation((id: string, productIds: string[]): any => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
}
|
||||
|
||||
describe("create default", async () => {
|
||||
@@ -145,8 +167,9 @@ describe("SalesChannelService", () => {
|
||||
...salesChannelData,
|
||||
})
|
||||
|
||||
expect(salesChannelRepositoryMock.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(salesChannelRepositoryMock.findOne).toHaveBeenLastCalledWith({
|
||||
expect(
|
||||
salesChannelRepositoryMock.findOneWithRelations
|
||||
).toHaveBeenLastCalledWith(undefined, {
|
||||
where: { id: IdMap.getId("sales_channel_1") },
|
||||
})
|
||||
})
|
||||
@@ -206,48 +229,53 @@ describe("SalesChannelService", () => {
|
||||
|
||||
expect(salesChannel).toBeTruthy()
|
||||
expect(salesChannel).toEqual(
|
||||
expect.arrayContaining([{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData,
|
||||
}])
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData,
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
expect(salesChannelRepositoryMock.findAndCount).toHaveBeenCalledTimes(0)
|
||||
expect(salesChannelRepositoryMock.getFreeTextSearchResultsAndCount).toHaveBeenCalledTimes(1)
|
||||
expect(salesChannelRepositoryMock.getFreeTextSearchResultsAndCount).toHaveBeenLastCalledWith(
|
||||
q,
|
||||
{
|
||||
skip: 0,
|
||||
take: 20,
|
||||
where: {},
|
||||
}
|
||||
)
|
||||
expect(
|
||||
salesChannelRepositoryMock.getFreeTextSearchResultsAndCount
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
salesChannelRepositoryMock.getFreeTextSearchResultsAndCount
|
||||
).toHaveBeenLastCalledWith(q, {
|
||||
skip: 0,
|
||||
take: 20,
|
||||
where: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("should retrieve a sales channel using find and count", async () => {
|
||||
const salesChannel = await salesChannelService.listAndCount({
|
||||
id: IdMap.getId("sales_channel_1")
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
})
|
||||
|
||||
expect(salesChannel).toBeTruthy()
|
||||
expect(salesChannel).toEqual(
|
||||
expect.arrayContaining([{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData,
|
||||
}])
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
...salesChannelData,
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
expect(salesChannelRepositoryMock.getFreeTextSearchResultsAndCount).toHaveBeenCalledTimes(0)
|
||||
expect(
|
||||
salesChannelRepositoryMock.getFreeTextSearchResultsAndCount
|
||||
).toHaveBeenCalledTimes(0)
|
||||
expect(salesChannelRepositoryMock.findAndCount).toHaveBeenCalledTimes(1)
|
||||
expect(salesChannelRepositoryMock.findAndCount).toHaveBeenLastCalledWith(
|
||||
{
|
||||
skip: 0,
|
||||
take: 20,
|
||||
where: {
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
},
|
||||
}
|
||||
)
|
||||
expect(salesChannelRepositoryMock.findAndCount).toHaveBeenLastCalledWith({
|
||||
skip: 0,
|
||||
take: 20,
|
||||
where: {
|
||||
id: IdMap.getId("sales_channel_1"),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -318,7 +346,7 @@ describe("SalesChannelService", () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should remove a list of product to a sales channel', async () => {
|
||||
it("should remove a list of product to a sales channel", async () => {
|
||||
const salesChannel = await salesChannelService.removeProducts(
|
||||
IdMap.getId("sales_channel_1"),
|
||||
[IdMap.getId("sales_channel_1_product_1")]
|
||||
@@ -349,7 +377,7 @@ describe("SalesChannelService", () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should add a list of product to a sales channel', async () => {
|
||||
it("should add a list of product to a sales channel", async () => {
|
||||
const salesChannel = await salesChannelService.addProducts(
|
||||
IdMap.getId("sales_channel_1"),
|
||||
[IdMap.getId("sales_channel_1_product_1")]
|
||||
|
||||
@@ -53,6 +53,9 @@ class CsvParser<
|
||||
): Promise<TOutputResult> {
|
||||
let outputTuple = {} as TOutputResult
|
||||
const columnMap = this.buildColumnMap_(this.$$schema.columns)
|
||||
const requiredColumnsMap = this.buildColumnMap_(
|
||||
this.$$schema.columns.filter((col) => col.required)
|
||||
)
|
||||
|
||||
const tupleKeys = Object.keys(line)
|
||||
|
||||
@@ -95,10 +98,10 @@ class CsvParser<
|
||||
}
|
||||
|
||||
/**
|
||||
* missing columns = columns defined in the schema - columns present in the line
|
||||
* missing columns = columns defined (& required) in the schema - columns present in the line
|
||||
*/
|
||||
const missingColumns = difference(
|
||||
Object.keys(columnMap),
|
||||
Object.keys(requiredColumnsMap),
|
||||
Object.keys(processedColumns)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
CreateSalesChannelInput,
|
||||
UpdateSalesChannelInput,
|
||||
} from "../types/sales-channels"
|
||||
import { FindConfig, QuerySelector } from "../types/common"
|
||||
import { FindConfig, QuerySelector, Selector } from "../types/common"
|
||||
|
||||
import { EntityManager } from "typeorm"
|
||||
import EventBusService from "./event-bus"
|
||||
@@ -54,9 +54,49 @@ class SalesChannelService extends TransactionBaseService {
|
||||
this.storeService_ = storeService
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic retrieve used to find a sales channel by different attributes.
|
||||
*
|
||||
* @param selector - SC selector
|
||||
* @param config - find config
|
||||
* @returns a single SC matching the query or throws
|
||||
*/
|
||||
protected async retrieve_(
|
||||
selector: Selector<SalesChannel>,
|
||||
config: FindConfig<SalesChannel> = {}
|
||||
): Promise<SalesChannel> {
|
||||
const manager = this.manager_
|
||||
|
||||
const salesChannelRepo = manager.getCustomRepository(
|
||||
this.salesChannelRepository_
|
||||
)
|
||||
|
||||
const { relations, ...query } = buildQuery(selector, config)
|
||||
|
||||
const salesChannel = await salesChannelRepo.findOneWithRelations(
|
||||
relations as (keyof SalesChannel)[],
|
||||
query
|
||||
)
|
||||
|
||||
if (!salesChannel) {
|
||||
const selectorConstraints = Object.entries(selector)
|
||||
.map((key, value) => `${key}: ${value}`)
|
||||
.join(", ")
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Sales channel with ${selectorConstraints} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return salesChannel
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a SalesChannel by id
|
||||
*
|
||||
* @param salesChannelId - id of the channel to retrieve
|
||||
* @param config - SC config
|
||||
* @experimental This feature is under development and may change in the future.
|
||||
* To use this feature please enable the corresponding feature flag in your medusa backend project.
|
||||
* @returns a sales channel
|
||||
@@ -65,28 +105,21 @@ class SalesChannelService extends TransactionBaseService {
|
||||
salesChannelId: string,
|
||||
config: FindConfig<SalesChannel> = {}
|
||||
): Promise<SalesChannel | never> {
|
||||
const manager = this.manager_
|
||||
const salesChannelRepo = manager.getCustomRepository(
|
||||
this.salesChannelRepository_
|
||||
)
|
||||
return await this.retrieve_({ id: salesChannelId }, config)
|
||||
}
|
||||
|
||||
const query = buildQuery(
|
||||
{
|
||||
id: salesChannelId,
|
||||
},
|
||||
config
|
||||
)
|
||||
|
||||
const salesChannel = await salesChannelRepo.findOne(query)
|
||||
|
||||
if (!salesChannel) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Sales channel with id ${salesChannelId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return salesChannel
|
||||
/**
|
||||
* Find a sales channel by name.
|
||||
*
|
||||
* @param name of the sales channel
|
||||
* @param config - find config
|
||||
* @return a sales channel with matching name
|
||||
*/
|
||||
async retrieveByName(
|
||||
name: string,
|
||||
config: FindConfig<SalesChannel> = {}
|
||||
): Promise<SalesChannel | unknown> {
|
||||
return await this.retrieve_({ name }, config)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ShippingProfileService,
|
||||
} from "../../../../services"
|
||||
import { InjectedProps } from "../../../batch-jobs/product/types"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
|
||||
let fakeJob = {
|
||||
id: IdMap.getId("product-import-job"),
|
||||
@@ -142,6 +143,7 @@ describe("Product import strategy", () => {
|
||||
productVariantService:
|
||||
productVariantServiceMock as unknown as ProductVariantService,
|
||||
regionService: regionServiceMock as unknown as RegionService,
|
||||
featureFlagRouter: new FlagRouter({}),
|
||||
} as unknown as InjectedProps)
|
||||
|
||||
it("`preProcessBatchJob` should generate import ops and upload them to a bucket using the file service", async () => {
|
||||
@@ -160,14 +162,14 @@ describe("Product import strategy", () => {
|
||||
1,
|
||||
{
|
||||
ext: "json",
|
||||
name: `imports/products/import/ops/${fakeJob.id}-PRODUCT_CREATE`,
|
||||
name: `imports/products/ops/${fakeJob.id}-PRODUCT_CREATE`,
|
||||
}
|
||||
)
|
||||
expect(fileServiceMock.getUploadStreamDescriptor).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
ext: "json",
|
||||
name: `imports/products/import/ops/${fakeJob.id}-VARIANT_UPDATE`, // because row data has variant.id
|
||||
name: `imports/products/ops/${fakeJob.id}-VARIANT_UPDATE`, // because row data has variant.id
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
ProductService,
|
||||
ProductVariantService,
|
||||
RegionService,
|
||||
SalesChannelService,
|
||||
ShippingProfileService,
|
||||
} from "../../../services"
|
||||
import { CreateProductInput } from "../../../types/product"
|
||||
import { CreateProductInput, UpdateProductInput } from "../../../types/product"
|
||||
import {
|
||||
CreateProductVariantInput,
|
||||
UpdateProductVariantInput,
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
TBuiltProductImportLine,
|
||||
TParsedProductImportRowData,
|
||||
} from "./types"
|
||||
import { SalesChannel } from "../../../models"
|
||||
import { FlagRouter } from "../../../utils/flag-router"
|
||||
import { transformProductData, transformVariantData } from "./utils"
|
||||
import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels"
|
||||
|
||||
/**
|
||||
* Process this many variant rows before reporting progress.
|
||||
@@ -35,22 +39,25 @@ const BATCH_SIZE = 100
|
||||
* Default strategy class used for a batch import of products/variants.
|
||||
*/
|
||||
class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
static identifier = "product-import"
|
||||
static identifier = "product-import-strategy"
|
||||
|
||||
static batchType = "product_import"
|
||||
static batchType = "product-import"
|
||||
|
||||
private processedCounter: Record<string, number> = {}
|
||||
|
||||
protected readonly featureFlagRouter_: FlagRouter
|
||||
|
||||
protected manager_: EntityManager
|
||||
protected transactionManager_: EntityManager | undefined
|
||||
|
||||
protected readonly fileService_: IFileService
|
||||
|
||||
protected readonly regionService_: RegionService
|
||||
protected readonly productService_: ProductService
|
||||
protected readonly batchJobService_: BatchJobService
|
||||
protected readonly salesChannelService_: SalesChannelService
|
||||
protected readonly productVariantService_: ProductVariantService
|
||||
protected readonly shippingProfileService_: ShippingProfileService
|
||||
protected readonly regionService_: RegionService
|
||||
|
||||
protected readonly csvParser_: CsvParser<
|
||||
ProductImportCsvSchema,
|
||||
@@ -61,21 +68,36 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
constructor({
|
||||
batchJobService,
|
||||
productService,
|
||||
salesChannelService,
|
||||
productVariantService,
|
||||
shippingProfileService,
|
||||
regionService,
|
||||
fileService,
|
||||
manager,
|
||||
featureFlagRouter,
|
||||
}: InjectedProps) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
super(arguments[0])
|
||||
|
||||
this.csvParser_ = new CsvParser(CSVSchema)
|
||||
const isSalesChannelsFeatureOn = featureFlagRouter.isFeatureEnabled(
|
||||
SalesChannelFeatureFlag.key
|
||||
)
|
||||
|
||||
this.csvParser_ = new CsvParser({
|
||||
...CSVSchema,
|
||||
columns: [
|
||||
...CSVSchema.columns,
|
||||
...(isSalesChannelsFeatureOn ? SalesChannelsSchema.columns : []),
|
||||
],
|
||||
})
|
||||
|
||||
this.featureFlagRouter_ = featureFlagRouter
|
||||
|
||||
this.manager_ = manager
|
||||
this.fileService_ = fileService
|
||||
this.batchJobService_ = batchJobService
|
||||
this.productService_ = productService
|
||||
this.salesChannelService_ = salesChannelService
|
||||
this.productVariantService_ = productVariantService
|
||||
this.shippingProfileService_ = shippingProfileService
|
||||
this.regionService_ = regionService
|
||||
@@ -89,15 +111,18 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
* Create a description of a row on which the error occurred and throw a Medusa error.
|
||||
*
|
||||
* @param row - Parsed CSV row data
|
||||
* @param errorDescription - Concrete error
|
||||
*/
|
||||
protected static throwDescriptiveError(
|
||||
row: TParsedProductImportRowData
|
||||
row: TParsedProductImportRowData,
|
||||
errorDescription?: string
|
||||
): never {
|
||||
const message = `Error while processing row with:
|
||||
product id: ${row["product.id"]},
|
||||
product handle: ${row["product.handle"]},
|
||||
variant id: ${row["variant.id"]}
|
||||
variant sku: ${row["variant.sku"]}`
|
||||
variant sku: ${row["variant.sku"]}
|
||||
${errorDescription}`
|
||||
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, message)
|
||||
}
|
||||
@@ -168,18 +193,18 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
}
|
||||
|
||||
if (price.regionName) {
|
||||
const region = await this.regionService_
|
||||
.withTransaction(transactionManager)
|
||||
.retrieveByName(price.regionName)
|
||||
|
||||
if (!region) {
|
||||
try {
|
||||
record.region_id = (
|
||||
await this.regionService_
|
||||
.withTransaction(transactionManager)
|
||||
.retrieveByName(price.regionName)
|
||||
)?.id
|
||||
} catch (e) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Trying to set a price for a region ${price.regionName} that doesn't exist`
|
||||
)
|
||||
}
|
||||
|
||||
record.region_id = region!.id
|
||||
} else {
|
||||
record.currency_code = price.currency_code
|
||||
}
|
||||
@@ -255,6 +280,49 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, or retrieve by name, sales channels from the input data.
|
||||
*
|
||||
* NOTE: Sales channel names provided in the CSV must exist in the DB.
|
||||
* New sales channels will not be created.
|
||||
*
|
||||
* @param data an array of sales channels partials
|
||||
* @return an array of sales channels created or retrieved by name
|
||||
*/
|
||||
private async processSalesChannels(
|
||||
data: Pick<SalesChannel, "name" | "id">[]
|
||||
): Promise<SalesChannel[]> {
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
const salesChannelServiceTx =
|
||||
this.salesChannelService_.withTransaction(transactionManager)
|
||||
|
||||
const salesChannels: SalesChannel[] = []
|
||||
|
||||
for (const input of data) {
|
||||
let channel: SalesChannel | null = null
|
||||
|
||||
if (input.id) {
|
||||
try {
|
||||
channel = await salesChannelServiceTx.retrieve(input.id, {
|
||||
select: ["id"],
|
||||
})
|
||||
} catch (e) {
|
||||
// noop - check if the channel exists with provided name
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
channel = (await salesChannelServiceTx.retrieveByName(input.name, {
|
||||
select: ["id"],
|
||||
})) as SalesChannel
|
||||
}
|
||||
|
||||
salesChannels.push(channel)
|
||||
}
|
||||
|
||||
return salesChannels
|
||||
}
|
||||
|
||||
/**
|
||||
* Method creates products using `ProductService` and parsed data from a CSV row.
|
||||
*
|
||||
@@ -267,15 +335,31 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
OperationType.ProductCreate
|
||||
)
|
||||
|
||||
const productServiceTx =
|
||||
this.productService_.withTransaction(transactionManager)
|
||||
|
||||
const isSalesChannelsFeatureOn = this.featureFlagRouter_.isFeatureEnabled(
|
||||
SalesChannelFeatureFlag.key
|
||||
)
|
||||
|
||||
for (const productOp of productOps) {
|
||||
const productData = transformProductData(
|
||||
productOp
|
||||
) as unknown as CreateProductInput
|
||||
|
||||
try {
|
||||
await this.productService_
|
||||
.withTransaction(transactionManager)
|
||||
.create(
|
||||
transformProductData(productOp) as unknown as CreateProductInput
|
||||
if (isSalesChannelsFeatureOn) {
|
||||
productData["sales_channels"] = await this.processSalesChannels(
|
||||
productOp["product.sales_channels"] as Pick<
|
||||
SalesChannel,
|
||||
"name" | "id"
|
||||
>[]
|
||||
)
|
||||
}
|
||||
|
||||
await productServiceTx.create(productData)
|
||||
} catch (e) {
|
||||
ProductImportStrategy.throwDescriptiveError(productOp)
|
||||
ProductImportStrategy.throwDescriptiveError(productOp, e.message)
|
||||
}
|
||||
|
||||
this.updateProgress(batchJobId)
|
||||
@@ -294,16 +378,31 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
OperationType.ProductUpdate
|
||||
)
|
||||
|
||||
const productServiceTx =
|
||||
this.productService_.withTransaction(transactionManager)
|
||||
|
||||
const isSalesChannelsFeatureOn = this.featureFlagRouter_.isFeatureEnabled(
|
||||
SalesChannelFeatureFlag.key
|
||||
)
|
||||
|
||||
for (const productOp of productOps) {
|
||||
const productData = transformProductData(productOp) as UpdateProductInput
|
||||
try {
|
||||
await this.productService_
|
||||
.withTransaction(transactionManager)
|
||||
.update(
|
||||
productOp["product.id"] as string,
|
||||
transformProductData(productOp)
|
||||
if (isSalesChannelsFeatureOn) {
|
||||
productData["sales_channels"] = await this.processSalesChannels(
|
||||
productOp["product.sales_channels"] as Pick<
|
||||
SalesChannel,
|
||||
"name" | "id"
|
||||
>[]
|
||||
)
|
||||
}
|
||||
|
||||
await productServiceTx.update(
|
||||
productOp["product.id"] as string,
|
||||
productData
|
||||
)
|
||||
} catch (e) {
|
||||
ProductImportStrategy.throwDescriptiveError(productOp)
|
||||
ProductImportStrategy.throwDescriptiveError(productOp, e.message)
|
||||
}
|
||||
|
||||
this.updateProgress(batchJobId)
|
||||
@@ -355,7 +454,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
|
||||
this.updateProgress(batchJobId)
|
||||
} catch (e) {
|
||||
ProductImportStrategy.throwDescriptiveError(variantOp)
|
||||
ProductImportStrategy.throwDescriptiveError(variantOp, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,7 +490,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
transformVariantData(variantOp) as UpdateProductVariantInput
|
||||
)
|
||||
} catch (e) {
|
||||
ProductImportStrategy.throwDescriptiveError(variantOp)
|
||||
ProductImportStrategy.throwDescriptiveError(variantOp, e.message)
|
||||
}
|
||||
|
||||
this.updateProgress(batchJobId)
|
||||
@@ -437,13 +536,14 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
const { writeStream, promise } = await this.fileService_
|
||||
.withTransaction(transactionManager)
|
||||
.getUploadStreamDescriptor({
|
||||
name: `imports/products/import/ops/${batchJobId}-${op}`,
|
||||
name: `imports/products/ops/${batchJobId}-${op}`,
|
||||
ext: "json",
|
||||
})
|
||||
|
||||
uploadPromises.push(promise)
|
||||
|
||||
writeStream.write(JSON.stringify(results[op]))
|
||||
writeStream.end()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,8 +566,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
const readableStream = await this.fileService_
|
||||
.withTransaction(transactionManager)
|
||||
.getDownloadStream({
|
||||
fileKey: `imports/products/import/ops/${batchJobId}-${op}`,
|
||||
ext: "json",
|
||||
fileKey: `imports/products/ops/${batchJobId}-${op}.json`,
|
||||
})
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
@@ -477,9 +576,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
readableStream.on("end", () => {
|
||||
resolve(JSON.parse(data))
|
||||
})
|
||||
readableStream.on("error", () =>
|
||||
readableStream.on("error", () => {
|
||||
// TODO: maybe should throw
|
||||
resolve([] as TParsedProductImportRowData[])
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -494,7 +594,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
for (const op of Object.keys(OperationType)) {
|
||||
try {
|
||||
this.fileService_.withTransaction(transactionManager).delete({
|
||||
fileKey: `imports/products/import/ops/-${batchJobId}-${op}`,
|
||||
fileKey: `imports/products/ops/-${batchJobId}-${op}`,
|
||||
})
|
||||
} catch (e) {
|
||||
// noop
|
||||
@@ -659,6 +759,7 @@ const CSVSchema: ProductImportCsvSchema = {
|
||||
return builtLine
|
||||
},
|
||||
},
|
||||
|
||||
// PRICES
|
||||
{
|
||||
name: "Price Region",
|
||||
@@ -730,3 +831,52 @@ const CSVSchema: ProductImportCsvSchema = {
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const SalesChannelsSchema: ProductImportCsvSchema = {
|
||||
columns: [
|
||||
{
|
||||
name: "Sales Channel Name",
|
||||
match: /Sales Channel \d+ Name/,
|
||||
reducer: (builtLine, key, value): TBuiltProductImportLine => {
|
||||
builtLine["product.sales_channels"] =
|
||||
builtLine["product.sales_channels"] || []
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return builtLine
|
||||
}
|
||||
;(
|
||||
builtLine["product.sales_channels"] as Record<
|
||||
string,
|
||||
string | number
|
||||
>[]
|
||||
).push({
|
||||
name: value,
|
||||
})
|
||||
|
||||
return builtLine
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Sales Channel Id",
|
||||
match: /Sales Channel \d+ Id/,
|
||||
reducer: (builtLine, key, value): TBuiltProductImportLine => {
|
||||
builtLine["product.sales_channels"] =
|
||||
builtLine["product.sales_channels"] || []
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return builtLine
|
||||
}
|
||||
;(
|
||||
builtLine["product.sales_channels"] as Record<
|
||||
string,
|
||||
string | number
|
||||
>[]
|
||||
).push({
|
||||
id: value,
|
||||
})
|
||||
|
||||
return builtLine
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
ProductService,
|
||||
ProductVariantService,
|
||||
RegionService,
|
||||
SalesChannelService,
|
||||
ShippingProfileService,
|
||||
} from "../../../services"
|
||||
import { ProductOptionRepository } from "../../../repositories/product-option"
|
||||
import { CsvSchema } from "../../../interfaces/csv-parser"
|
||||
import { FlagRouter } from "../../../utils/flag-router"
|
||||
|
||||
/**
|
||||
* DI props for the Product import strategy
|
||||
@@ -19,9 +20,11 @@ export type InjectedProps = {
|
||||
productService: ProductService
|
||||
productVariantService: ProductVariantService
|
||||
shippingProfileService: ShippingProfileService
|
||||
salesChannelService: SalesChannelService
|
||||
regionService: RegionService
|
||||
fileService: typeof FileService
|
||||
|
||||
featureFlagRouter: FlagRouter
|
||||
manager: EntityManager
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user