feat(medusa): PriceList import strategy (#2210)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import zeroDecimalCurrencies from "./zero-decimal-currencies"
|
||||
|
||||
const computerizeAmount = (amount, currency) => {
|
||||
let divisor = 100
|
||||
|
||||
if (zeroDecimalCurrencies.includes(currency.toLowerCase())) {
|
||||
divisor = 1
|
||||
}
|
||||
|
||||
return Math.round(amount * divisor)
|
||||
}
|
||||
|
||||
export default computerizeAmount
|
||||
@@ -4,8 +4,8 @@ export { default as createRequireFromPath } from "./create-require-from-path"
|
||||
export { default as MedusaError } from "./errors"
|
||||
export { default as getConfigFile } from "./get-config-file"
|
||||
export { default as humanizeAmount } from "./humanize-amount"
|
||||
export { default as computerizeAmount } from "./computerize-amount"
|
||||
export { indexTypes } from "./index-types"
|
||||
export { transformIdableFields } from "./transform-idable-fields"
|
||||
export { default as Validator } from "./validator"
|
||||
export { default as zeroDecimalCurrencies } from "./zero-decimal-currencies"
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export { default as OrderEditService } from "./order-edit"
|
||||
export { default as OrderEditItemChangeService } from "./order-edit-item-change"
|
||||
export { default as PaymentProviderService } from "./payment-provider"
|
||||
export { default as PricingService } from "./pricing"
|
||||
export { default as PriceListService } from "./price-list"
|
||||
export { default as ProductCollectionService } from "./product-collection"
|
||||
export { default as ProductService } from "./product"
|
||||
export { default as ProductTypeService } from "./product-type"
|
||||
|
||||
@@ -249,6 +249,19 @@ class PriceListService extends TransactionBaseService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all prices from a price list and deletes the removed prices in bulk
|
||||
* @param id - id of the price list
|
||||
* @returns {Promise<void>} updated Price List
|
||||
*/
|
||||
async clearPrices(id: string): Promise<void> {
|
||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
|
||||
const priceList = await this.retrieve(id, { select: ["id"] })
|
||||
await moneyAmountRepo.delete({ price_list_id: priceList.id })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Price List
|
||||
* Will never fail due to delete being idempotent.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Readable, PassThrough } from "stream"
|
||||
import { EntityManager } from "typeorm"
|
||||
|
||||
import { FileService } from "medusa-interfaces"
|
||||
import { MockManager } from "medusa-test-utils"
|
||||
|
||||
import { User } from "../../../../models"
|
||||
import { BatchJobStatus } from "../../../../types/batch-job"
|
||||
import PriceListImportStrategy from "../../../batch-jobs/price-list/import"
|
||||
import {
|
||||
PriceListService,
|
||||
BatchJobService,
|
||||
ProductVariantService,
|
||||
RegionService,
|
||||
} from "../../../../services"
|
||||
import { InjectedProps } from "../../../batch-jobs/price-list/types"
|
||||
|
||||
let fakeJob = {
|
||||
id: "batch_plimport",
|
||||
type: "price-list-import",
|
||||
context: {
|
||||
price_list_id: "pl_1234",
|
||||
fileKey: "csv.key",
|
||||
},
|
||||
results: { advancement_count: 0, count: 6 },
|
||||
created_by: "usr_tester",
|
||||
created_by_user: {} as User,
|
||||
result: {},
|
||||
dry_run: false,
|
||||
status: BatchJobStatus.PROCESSING,
|
||||
}
|
||||
|
||||
async function* generateCSVDataForStream() {
|
||||
yield "Product Variant ID,SKU,Price EUR,Price NA [USD]\n"
|
||||
yield ",MEDUSA-SWEAT-SMALL,15,13.5\n"
|
||||
yield "5VxiEkmnPV,,15,13.5\n"
|
||||
}
|
||||
|
||||
/* ******************** SERVICES MOCK ******************** */
|
||||
|
||||
const fileServiceMock = {
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
delete: jest.fn(),
|
||||
getDownloadStream: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve(Readable.from(generateCSVDataForStream()))
|
||||
}),
|
||||
getUploadStreamDescriptor: jest.fn().mockImplementation(() => ({
|
||||
writeStream: new PassThrough(),
|
||||
promise: Promise.resolve(),
|
||||
})),
|
||||
}
|
||||
|
||||
const priceListServiceMock = {
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
retrieve: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve(fakeJob)
|
||||
}),
|
||||
}
|
||||
|
||||
const batchJobServiceMock = {
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
update: jest.fn().mockImplementation((data) => {
|
||||
fakeJob = {
|
||||
...fakeJob,
|
||||
...data,
|
||||
}
|
||||
return Promise.resolve(fakeJob)
|
||||
}),
|
||||
complete: jest.fn().mockImplementation(() => {
|
||||
fakeJob.status = BatchJobStatus.COMPLETED
|
||||
return Promise.resolve(fakeJob)
|
||||
}),
|
||||
confirmed: jest.fn().mockImplementation(() => {
|
||||
fakeJob.status = BatchJobStatus.CONFIRMED
|
||||
return Promise.resolve(fakeJob)
|
||||
}),
|
||||
retrieve: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve(fakeJob)
|
||||
}),
|
||||
}
|
||||
|
||||
const productVariantServiceMock = {
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
retrieve: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: "retrieved-by-id",
|
||||
})
|
||||
),
|
||||
retrieveBySKU: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: "retrieved-by-sku",
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
const regionServiceMock = {
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
retrieveByName: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: "reg_HMnixPlOicAs7aBlXuchAGxd",
|
||||
name: "Denmark",
|
||||
currency_code: "DKK",
|
||||
currency: "DKK",
|
||||
tax_rate: 0.25,
|
||||
tax_code: null,
|
||||
countries: [
|
||||
{
|
||||
id: "1001",
|
||||
iso_2: "DK",
|
||||
iso_3: "DNK",
|
||||
num_code: "208",
|
||||
name: "denmark",
|
||||
display_name: "Denmark",
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
const managerMock = MockManager
|
||||
|
||||
/* ******************** PRICE LIST IMPORT STRATEGY TESTS ******************** */
|
||||
|
||||
describe("Price List import strategy", () => {
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
const priceListImportStrategy = new PriceListImportStrategy({
|
||||
manager: managerMock as EntityManager,
|
||||
fileService: fileServiceMock as typeof FileService,
|
||||
batchJobService: batchJobServiceMock as unknown as BatchJobService,
|
||||
priceListService: priceListServiceMock as unknown as PriceListService,
|
||||
productVariantService:
|
||||
productVariantServiceMock as unknown as ProductVariantService,
|
||||
regionService: regionServiceMock as unknown as RegionService,
|
||||
} as unknown as InjectedProps)
|
||||
|
||||
it("`preProcessBatchJob` should generate import ops and upload them to a bucket using the file service", async () => {
|
||||
const getImportInstructionsSpy = jest.spyOn(
|
||||
priceListImportStrategy,
|
||||
"getImportInstructions"
|
||||
)
|
||||
|
||||
await priceListImportStrategy.preProcessBatchJob(fakeJob.id)
|
||||
|
||||
expect(getImportInstructionsSpy).toBeCalledTimes(1)
|
||||
expect(fileServiceMock.getUploadStreamDescriptor).toBeCalledTimes(1)
|
||||
expect(fileServiceMock.getUploadStreamDescriptor).toHaveBeenCalledWith({
|
||||
ext: "json",
|
||||
name: `imports/price-lists/ops/${fakeJob.id}-PRICE_LIST_PRICE_CREATE`,
|
||||
})
|
||||
|
||||
getImportInstructionsSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import { EntityManager } from "typeorm"
|
||||
import { MedusaError, computerizeAmount } from "medusa-core-utils"
|
||||
|
||||
import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces"
|
||||
import CsvParser from "../../../services/csv-parser"
|
||||
import {
|
||||
BatchJobService,
|
||||
ProductVariantService,
|
||||
PriceListService,
|
||||
RegionService,
|
||||
} from "../../../services"
|
||||
import { CreateBatchJobInput } from "../../../types/batch-job"
|
||||
import {
|
||||
InjectedProps,
|
||||
OperationType,
|
||||
PriceListImportOperation,
|
||||
PriceListImportOperationPrice,
|
||||
ParsedPriceListImportPrice,
|
||||
PriceListImportBatchJob,
|
||||
PriceListImportCsvSchema,
|
||||
TBuiltPriceListImportLine,
|
||||
TParsedPriceListImportRowData,
|
||||
} from "./types"
|
||||
|
||||
/*
|
||||
* Default strategy class used for a batch import of products/variants.
|
||||
*/
|
||||
class PriceListImportStrategy extends AbstractBatchJobStrategy {
|
||||
static identifier = "price-list-import-strategy"
|
||||
|
||||
static batchType = "price-list-import"
|
||||
|
||||
private processedCounter: Record<string, number> = {}
|
||||
|
||||
protected manager_: EntityManager
|
||||
protected transactionManager_: EntityManager | undefined
|
||||
|
||||
protected readonly fileService_: IFileService
|
||||
|
||||
protected readonly regionService_: RegionService
|
||||
protected readonly priceListService_: PriceListService
|
||||
protected readonly batchJobService_: BatchJobService
|
||||
protected readonly productVariantService_: ProductVariantService
|
||||
|
||||
protected readonly csvParser_: CsvParser<
|
||||
PriceListImportCsvSchema,
|
||||
Record<string, string>,
|
||||
Record<string, string>
|
||||
>
|
||||
|
||||
constructor({
|
||||
batchJobService,
|
||||
productVariantService,
|
||||
priceListService,
|
||||
regionService,
|
||||
fileService,
|
||||
manager,
|
||||
}: InjectedProps) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
super(arguments[0])
|
||||
|
||||
this.csvParser_ = new CsvParser(CSVSchema)
|
||||
|
||||
this.manager_ = manager
|
||||
this.fileService_ = fileService
|
||||
this.batchJobService_ = batchJobService
|
||||
this.priceListService_ = priceListService
|
||||
this.productVariantService_ = productVariantService
|
||||
this.regionService_ = regionService
|
||||
}
|
||||
|
||||
async buildTemplate(): Promise<string> {
|
||||
throw new Error("Not implemented!")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: TParsedPriceListImportRowData,
|
||||
errorDescription?: string
|
||||
): never {
|
||||
const message = `Error while processing row with:
|
||||
variant ID: ${row[PriceListRowKeys.VARIANT_ID]},
|
||||
variant SKU: ${row[PriceListRowKeys.VARIANT_SKU]},
|
||||
${errorDescription}`
|
||||
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, message)
|
||||
}
|
||||
|
||||
async prepareBatchJobForProcessing(
|
||||
batchJob: CreateBatchJobInput,
|
||||
reqContext: any
|
||||
): Promise<CreateBatchJobInput> {
|
||||
const manager = this.transactionManager_ ?? this.manager_
|
||||
|
||||
if (!batchJob.context?.price_list_id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Price list id is required"
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that PriceList exists
|
||||
const priceListId = batchJob.context.price_list_id as string
|
||||
await this.priceListService_.withTransaction(manager).retrieve(priceListId)
|
||||
|
||||
return batchJob
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate instructions for creation of prices from parsed CSV rows.
|
||||
*
|
||||
* @param priceListId - the ID of the price list where the prices will be created
|
||||
* @param csvData - An array of parsed CSV rows.
|
||||
*/
|
||||
async getImportInstructions(
|
||||
priceListId: string,
|
||||
csvData: TParsedPriceListImportRowData[]
|
||||
): Promise<Record<OperationType, PriceListImportOperation[]>> {
|
||||
// Validate that PriceList exists
|
||||
const manager = this.transactionManager_ ?? this.manager_
|
||||
await this.priceListService_.withTransaction(manager).retrieve(priceListId)
|
||||
|
||||
const pricesToCreate: PriceListImportOperation[] = []
|
||||
|
||||
for (const row of csvData) {
|
||||
let variantId = row[PriceListRowKeys.VARIANT_ID]
|
||||
|
||||
if (!variantId) {
|
||||
if (!row[PriceListRowKeys.VARIANT_SKU]) {
|
||||
PriceListImportStrategy.throwDescriptiveError(
|
||||
row,
|
||||
"SKU or ID is required"
|
||||
)
|
||||
}
|
||||
|
||||
const variant = await this.productVariantService_.retrieveBySKU(
|
||||
`${row[PriceListRowKeys.VARIANT_SKU]}`,
|
||||
{
|
||||
select: ["id"],
|
||||
}
|
||||
)
|
||||
variantId = variant.id
|
||||
} else {
|
||||
// Validate that product exists
|
||||
await this.productVariantService_.retrieve(`${variantId}`, {
|
||||
select: ["id"],
|
||||
})
|
||||
}
|
||||
|
||||
const pricesOperationData = await this.prepareVariantPrices(
|
||||
row[PriceListRowKeys.PRICES] as ParsedPriceListImportPrice[]
|
||||
)
|
||||
|
||||
pricesToCreate.push({
|
||||
variant_id: `${variantId}`,
|
||||
prices: pricesOperationData,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
[OperationType.PricesCreate]: pricesToCreate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare prices records for insert - find and append region ids to records that contain a region name.
|
||||
*
|
||||
* @param prices - the parsed prices to prepare
|
||||
* @returns the prepared prices. All prices have amount in DB format, currency_code and if applicable region_id.
|
||||
*/
|
||||
protected async prepareVariantPrices(
|
||||
prices: ParsedPriceListImportPrice[]
|
||||
): Promise<PriceListImportOperationPrice[]> {
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
|
||||
const operationalPrices: PriceListImportOperationPrice[] = []
|
||||
|
||||
for (const price of prices) {
|
||||
const record: Partial<PriceListImportOperationPrice> = {
|
||||
amount: price.amount,
|
||||
}
|
||||
|
||||
if ("region_name" in price) {
|
||||
try {
|
||||
const region = await this.regionService_
|
||||
.withTransaction(transactionManager)
|
||||
.retrieveByName(price.region_name)
|
||||
|
||||
record.region_id = region.id
|
||||
record.currency_code = region.currency_code
|
||||
} catch (e) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Trying to set a price for a region ${price.region_name} that doesn't exist`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// TODO: Verify that currency is activated for store
|
||||
record.currency_code = price.currency_code
|
||||
}
|
||||
|
||||
record.amount = computerizeAmount(record.amount, record.currency_code)
|
||||
|
||||
operationalPrices.push(record as PriceListImportOperationPrice)
|
||||
}
|
||||
|
||||
return operationalPrices
|
||||
}
|
||||
|
||||
/**
|
||||
* A worker method called after a batch job has been created.
|
||||
* The method parses a CSV file, generates sets of instructions
|
||||
* for processing and stores these instructions to a JSON file
|
||||
* which is uploaded to a bucket.
|
||||
*
|
||||
* @param batchJobId - An id of a job that is being preprocessed.
|
||||
*/
|
||||
async preProcessBatchJob(batchJobId: string): Promise<void> {
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
const batchJob = (await this.batchJobService_
|
||||
.withTransaction(transactionManager)
|
||||
.retrieve(batchJobId)) as PriceListImportBatchJob
|
||||
|
||||
const csvFileKey = batchJob.context.fileKey
|
||||
const priceListId = batchJob.context.price_list_id
|
||||
const csvStream = await this.fileService_.getDownloadStream({
|
||||
fileKey: csvFileKey,
|
||||
})
|
||||
|
||||
let builtData: Record<string, string>[]
|
||||
try {
|
||||
const parsedData = await this.csvParser_.parse(csvStream)
|
||||
builtData = await this.csvParser_.buildData(parsedData)
|
||||
} catch (e) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"The csv file parsing failed due to: " + e.message
|
||||
)
|
||||
}
|
||||
|
||||
const ops = await this.getImportInstructions(priceListId, builtData)
|
||||
|
||||
await this.uploadImportOpsFile(batchJobId, ops)
|
||||
|
||||
let totalOperationCount = 0
|
||||
const operationsCounts = {}
|
||||
Object.keys(ops).forEach((key) => {
|
||||
operationsCounts[key] = ops[key].length
|
||||
totalOperationCount += ops[key].length
|
||||
})
|
||||
|
||||
await this.batchJobService_
|
||||
.withTransaction(transactionManager)
|
||||
.update(batchJobId, {
|
||||
result: {
|
||||
advancement_count: 0,
|
||||
// number of update/create operations to execute
|
||||
count: totalOperationCount,
|
||||
operations: operationsCounts,
|
||||
stat_descriptors: [
|
||||
{
|
||||
key: "price-list-import-count",
|
||||
name: "PriceList to import",
|
||||
message: `${
|
||||
ops[OperationType.PricesCreate].length
|
||||
} prices will be added`,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The main processing method called after a batch job
|
||||
* is ready/confirmed for processing.
|
||||
*
|
||||
* @param batchJobId - An id of a batch job that is being processed.
|
||||
*/
|
||||
async processJob(batchJobId: string): Promise<void> {
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
const batchJob = (await this.batchJobService_
|
||||
.withTransaction(manager)
|
||||
.retrieve(batchJobId)) as PriceListImportBatchJob
|
||||
|
||||
const priceListId = batchJob.context.price_list_id
|
||||
const txPriceListService = this.priceListService_.withTransaction(manager)
|
||||
|
||||
// Delete Existing prices for price list
|
||||
await txPriceListService.clearPrices(priceListId)
|
||||
|
||||
// Upload new prices for price list
|
||||
const priceImportOperations = await this.downloadImportOpsFile(
|
||||
batchJobId,
|
||||
OperationType.PricesCreate
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
priceImportOperations.map(async (op) => {
|
||||
await txPriceListService.addPrices(
|
||||
priceListId,
|
||||
op.prices.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
variant_id: op.variant_id,
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
await this.finalize(batchJob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Store import ops JSON file to a bucket.
|
||||
*
|
||||
* @param batchJobId - An id of the current batch job being processed.
|
||||
* @param results - An object containing parsed CSV data.
|
||||
*/
|
||||
protected async uploadImportOpsFile(
|
||||
batchJobId: string,
|
||||
results: Record<OperationType, PriceListImportOperation[]>
|
||||
): Promise<void> {
|
||||
const uploadPromises: Promise<void>[] = []
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
|
||||
for (const op in results) {
|
||||
if (results[op]?.length) {
|
||||
const { writeStream, promise } = await this.fileService_
|
||||
.withTransaction(transactionManager)
|
||||
.getUploadStreamDescriptor({
|
||||
name: PriceListImportStrategy.buildFilename(batchJobId, op),
|
||||
ext: "json",
|
||||
})
|
||||
|
||||
uploadPromises.push(promise)
|
||||
|
||||
writeStream.write(JSON.stringify(results[op]))
|
||||
writeStream.end()
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(uploadPromises)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove parsed ops JSON file.
|
||||
*
|
||||
* @param batchJobId - An id of the current batch job being processed.
|
||||
* @param op - Type of import operation.
|
||||
*/
|
||||
protected async downloadImportOpsFile(
|
||||
batchJobId: string,
|
||||
op: OperationType
|
||||
): Promise<PriceListImportOperation[]> {
|
||||
let data = ""
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
|
||||
const readableStream = await this.fileService_
|
||||
.withTransaction(transactionManager)
|
||||
.getDownloadStream({
|
||||
fileKey: PriceListImportStrategy.buildFilename(batchJobId, op, {
|
||||
appendExt: ".json",
|
||||
}),
|
||||
})
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
readableStream.on("data", (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
readableStream.on("end", () => {
|
||||
resolve(JSON.parse(data))
|
||||
})
|
||||
readableStream.on("error", () => {
|
||||
// TODO: maybe should throw
|
||||
resolve([] as PriceListImportOperation[])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete parsed CSV ops files.
|
||||
*
|
||||
* @param batchJobId - An id of the current batch job being processed.
|
||||
*/
|
||||
protected async deleteOpsFiles(batchJobId: string): Promise<void> {
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
|
||||
const fileServiceTx = this.fileService_.withTransaction(transactionManager)
|
||||
for (const op of Object.values(OperationType)) {
|
||||
try {
|
||||
await fileServiceTx.delete({
|
||||
fileKey: PriceListImportStrategy.buildFilename(batchJobId, op, {
|
||||
appendExt: ".json",
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update count of processed data in the batch job `result` column
|
||||
* and cleanup temp JSON files.
|
||||
*
|
||||
* @param batchJob - The current batch job being processed.
|
||||
*/
|
||||
private async finalize(batchJob: PriceListImportBatchJob): Promise<void> {
|
||||
const transactionManager = this.transactionManager_ ?? this.manager_
|
||||
|
||||
delete this.processedCounter[batchJob.id]
|
||||
|
||||
await this.batchJobService_
|
||||
.withTransaction(transactionManager)
|
||||
.update(batchJob.id, {
|
||||
result: { advancement_count: batchJob.result.count },
|
||||
})
|
||||
|
||||
const { fileKey } = batchJob.context
|
||||
|
||||
await this.fileService_
|
||||
.withTransaction(transactionManager)
|
||||
.delete({ fileKey })
|
||||
|
||||
await this.deleteOpsFiles(batchJob.id)
|
||||
}
|
||||
|
||||
private static buildFilename(
|
||||
batchJobId: string,
|
||||
operation: string,
|
||||
{ appendExt }: { appendExt?: string } = { appendExt: undefined }
|
||||
): string {
|
||||
const filename = `imports/price-lists/ops/${batchJobId}-${operation}`
|
||||
return appendExt ? filename + appendExt : filename
|
||||
}
|
||||
}
|
||||
|
||||
export default PriceListImportStrategy
|
||||
|
||||
enum PriceListRowKeys {
|
||||
VARIANT_ID = "id",
|
||||
VARIANT_SKU = "sku",
|
||||
PRICES = "prices",
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema definition for the CSV parser.
|
||||
*/
|
||||
const CSVSchema: PriceListImportCsvSchema = {
|
||||
columns: [
|
||||
{
|
||||
name: "Product Variant ID",
|
||||
mapTo: PriceListRowKeys.VARIANT_ID,
|
||||
},
|
||||
{ name: "SKU", mapTo: PriceListRowKeys.VARIANT_SKU },
|
||||
{
|
||||
name: "Price Region",
|
||||
match: /Price (.*) \[([A-Z]{3})\]/,
|
||||
reducer: (
|
||||
builtLine: TBuiltPriceListImportLine,
|
||||
key: string,
|
||||
value: string
|
||||
): TBuiltPriceListImportLine => {
|
||||
builtLine[PriceListRowKeys.PRICES] =
|
||||
builtLine[PriceListRowKeys.PRICES] || []
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return builtLine
|
||||
}
|
||||
|
||||
const [, regionName] =
|
||||
key.trim().match(/Price (.*) \[([A-Z]{3})\]/) || []
|
||||
builtLine[PriceListRowKeys.PRICES].push({
|
||||
amount: parseFloat(value),
|
||||
region_name: regionName,
|
||||
})
|
||||
|
||||
return builtLine
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Price Currency",
|
||||
match: /Price [A-Z]{3}/,
|
||||
reducer: (
|
||||
builtLine: TBuiltPriceListImportLine,
|
||||
key: string,
|
||||
value: string
|
||||
): TBuiltPriceListImportLine => {
|
||||
builtLine[PriceListRowKeys.PRICES] =
|
||||
builtLine[PriceListRowKeys.PRICES] || []
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return builtLine
|
||||
}
|
||||
|
||||
const currency = key.trim().split(" ")[1]
|
||||
builtLine[PriceListRowKeys.PRICES].push({
|
||||
amount: parseFloat(value),
|
||||
currency_code: currency.toLowerCase(),
|
||||
})
|
||||
|
||||
return builtLine
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EntityManager } from "typeorm"
|
||||
import { FileService } from "medusa-interfaces"
|
||||
|
||||
import {
|
||||
BatchJobService,
|
||||
PriceListService,
|
||||
ProductVariantService,
|
||||
RegionService,
|
||||
} from "../../../services"
|
||||
import { CsvSchema } from "../../../interfaces/csv-parser"
|
||||
import { BatchJob } from "../../../models"
|
||||
|
||||
export type PriceListImportBatchJob = BatchJob & {
|
||||
context: PriceListImportJobContext
|
||||
result: Pick<BatchJob, "result"> & {
|
||||
operations: {
|
||||
[K in keyof typeof OperationType]: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DI props for the Product import strategy
|
||||
*/
|
||||
export type InjectedProps = {
|
||||
priceListService: PriceListService
|
||||
batchJobService: BatchJobService
|
||||
productVariantService: ProductVariantService
|
||||
regionService: RegionService
|
||||
fileService: typeof FileService
|
||||
manager: EntityManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Data shape returned by the CSVParser.
|
||||
*/
|
||||
export type TParsedPriceListImportRowData = Record<
|
||||
string,
|
||||
string | number | (string | number | object)[]
|
||||
>
|
||||
|
||||
export type PriceListImportOperationPrice = {
|
||||
region_id?: string
|
||||
currency_code: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export type PriceListImportOperation = {
|
||||
variant_id: string
|
||||
prices: PriceListImportOperationPrice[]
|
||||
}
|
||||
|
||||
export type ParsedPriceListImportPrice =
|
||||
| {
|
||||
amount: number
|
||||
currency_code: string
|
||||
}
|
||||
| {
|
||||
amount: number
|
||||
region_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV parser's row reducer result data shape.
|
||||
*/
|
||||
export type TBuiltPriceListImportLine = Record<string, any>
|
||||
|
||||
/**
|
||||
* Schema definition of for an import CSV file.
|
||||
*/
|
||||
export type PriceListImportCsvSchema = CsvSchema<
|
||||
TParsedPriceListImportRowData,
|
||||
TBuiltPriceListImportLine
|
||||
>
|
||||
|
||||
/**
|
||||
* Import Batch job context column type.
|
||||
*/
|
||||
export type PriceListImportJobContext = {
|
||||
price_list_id: string
|
||||
fileKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported batch job import ops.
|
||||
*/
|
||||
export enum OperationType {
|
||||
PricesCreate = "PRICE_LIST_PRICE_CREATE",
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable valid-jsdoc */
|
||||
import { EntityManager } from "typeorm"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { computerizeAmount, MedusaError } from "medusa-core-utils"
|
||||
|
||||
import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces"
|
||||
import CsvParser from "../../../services/csv-parser"
|
||||
@@ -198,11 +198,12 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
|
||||
if (price.regionName) {
|
||||
try {
|
||||
record.region_id = (
|
||||
await this.regionService_
|
||||
.withTransaction(transactionManager)
|
||||
.retrieveByName(price.regionName)
|
||||
)?.id
|
||||
const region = await this.regionService_
|
||||
.withTransaction(transactionManager)
|
||||
.retrieveByName(price.regionName)
|
||||
|
||||
record.region_id = region.id
|
||||
record.currency_code = region.currency_code
|
||||
} catch (e) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
@@ -213,6 +214,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
|
||||
record.currency_code = price.currency_code
|
||||
}
|
||||
|
||||
record.amount = computerizeAmount(record.amount, record.currency_code)
|
||||
prices.push(record)
|
||||
}
|
||||
|
||||
@@ -845,7 +847,7 @@ const CSVSchema: ProductImportCsvSchema = {
|
||||
// PRICES
|
||||
{
|
||||
name: "Price Region",
|
||||
match: /Price .* \[([A-Z]{2,4})\]/,
|
||||
match: /Price (.*) \[([A-Z]{3})\]/,
|
||||
reducer: (
|
||||
builtLine: TParsedProductImportRowData,
|
||||
key,
|
||||
@@ -857,11 +859,12 @@ const CSVSchema: ProductImportCsvSchema = {
|
||||
return builtLine
|
||||
}
|
||||
|
||||
const regionName = key.split(" ")[1]
|
||||
const [, regionName] =
|
||||
key.trim().match(/Price (.*) \[([A-Z]{3})\]/) || []
|
||||
;(
|
||||
builtLine["variant.prices"] as Record<string, string | number>[]
|
||||
).push({
|
||||
amount: value,
|
||||
amount: parseFloat(value),
|
||||
regionName,
|
||||
})
|
||||
|
||||
@@ -870,7 +873,7 @@ const CSVSchema: ProductImportCsvSchema = {
|
||||
},
|
||||
{
|
||||
name: "Price Currency",
|
||||
match: /Price [A-Z]{2,4}/,
|
||||
match: /Price [A-Z]{3}/,
|
||||
reducer: (
|
||||
builtLine: TParsedProductImportRowData,
|
||||
key,
|
||||
@@ -882,11 +885,12 @@ const CSVSchema: ProductImportCsvSchema = {
|
||||
return builtLine
|
||||
}
|
||||
|
||||
const currency = key.split(" ")[1]
|
||||
const currency = key.trim().split(" ")[1]
|
||||
|
||||
;(
|
||||
builtLine["variant.prices"] as Record<string, string | number>[]
|
||||
).push({
|
||||
amount: value,
|
||||
amount: parseFloat(value),
|
||||
currency_code: currency,
|
||||
})
|
||||
|
||||
@@ -962,3 +966,4 @@ const SalesChannelsSchema: ProductImportCsvSchema = {
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class BatchJobSubscriber {
|
||||
.preProcessBatchJob(batchJob.id)
|
||||
await batchJobServiceTx.setPreProcessingDone(batchJob.id)
|
||||
} catch (e) {
|
||||
await this.batchJobService_.setFailed(batchJob.id)
|
||||
await this.batchJobService_.setFailed(batchJob.id, e.message)
|
||||
throw e
|
||||
}
|
||||
})
|
||||
@@ -68,7 +68,7 @@ class BatchJobSubscriber {
|
||||
await batchJobStrategy.withTransaction(manager).processJob(batchJob.id)
|
||||
await batchJobServiceTx.complete(batchJob.id)
|
||||
} catch (e) {
|
||||
await this.batchJobService_.setFailed(batchJob.id)
|
||||
await this.batchJobService_.setFailed(batchJob.id, e.message)
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user