feat(pricing, types): add price rule operators to price calculations (#10350)

what:

- adds price rule operators when doing price calculations
- rules now accepts a key where the value can be an array of objects `({ operator: string, value: number })`
  - validation for available types of operator and value to be a number
```
await service.createPriceSets({
  prices: [
    {
      amount: 50,
      currency_code: "usd",
      rules: {
        region_id: "de",
        cart_total: [
          { operator: "gte", value: 400 },
          { operator: "lte", value: 500 },
        ]
      },
    },
  ]
})
```
- price calculations will now account for the operators - lte, gte, lt, gt when the price context is a number

RESOLVES CMRC-747
This commit is contained in:
Riqwan Thamir
2024-11-28 21:48:00 +01:00
committed by GitHub
parent 805fe4b1db
commit 324b4ab438
8 changed files with 462 additions and 33 deletions

View File

@@ -11,7 +11,7 @@ import {
PricingFilters,
PricingRepositoryService,
} from "@medusajs/framework/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Knex, SqlEntityManager } from "@mikro-orm/postgresql"
export class PricingRepository
extends MikroOrmBase
@@ -58,7 +58,7 @@ export class PricingRepository
return []
}
// Gets all the price set money amounts where rules match for each of the contexts
// Gets all the prices where rules match for each of the contexts
// that the price set is configured for
const priceSubQueryKnex = knex({
price: "price",
@@ -90,7 +90,7 @@ export class PricingRepository
.groupBy("price.id", "pl.id")
.having(
knex.raw(
"count(DISTINCT pr.attribute) = price.rules_count AND price.price_list_id IS NULL"
"count(pr.attribute) = price.rules_count AND price.price_list_id IS NULL"
)
)
.orHaving(
@@ -99,17 +99,57 @@ export class PricingRepository
)
)
priceSubQueryKnex.orWhere((q) => {
const nullPLq = q.whereNull("price.price_list_id")
nullPLq.andWhere((q) => {
for (const [key, value] of Object.entries(context)) {
q.orWhere({
"pr.attribute": key,
"pr.value": value,
})
}
q.orWhere("price.rules_count", "=", 0)
})
const buildOperatorQueries = (
operatorGroupBuilder: Knex.QueryBuilder,
value
) => {
operatorGroupBuilder
.where((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "gte")
.whereRaw("? >= pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "gt")
.whereRaw("? > pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "lt")
.whereRaw("? < pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "lte")
.whereRaw("? <= pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "eq")
.whereRaw("? = pr.value::numeric", [value])
})
}
priceSubQueryKnex.orWhere((priceBuilder) => {
priceBuilder
.whereNull("price.price_list_id")
.andWhere((withoutPriceListBuilder) => {
for (const [key, value] of Object.entries(context)) {
withoutPriceListBuilder.orWhere((orBuilder) => {
orBuilder.where("pr.attribute", key)
if (typeof value === "number") {
orBuilder.where((operatorGroupBuilder) => {
buildOperatorQueries(operatorGroupBuilder, value)
})
} else {
orBuilder.where({ "pr.value": value })
}
})
}
withoutPriceListBuilder.orWhere("price.rules_count", "=", 0)
})
})
priceSubQueryKnex.orWhere((q) => {
@@ -132,9 +172,7 @@ export class PricingRepository
.andWhere(function () {
this.andWhere(function () {
for (const [key, value] of Object.entries(context)) {
this.orWhere({
"plr.attribute": key,
})
this.orWhere({ "plr.attribute": key })
this.where(
"plr.value",
"@>",
@@ -146,14 +184,20 @@ export class PricingRepository
})
this.andWhere(function () {
this.andWhere(function () {
this.andWhere((contextBuilder) => {
for (const [key, value] of Object.entries(context)) {
this.orWhere({
"pr.attribute": key,
"pr.value": value,
contextBuilder.orWhere((orBuilder) => {
orBuilder.where("pr.attribute", key)
if (typeof value === "number") {
buildOperatorQueries(orBuilder, value)
} else {
orBuilder.where({ "pr.value": value })
}
})
}
this.andWhere("price.rules_count", ">", 0)
contextBuilder.andWhere("price.rules_count", ">", 0)
})
this.orWhere("price.rules_count", "=", 0)
})

View File

@@ -15,6 +15,7 @@ import {
PricingContext,
PricingFilters,
PricingRepositoryService,
PricingRuleOperatorValues,
PricingTypes,
UpsertPricePreferenceDTO,
UpsertPriceSetDTO,
@@ -33,6 +34,7 @@ import {
MedusaError,
ModulesSdkUtils,
PriceListType,
PricingRuleOperator,
promiseAll,
removeNullish,
simpleHash,
@@ -596,20 +598,48 @@ export default class PricingModuleService
data?.forEach((price) => {
const cleanRules = price.rules ? removeNullish(price.rules) : {}
const ruleEntries = Object.entries(cleanRules)
const rules = ruleEntries.map(([attribute, value]) => {
return {
attribute,
value,
}
})
const ruleOperators: PricingRuleOperatorValues[] =
Object.values(PricingRuleOperator)
const rules = Object.entries(cleanRules)
.map(([attribute, value]) => {
if (Array.isArray(value)) {
return value.map((customRule) => {
if (!ruleOperators.includes(customRule.operator)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`operator should be one of ${ruleOperators.join(", ")}`
)
}
if (typeof customRule.value !== "number") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`value should be a number`
)
}
return {
attribute,
operator: customRule.operator,
value: customRule.value,
}
})
}
return {
attribute,
value,
}
})
.flat(1)
const hasRulesInput = isPresent(price.rules)
const entry = {
...price,
price_list_id: priceListId,
price_rules: hasRulesInput ? rules : undefined,
rules_count: hasRulesInput ? ruleEntries.length : undefined,
rules_count: hasRulesInput ? rules.length : undefined,
} as ServiceTypes.UpsertPriceDTO
delete (entry as CreatePricesDTO).rules