Skip to content

Commit

Permalink
feat(medusa): PriceList import strategy (#2210)
Browse files Browse the repository at this point in the history
  • Loading branch information
srindom committed Sep 28, 2022
1 parent 884f36e commit 7dc8d3a
Show file tree
Hide file tree
Showing 17 changed files with 1,129 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/green-snakes-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": minor
---

Adds a BatchJob strategy for importing prices to PriceLists
5 changes: 5 additions & 0 deletions .changeset/lazy-apes-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"medusa-core-utils": minor
---

Adds `computerizeAmount` utility to convert human money format into the DB format Medusa uses (integer of lowest currency unit)
290 changes: 290 additions & 0 deletions integration-tests/api/__tests__/batch-jobs/price-list/import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
const fs = require("fs")
const path = require("path")

const setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../../helpers/use-db")

const adminSeeder = require("../../../helpers/admin-seeder")
const {
simpleRegionFactory,
simplePriceListFactory,
simpleProductFactory,
} = require("../../../factories")

const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}

jest.setTimeout(1000000)

function cleanTempData() {
// cleanup tmp ops files
const opsFiles = path.resolve(
"__tests__",
"batch-jobs",
"price-list",
"imports"
)

fs.rmSync(opsFiles, { recursive: true, force: true })
}

function getImportFile() {
return path.resolve(
"__tests__",
"batch-jobs",
"price-list",
"price-list-import.csv"
)
}

function copyTemplateFile() {
const csvTemplate = path.resolve(
"__tests__",
"batch-jobs",
"price-list",
"price-list-import-template.csv"
)
const destination = getImportFile()
fs.copyFileSync(csvTemplate, destination)
}

describe("Price list import batch job", () => {
let medusaProcess
let dbConnection

beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd })

cleanTempData() // cleanup if previous process didn't manage to do it

medusaProcess = await setupServer({
cwd,
redisUrl: "redis://127.0.0.1:6379",
uploadDir: __dirname,
verbose: false,
})
})

afterAll(async () => {
const db = useDb()
await db.shutdown()

cleanTempData()

medusaProcess.kill()
})

beforeEach(async () => {
await adminSeeder(dbConnection)
})

afterEach(async () => {
const db = useDb()
await db.teardown()
})

it("should import a csv file", async () => {
jest.setTimeout(1000000)
const api = useApi()

copyTemplateFile()

const product = await simpleProductFactory(dbConnection, {
variants: [
{
id: "test-pl-variant",
},
{
id: "test-pl-sku-variant",
sku: "pl-sku",
},
],
})

await simpleRegionFactory(dbConnection, {
id: "test-pl-region",
name: "PL Region",
currency_code: "eur",
})

const priceList = await simplePriceListFactory(dbConnection, {
id: "pl_my_price_list",
name: "Test price list",
prices: [
{
variant_id: product.variants[0].id,
currency_code: "usd",
amount: 1000,
},
{
variant_id: product.variants[0].id,
currency_code: "eur",
amount: 2080,
},
],
})

const response = await api.post(
"/admin/batch-jobs",
{
type: "price-list-import",
context: {
price_list_id: priceList.id,
fileKey: "price-list-import.csv",
},
},
adminReqConfig
)

const batchJobId = response.data.batch_job.id

expect(batchJobId).toBeTruthy()

// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)

await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})

batchJob = res.data.batch_job

shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}

expect(batchJob.status).toBe("completed")

const priceListRes = await api.get(
"/admin/price-lists/pl_my_price_list",
adminReqConfig
)

// Verify that file service deleted file
const importFilePath = getImportFile()
expect(fs.existsSync(importFilePath)).toBe(false)

expect(priceListRes.data.price_list.prices.length).toEqual(5)
expect(priceListRes.data.price_list.prices).toEqual(
expect.arrayContaining([
expect.objectContaining({
variant_id: "test-pl-variant",
currency_code: "usd",
amount: 1111,
}),
expect.objectContaining({
variant_id: "test-pl-variant",
currency_code: "eur",
region_id: "test-pl-region",
amount: 2222,
}),
expect.objectContaining({
variant_id: "test-pl-variant",
currency_code: "jpy",
amount: 3333,
}),
expect.objectContaining({
variant_id: "test-pl-sku-variant",
currency_code: "usd",
amount: 4444,
}),
expect.objectContaining({
variant_id: "test-pl-sku-variant",
currency_code: "eur",
region_id: "test-pl-region",
amount: 5555,
}),
])
)
})

