fix(pricing, medusa): resolve minor pricing-module bugs (#5685)

* fix region price updates

* pricing migration: include title/name field value migration

* update migration scripts for pricing module

* move file to script utils

* rename file

* rename file

* add changeset

* remove redundant maps

* update migration script

* nit

* remove unnecessary variable

* filter before map

* array function naming cleanup

* chore: address pr reviews

---------

Co-authored-by: Philip Korsholm <philip.korsholm@hotmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2023-11-23 17:11:47 +01:00
committed by GitHub
parent 02ea9ac3ac
commit 1e39a95f8a
9 changed files with 404 additions and 337 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/workflows": patch
"@medusajs/pricing": patch
"@medusajs/medusa": patch
---
fix(workflows, pricing, medusa): update region variants fix + pricing module migration scripts

View File

@@ -1,40 +1,10 @@
import { AwilixContainer } from "awilix"
import { IPricingModuleService } from "@medusajs/types"
import { createDefaultRuleTypes } from "./utils/create-default-rule-types"
import dotenv from "dotenv"
import express from "express"
import loaders from "../loaders"
dotenv.config()
export const createDefaultRuleTypes = async (container: AwilixContainer) => {
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const existing = await pricingModuleService.listRuleTypes(
{ rule_attribute: ["region_id", "customer_group_id"] },
{ take: 2 }
)
if (existing.length === 2) {
return
}
if (existing.length === 0) {
await pricingModuleService.createRuleTypes([
{ name: "region_id", rule_attribute: "region_id" },
{ name: "customer_group_id", rule_attribute: "customer_group_id" },
])
} else if (existing[0].rule_attribute === "region_id") {
await pricingModuleService.createRuleTypes([
{ name: "customer_group_id", rule_attribute: "customer_group_id" },
])
} else {
await pricingModuleService.createRuleTypes([
{ name: "region_id", rule_attribute: "region_id" },
])
}
}
const migrate = async function ({ directory }) {
const app = express()
const { container } = await loaders({

View File

@@ -1,146 +0,0 @@
import { IPricingModuleService, PricingTypes } from "@medusajs/types"
import { AwilixContainer } from "awilix"
import dotenv from "dotenv"
import express from "express"
import loaders from "../loaders"
import Logger from "../loaders/logger"
import { PriceListService } from "../services"
dotenv.config()
const BATCH_SIZE = 1000
export const migratePriceLists = async (container: AwilixContainer) => {
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const priceListCoreService: PriceListService =
container.resolve("priceListService")
const remoteQuery = container.resolve("remoteQuery")
const existingRuleTypes = await pricingModuleService.listRuleTypes(
{ rule_attribute: ["customer_group_id"] },
{ take: 2 }
)
if (existingRuleTypes.length === 0) {
Logger.info(
`Run default rules migration before running this migration - node node_modules/@medusajs/medusa/dist/scripts/create-default-rule-types.js`
)
return
}
let offset = 0
let arePriceListsAvailable = true
while (arePriceListsAvailable) {
const priceLists = await priceListCoreService.list(
{},
{
take: BATCH_SIZE,
skip: offset,
relations: ["customer_groups"],
}
)
if (priceLists.length === 0) {
break
}
offset += BATCH_SIZE
await pricingModuleService.update(
priceLists.map((priceList) => {
const updateData: PricingTypes.UpdatePriceListDTO = {
id: priceList.id,
title: priceList.name,
}
if (priceList?.customer_groups?.length) {
updateData.rules = {
customer_group_id: priceList.customer_groups.map((cg) => cg.id),
}
}
return updateData
})
)
for (const priceList of priceLists) {
let productsOffset = 0
let areVariantsAvailable = true
while (areVariantsAvailable) {
const [priceListVariants, variantsCount] =
await priceListCoreService.listVariants(
priceList.id,
{},
{
skip: productsOffset,
take: BATCH_SIZE,
}
)
if (variantsCount === 0) {
break
}
productsOffset += BATCH_SIZE
const query = {
product_variant_price_set: {
__args: {
variant_id: priceListVariants.map((plv) => plv.id),
},
fields: ["variant_id", "price_set_id"],
},
}
const variantPriceSets = await remoteQuery(query)
const variantPriceSetMap = new Map<string, string>(
variantPriceSets.map((mps) => [mps.variant_id, mps.price_set_id])
)
const variantPrices = priceListVariants
.map((plv) => plv.prices || [])
.flat()
await pricingModuleService.addPriceListPrices([
{
priceListId: priceList.id,
prices: variantPrices.map((vp) => {
return {
id: vp.id,
price_set_id: variantPriceSetMap.get(vp.variant_id)!,
currency_code: vp.currency_code,
amount: vp.amount,
}
}),
},
])
}
}
}
}
const migrate = async function ({ directory }) {
const app = express()
const { container } = await loaders({
directory,
expressApp: app,
isTest: false,
})
return await migratePriceLists(container)
}
migrate({ directory: process.cwd() })
.then(() => {
console.log("Migrated price lists")
})
.catch(() => {
console.log("Failed to migrate price lists")
})

View File

@@ -0,0 +1,250 @@
import { IPricingModuleService, PricingTypes } from "@medusajs/types"
import { promiseAll } from "@medusajs/utils"
import { AwilixContainer } from "awilix"
import dotenv from "dotenv"
import express from "express"
import loaders from "../loaders"
import Logger from "../loaders/logger"
import { PriceList } from "../models"
import { CurrencyService, PriceListService } from "../services"
import { createDefaultRuleTypes } from "./utils/create-default-rule-types"
import { migrateProductVariantPricing } from "./utils/migrate-money-amounts-to-pricing-module"
dotenv.config()
const BATCH_SIZE = 1000
const migratePriceLists = async (container: AwilixContainer) => {
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
let offset = 0
const priceListCoreService: PriceListService =
container.resolve("priceListService")
const remoteQuery = container.resolve("remoteQuery")
const [_, totalCount] = await priceListCoreService.listAndCount(
{},
{ select: ["id"] }
)
while (offset < totalCount) {
const corePriceLists = await priceListCoreService.list(
{},
{
take: BATCH_SIZE,
skip: offset,
relations: ["customer_groups", "prices", "prices.variants"],
}
)
const pricingModulePriceLists = await pricingModuleService.listPriceLists(
{ id: corePriceLists.map(({ id }) => id) },
{
take: BATCH_SIZE,
skip: offset,
select: ["id"],
}
)
const priceListIdsToUpdateSet = new Set<string>(
pricingModulePriceLists.map(({ id }) => id)
)
const priceListsToCreate: PriceList[] = []
const priceListsToUpdate: PriceList[] = []
const variantIds: string[] = []
for (const corePriceList of corePriceLists) {
if (priceListIdsToUpdateSet.has(corePriceList.id)) {
priceListsToCreate.push(corePriceList)
} else {
priceListsToUpdate.push(corePriceList)
}
const corePrices = corePriceList.prices || []
variantIds.push(
...corePrices.map((corePrice) => corePrice.variants?.[0]?.id)
)
}
const query = {
product_variant_price_set: {
__args: {
variant_id: variantIds,
},
fields: ["variant_id", "price_set_id"],
},
}
const variantPriceSets = await remoteQuery(query)
const variantIdPriceSetIdMap = new Map<string, string>(
variantPriceSets.map((vps) => [vps.variant_id, vps.price_set_id])
)
const promises: Promise<any>[] = []
if (priceListsToUpdate.length) {
await pricingModuleService.updatePriceLists(
priceListsToUpdate.map((priceList) => {
const updateData: PricingTypes.UpdatePriceListDTO = {
id: priceList.id,
title: priceList.name,
}
if (priceList?.customer_groups?.length) {
updateData.rules = {
customer_group_id: priceList.customer_groups.map(({ id }) => id),
}
}
return updateData
})
)
promises.push(
pricingModuleService.addPriceListPrices(
priceListsToUpdate.map((priceList) => {
return {
priceListId: priceList.id,
prices: priceList.prices
.filter((price) =>
variantIdPriceSetIdMap.has(price.variants?.[0]?.id)
)
.map((price) => {
return {
price_set_id: variantIdPriceSetIdMap.get(
price.variants?.[0]?.id
)!,
currency_code: price.currency_code,
amount: price.amount,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
}
}),
}
})
)
)
}
if (priceListsToCreate.length) {
promises.push(
pricingModuleService.createPriceLists(
priceListsToCreate.map(
({ name: title, prices, customer_groups, ...priceList }) => {
const createData: PricingTypes.CreatePriceListDTO = {
...priceList,
starts_at: priceList.starts_at?.toISOString(),
ends_at: priceList.ends_at?.toISOString(),
title,
}
if (customer_groups?.length) {
createData.rules = {
customer_group_id: customer_groups.map(({ id }) => id),
}
}
if (prices?.length) {
createData.prices = prices.map((price) => {
return {
price_set_id: variantIdPriceSetIdMap.get(
price.variants?.[0]?.id
)!,
currency_code: price.currency_code,
amount: price.amount,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
}
})
}
return createData
}
)
)
)
}
await promiseAll(promises)
offset += corePriceLists.length
Logger.info(`Processed ${offset} of ${totalCount}`)
}
}
const ensureCurrencies = async (container: AwilixContainer) => {
const currenciesService: CurrencyService =
container.resolve("currencyService")
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const [coreCurrencies, totalCurrencies] =
await currenciesService.listAndCount({}, {})
const moduleCurrencies = await pricingModuleService.listCurrencies(
{},
{ take: 100000 }
)
const moduleCurrenciesSet = new Set(moduleCurrencies.map(({ code }) => code))
const currenciesToCreate = coreCurrencies
.filter(({ code }) => {
return !moduleCurrenciesSet.has(code)
})
.map(({ includes_tax, ...currency }) => currency)
await pricingModuleService.createCurrencies(currenciesToCreate)
}
const migrate = async function ({ directory }) {
const app = express()
const { container } = await loaders({
directory,
expressApp: app,
isTest: false,
})
Logger.info("-----------------------------------------------")
Logger.info("------------- Creating currencies -------------")
Logger.info("-----------------------------------------------")
await ensureCurrencies(container)
Logger.info("-----------------------------------------------")
Logger.info("--------- Creating default rule types ---------")
Logger.info("-----------------------------------------------")
await createDefaultRuleTypes(container)
Logger.info("-----------------------------------------------")
Logger.info("---------- Migrating Variant Prices -----------")
Logger.info("-----------------------------------------------")
await migrateProductVariantPricing(container)
Logger.info("-----------------------------------------------")
Logger.info("----------- Migrating Price Lists -------------")
Logger.info("-----------------------------------------------")
return await migratePriceLists(container)
}
migrate({ directory: process.cwd() })
.then(() => {
Logger.info("Migrated price lists")
process.exit(0)
})
.catch((error) => {
console.warn(error)
Logger.info("Failed to migrate price lists")
process.exit(1)
})

View File

@@ -1,140 +0,0 @@
import { IPricingModuleService, MedusaContainer } from "@medusajs/types"
import {
FlagRouter,
MedusaError,
MedusaV2Flag,
promiseAll,
} from "@medusajs/utils"
import dotenv from "dotenv"
import express from "express"
import { EntityManager } from "typeorm"
import loaders from "../loaders"
import loadMedusaApp from "../loaders/medusa-app"
import { ProductVariant } from "../models"
import { ProductVariantService } from "../services"
import { createDefaultRuleTypes } from "./create-default-rule-types"
dotenv.config()
const BATCH_SIZE = 100
const migrateProductVariant = async (
variant: ProductVariant,
{
container,
}: { container: MedusaContainer; transactionManager: EntityManager }
) => {
const pricingService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const configModule = await container.resolve("configModule")
const { link } = await loadMedusaApp(
{ configModule, container },
{ registerInContainer: false }
)
if (!link) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't migrate money_amounts: Pricing module is not configured correctly"
)
}
const priceSet = await pricingService.create({
rules: [{ rule_attribute: "region_id" }],
prices: variant.prices.map((price) => ({
rules: {
...(price.region_id ? { region_id: price.region_id } : {}),
},
currency_code: price.currency_code,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
})),
})
await link.create({
productService: {
variant_id: variant.id,
},
pricingService: {
price_set_id: priceSet.id,
},
})
}
const processBatch = async (
variants: ProductVariant[],
container: MedusaContainer
) => {
const manager = container.resolve("manager")
return await manager.transaction(async (transactionManager) => {
await promiseAll(
variants.map(async (variant) => {
await migrateProductVariant(variant, {
container,
transactionManager,
})
})
)
})
}
const migrate = async function ({ directory }) {
const app = express()
const { container } = await loaders({
directory,
expressApp: app,
isTest: false,
})
const variantService: ProductVariantService = await container.resolve(
"productVariantService"
)
const featureFlagRouter: FlagRouter = await container.resolve(
"featureFlagRouter"
)
if (!featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Pricing module not enabled"
)
}
await createDefaultRuleTypes(container)
const [variants, totalCount] = await variantService.listAndCount(
{},
{ take: BATCH_SIZE, order: { id: "ASC" }, relations: ["prices"] }
)
await processBatch(variants, container)
let processedCount = variants.length
console.log(`Processed ${processedCount} of ${totalCount}`)
while (processedCount < totalCount) {
const nextBatch = await variantService.list(
{},
{
skip: processedCount,
take: BATCH_SIZE,
order: { id: "ASC" },
relations: ["prices"],
}
)
await processBatch(nextBatch, container)
processedCount += nextBatch.length
console.log(`Processed ${processedCount} of ${totalCount}`)
}
console.log("Done")
process.exit(0)
}
migrate({ directory: process.cwd() })

View File

@@ -0,0 +1,31 @@
import { AwilixContainer } from "awilix"
import { IPricingModuleService } from "@medusajs/types"
export const createDefaultRuleTypes = async (container: AwilixContainer) => {
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const existing = await pricingModuleService.listRuleTypes(
{ rule_attribute: ["region_id", "customer_group_id"] },
{ take: 2 }
)
if (existing.length === 2) {
return
}
if (existing.length === 0) {
await pricingModuleService.createRuleTypes([
{ name: "region_id", rule_attribute: "region_id" },
{ name: "customer_group_id", rule_attribute: "customer_group_id" },
])
} else if (existing[0].rule_attribute === "region_id") {
await pricingModuleService.createRuleTypes([
{ name: "customer_group_id", rule_attribute: "customer_group_id" },
])
} else {
await pricingModuleService.createRuleTypes([
{ name: "region_id", rule_attribute: "region_id" },
])
}
}

View File

@@ -0,0 +1,83 @@
import { IPricingModuleService, MedusaContainer } from "@medusajs/types"
import { MedusaError, promiseAll } from "@medusajs/utils"
import { ProductVariantService } from "../../services"
import dotenv from "dotenv"
dotenv.config()
const BATCH_SIZE = 100
export const migrateProductVariantPricing = async function (
container: MedusaContainer
) {
const variantService: ProductVariantService = await container.resolve(
"productVariantService"
)
const pricingService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const link = await container.resolve("remoteLink")
if (!link) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't migrate money_amounts: Pricing module is not configured correctly"
)
}
const [_, totalCount] = await variantService.listAndCount(
{},
{ take: BATCH_SIZE, order: { id: "ASC" }, relations: ["prices"] }
)
let processedCount = 0
while (processedCount < totalCount) {
const [variants] = await variantService.listAndCount(
{},
{
skip: processedCount,
take: BATCH_SIZE,
order: { id: "ASC" },
relations: ["prices"],
}
)
const links: any[] = []
await promiseAll(
variants.map(async (variant) => {
const priceSet = await pricingService.create({
rules: [{ rule_attribute: "region_id" }],
prices:
variant?.prices
?.filter(({ price_list_id }) => !price_list_id)
.map((price) => ({
rules: {
...(price.region_id ? { region_id: price.region_id } : {}),
},
currency_code: price.currency_code,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
})) ?? [],
})
links.push({
productService: {
variant_id: variant.id,
},
pricingService: {
price_set_id: priceSet.id,
},
})
})
)
await link.create(links)
processedCount += variants.length
console.log(`Processed ${processedCount} of ${totalCount}`)
}
}