it("should fail with invalid import format", async () => {
jest.setTimeout(1000000)
const api = useApi()

const product = await simpleProductFactory(dbConnection, {
variants: [
{ id: "test-pl-variant" },
{ id: "test-pl-sku-variant", sku: "pl-sku" },
],
})

await simpleRegionFactory(dbConnection, {
id: "test-pl-region",
name: "PL Region",
currency_code: "eur",
})

const priceList = await simplePriceListFactory(dbConnection, {
id: "pl_my_price_list",
name: "Test price list",
prices: [
{
variant_id: product.variants[0].id,
currency_code: "usd",
amount: 1000,
},
{
variant_id: product.variants[0].id,
currency_code: "eur",
amount: 2080,
},
],
})

const response = await api.post(
"/admin/batch-jobs",
{
type: "price-list-import",
context: {
price_list_id: priceList.id,
fileKey: "invalid-format.csv",
},
},
adminReqConfig
)

const batchJobId = response.data.batch_job.id

expect(batchJobId).toBeTruthy()

// Pull to check the status until it is completed
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)

await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})

batchJob = res.data.batch_job

shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}

expect(batchJob.status).toBe("failed")
expect(batchJob.result).toEqual({
errors: [
"The csv file parsing failed due to: Unable to treat column non-descript-column from the csv file. No target column found in the provided schema",
],
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
non-descript-column,SKU,Price USD,Price PL Region [EUR], Price JPY
test-pl-variant,,11.11,22.22,3333
,pl-sku,44.441,55.55,
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Product Variant ID,SKU,Price USD,Price PL Region [EUR], Price JPY
test-pl-variant,,11.11,22.22,3333
,pl-sku,44.441,55.55,
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Product id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External ID,Product Profile Name,Product Profile Type,Variant id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow backorder,Variant Manage inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url,Sales Channel 1 Name,Sales Channel 2 Name,Sales Channel 1 Id,Sales Channel 2 Id
O6S1YQ6mKm,test-product-product-1,Test product,,test-product-description-1,draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,profile_1,profile_type_1,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,100,110,130,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png,Import Sales Channel 1,Import Sales Channel 2,,
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,110,test-option,Option 1 value 1,,,test-image.png,,,,
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,120,,,test-option,Option 1 Value blue,,,test-image.png,,,,
O6S1YQ6mKm,test-product-product-1,Test product,,test-product-description-1,draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,profile_1,profile_type_1,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png,Import Sales Channel 1,Import Sales Channel 2,,
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,test-option,Option 1 value 1,,,test-image.png,,,,
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,test-option,Option 1 Value blue,,,test-image.png,,,,
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Product id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External ID,Product Profile Name,Product Profile Type,Variant id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow backorder,Variant Manage inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url
O6S1YQ6mKm,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,profile_1,profile_type_1,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,100,110,130,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,110,test-option,Option 1 value 1,,,test-image.png
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,120,,,test-option,Option 1 Value blue,,,test-image.png
O6S1YQ6mKm,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,profile_1,profile_type_1,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,test-option,Option 1 value 1,,,test-image.png
5VxiEkmnPV,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,profile_2,profile_type_2,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,test-option,Option 1 Value blue,,,test-image.png
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ProductVariantFactoryData = {
product_id: string
id?: string
is_giftcard?: boolean
sku?: string
inventory_quantity?: number
title?: string
options?: { option_id: string; value: string }[]
Expand All @@ -31,6 +32,7 @@ export const simpleProductVariantFactory = async (
const toSave = manager.create(ProductVariant, {
id,
product_id: data.product_id,
sku: data.sku ?? null,
inventory_quantity:
typeof data.inventory_quantity !== "undefined"
? data.inventory_quantity
Expand Down
13 changes: 13 additions & 0 deletions packages/medusa-core-utils/src/computerize-amount.js
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/medusa-core-utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"

1 change: 1 addition & 0 deletions packages/medusa/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions packages/medusa/src/services/price-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 7dc8d3a

Please sign in to comment.