View File

@@ -14,13 +14,10 @@ export class Migration20231101232834 extends Migration {
)
this.addSql(
`ALTER TABLE price_list ADD COLUMN IF NOT EXISTS number_rules integer not null default 0`
`ALTER TABLE price_list
ADD COLUMN IF NOT EXISTS number_rules integer not null default 0`
)
this.addSql(
'alter table "price_list" add column if not exists "title" text not null, add column if not exists "description" text not null, add column if not exists "type" text check ("type" in (\'sale\', \'override\')) not null default \'sale\';'
)
this.addSql(
'alter table "price_set_money_amount" drop constraint "price_set_money_amount_price_list_id_foreign";'
)
@@ -30,9 +27,19 @@ export class Migration20231101232834 extends Migration {
)
this.addSql(
'alter table "price_list" add column if not exists "title" text not null, add column if not exists "description" text not null, add column if not exists "type" text check ("type" in (\'sale\', \'override\')) not null default \'sale\', add column if not exists "created_at" timestamptz not null default now(), add column if not exists "updated_at" timestamptz not null default now(), add column if not exists "deleted_at" timestamptz null;'
'alter table "price_list" add column if not exists "title" text, add column if not exists "name" text, add column if not exists "description" text not null, add column if not exists "type" text check ("type" in (\'sale\', \'override\')) not null default \'sale\', add column if not exists "created_at" timestamptz not null default now(), add column if not exists "updated_at" timestamptz not null default now(), add column if not exists "deleted_at" timestamptz null;'
)
this.addSql(`
UPDATE "price_list"
SET title = name
`)
this.addSql(`alter table "price_list"
alter column "title" set not null `)
this.addSql('alter table "price_list" drop column if exists "name";')
this.addSql(
'create index if not exists "IDX_price_list_deleted_at" on "price_list" ("deleted_at");'
)

View File

@@ -78,29 +78,34 @@ export async function upsertVariantPrices({
)
for (const price of prices) {
const region = price.region_id && regionsMap.get(price.region_id)
let region_currency_code: string | undefined
let region_rules: Record<string, string> | undefined
if (region) {
region_currency_code = region.currency_code
region_rules = {
region_id: region.id,
}
}
if (price.id) {
moneyAmountsToUpdate.push({
const priceToUpdate = {
id: price.id,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
currency_code: price.currency_code,
})
currency_code: region_currency_code ?? price.currency_code,
}
moneyAmountsToUpdate.push(priceToUpdate)
} else {
const region = price.region_id && regionsMap.get(price.region_id)
const variantPrice: PricingTypes.CreatePricesDTO = {
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
currency_code: price.currency_code,
rules: {},
}
if (region) {
variantPrice.currency_code = region.currency_code
variantPrice.rules = {
region_id: region.id,
}
currency_code: region_currency_code ?? price.currency_code,
rules: region_rules ?? {},
}
delete price.region_id