chore(promotion): Improve performances [1] (#12129)

**What**
Reduce database queries when possible and use proper data structure and aggregation when possible in order to reduce performance decrease overall
This commit is contained in:
Adrien de Peretti
2025-04-10 17:53:39 +02:00
committed by GitHub
parent 31abba8cde
commit d87b25203c
5 changed files with 406 additions and 307 deletions
@@ -50,6 +50,17 @@ export function getComputedActionsForBuyGet(
): PromotionTypes.ComputeActions[] {
const computedActions: PromotionTypes.ComputeActions[] = []
if (!itemsContext) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"items" should be present as an array in the context to compute actions`
)
}
if (!itemsContext?.length) {
return computedActions
}
const minimumBuyQuantity = MathBN.convert(
promotion.application_method?.buy_rules_min_quantity ?? 0
)
@@ -58,11 +69,11 @@ export function getComputedActionsForBuyGet(
itemsContext.map((i) => [i.id, i])
)
if (!itemsContext) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"items" should be present as an array in the context to compute actions`
)
if (
MathBN.lte(minimumBuyQuantity, 0) ||
!promotion.application_method?.buy_rules?.length
) {
return computedActions
}
const eligibleBuyItems = filterItemsByPromotionRules(
@@ -70,18 +81,25 @@ export function getComputedActionsForBuyGet(
promotion.application_method?.buy_rules
)
if (!eligibleBuyItems.length) {
return computedActions
}
const eligibleBuyItemQuantity = MathBN.sum(
...eligibleBuyItems.map((item) => item.quantity)
)
/*
Get the total quantity of items where buy rules apply. If the total sum of eligible items
Get the total quantity of items where buy rules apply. If the total sum of eligible items
does not match up to the minimum buy quantity set on the promotion, return early.
*/
if (MathBN.gt(minimumBuyQuantity, eligibleBuyItemQuantity)) {
return []
return computedActions
}
const eligibleItemsByPromotion: EligibleItem[] = []
let accumulatedQuantity = MathBN.convert(0)
/*
Eligibility of a BuyGet promotion can span across line items. Once an item has been chosen
as eligible, we can't use this item or its partial remaining quantity when we apply the promotion on
@@ -89,35 +107,19 @@ export function getComputedActionsForBuyGet(
We build the map here to use when we apply promotions on the target items.
*/
for (const eligibleBuyItem of eligibleBuyItems) {
const eligibleItemsByPromotion =
eligibleBuyItemMap.get(promotion.code!) || []
const accumulatedQuantity = eligibleItemsByPromotion.reduce(
(acc, item) => MathBN.sum(acc, item.quantity),
MathBN.convert(0)
)
// If we have reached the minimum buy quantity from the eligible items for this promotion,
// we can break early and continue to applying the target items
if (MathBN.gte(accumulatedQuantity, minimumBuyQuantity)) {
break
}
const eligibleQuantity = MathBN.sum(
...eligibleItemsByPromotion
.filter((buy) => buy.item_id === eligibleBuyItem.id)
.map((b) => b.quantity)
)
const reservableQuantity = MathBN.min(
eligibleBuyItem.quantity,
MathBN.sub(minimumBuyQuantity, eligibleQuantity)
MathBN.sub(minimumBuyQuantity, accumulatedQuantity)
)
// If we have reached the required minimum quantity, we break the loop early
if (MathBN.lte(reservableQuantity, 0)) {
break
continue
}
eligibleItemsByPromotion.push({
@@ -128,93 +130,126 @@ export function getComputedActionsForBuyGet(
).toNumber(),
})
eligibleBuyItemMap.set(promotion.code!, eligibleItemsByPromotion)
accumulatedQuantity = MathBN.add(accumulatedQuantity, reservableQuantity)
}
// Store the eligible buy items for this promotion code in the map
eligibleBuyItemMap.set(promotion.code!, eligibleItemsByPromotion)
// If we couldn't accumulate enough items to meet the minimum buy quantity, return early
if (MathBN.lt(accumulatedQuantity, minimumBuyQuantity)) {
return computedActions
}
// Get the number of target items that should receive the discount
const targetQuantity = MathBN.convert(
promotion.application_method?.apply_to_quantity ?? 0
)
// If no target quantity is specified, return early
if (MathBN.lte(targetQuantity, 0)) {
return computedActions
}
// Find all items that match the target rules criteria
const eligibleTargetItems = filterItemsByPromotionRules(
itemsContext,
promotion.application_method?.target_rules
)
const targetQuantity = MathBN.convert(
promotion.application_method?.apply_to_quantity ?? 0
)
// If no items match the target rules, return early
if (!eligibleTargetItems.length) {
return computedActions
}
/*
In this loop, we build a map of eligible target items and quantity applicable to these items.
// Track quantities of items that can't be used as targets because they were used in buy rules
const inapplicableQuantityMap = new Map<string, BigNumberInput>()
Here we remove the quantity we used previously to identify eligible buy items
from the eligible target items.
This is done to prevent applying promotion to the same item we use to qualify the buy rules.
*/
for (const eligibleTargetItem of eligibleTargetItems) {
const inapplicableQuantity = MathBN.sum(
...Array.from(eligibleBuyItemMap.values())
.flat(1)
.filter((buy) => buy.item_id === eligibleTargetItem.id)
.map((b) => b.quantity)
// Build map of quantities that are ineligible as targets because they were used to satisfy buy rules
for (const buyItem of eligibleItemsByPromotion) {
const currentValue =
inapplicableQuantityMap.get(buyItem.item_id) || MathBN.convert(0)
inapplicableQuantityMap.set(
buyItem.item_id,
MathBN.add(currentValue, buyItem.quantity)
)
}
// Track items eligible for receiving the discount and total quantity that can be discounted
const targetItemsByPromotion: EligibleItem[] = []
let targetableQuantity = MathBN.convert(0)
// Find items eligible for discount, excluding quantities used in buy rules
for (const eligibleTargetItem of eligibleTargetItems) {
// Calculate how much of this item's quantity can receive the discount
const inapplicableQuantity =
inapplicableQuantityMap.get(eligibleTargetItem.id) || MathBN.convert(0)
const applicableQuantity = MathBN.sub(
eligibleTargetItem.quantity,
inapplicableQuantity
)
const fulfillableQuantity = MathBN.min(targetQuantity, applicableQuantity)
if (MathBN.lte(applicableQuantity, 0)) {
continue
}
// Calculate how many more items we need to fulfill target quantity
const remainingNeeded = MathBN.sub(targetQuantity, targetableQuantity)
const fulfillableQuantity = MathBN.min(remainingNeeded, applicableQuantity)
// If we have reached the required quantity to target from this item, we
// move on to the next item
if (MathBN.lte(fulfillableQuantity, 0)) {
continue
}
const targetItemsByPromotion =
eligibleTargetItemMap.get(promotion.code!) || []
// Add this item to eligible targets
targetItemsByPromotion.push({
item_id: eligibleTargetItem.id,
quantity: MathBN.min(fulfillableQuantity, targetQuantity).toNumber(),
quantity: fulfillableQuantity.toNumber(),
})
eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion)
targetableQuantity = MathBN.add(targetableQuantity, fulfillableQuantity)
// If we've found enough items to fulfill target quantity, stop looking
if (MathBN.gte(targetableQuantity, targetQuantity)) {
break
}
}
const targetItemsByPromotion =
eligibleTargetItemMap.get(promotion.code!) || []
// Store eligible target items for this promotion
eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion)
const targettableQuantity = targetItemsByPromotion.reduce(
(sum, item) => MathBN.sum(sum, item.quantity),
MathBN.convert(0)
)
// If we were able to match the target requirements across all line items, we return early.
if (MathBN.lt(targettableQuantity, targetQuantity)) {
return []
// If we couldn't find enough eligible target items, return early
if (MathBN.lt(targetableQuantity, targetQuantity)) {
return computedActions
}
// Track remaining quantity to apply discount to and get discount percentage
let remainingQtyToApply = MathBN.convert(targetQuantity)
const applicablePercentage = promotion.application_method?.value ?? 100
// Apply discounts to eligible target items
for (const targetItem of targetItemsByPromotion) {
if (MathBN.lte(remainingQtyToApply, 0)) {
break
}
const item = itemsMap.get(targetItem.item_id)!
const appliedPromoValue =
methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0)
const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply)
const applicableAmount = MathBN.mult(
MathBN.div(item.subtotal, item.quantity),
multiplier
)
const applicablePercentage = promotion.application_method?.value ?? 100
// Calculate discount amount based on item price and applicable percentage
const pricePerUnit = MathBN.div(item.subtotal, item.quantity)
const applicableAmount = MathBN.mult(pricePerUnit, multiplier)
const amount = MathBN.mult(applicableAmount, applicablePercentage).div(100)
const newRemainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier)
if (MathBN.lt(newRemainingQtyToApply, 0) || MathBN.lte(amount, 0)) {
break
} else {
remainingQtyToApply = newRemainingQtyToApply
if (MathBN.lte(amount, 0)) {
continue
}
remainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier)
// Check if applying this discount would exceed promotion budget
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
@@ -222,15 +257,16 @@ export function getComputedActionsForBuyGet(
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
// Track total promotional value applied to this item
methodIdPromoValueMap.set(
item.id,
MathBN.add(appliedPromoValue, amount).toNumber()
)
// Add computed discount action
computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: item.id,
@@ -75,17 +75,35 @@ function applyPromotionToItems(
allocationOverride?: ApplicationMethodAllocationValues
): PromotionTypes.ComputeActions[] {
const { application_method: applicationMethod } = promotion
if (!applicationMethod) {
return []
}
const allocation = applicationMethod?.allocation! || allocationOverride
const computedActions: PromotionTypes.ComputeActions[] = []
const applicableItems = getValidItemsForPromotion(items, promotion)
const target = applicationMethod?.target_type
if (!items?.length || !target) {
return []
}
const computedActions: PromotionTypes.ComputeActions[] = []
const applicableItems = getValidItemsForPromotion(items, promotion)
if (!applicableItems.length) {
return computedActions
}
const isTargetShippingMethod = target === TargetType.SHIPPING_METHODS
const isTargetLineItems = target === TargetType.ITEMS
const isTargetOrder = target === TargetType.ORDER
const promotionValue = applicationMethod?.value ?? 0
const maxQuantity = isTargetShippingMethod
? 1
: applicationMethod?.max_quantity!
let lineItemsTotal = MathBN.convert(0)
if (allocation === ApplicationMethodAllocation.ACROSS) {
lineItemsTotal = applicableItems.reduce(
(acc, item) =>
@@ -95,22 +113,27 @@ function applyPromotionToItems(
),
MathBN.convert(0)
)
if (MathBN.lte(lineItemsTotal, 0)) {
return computedActions
}
}
for (const item of applicableItems!) {
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0
const maxQuantity = isTargetShippingMethod
? 1
: applicationMethod?.max_quantity!
for (const item of applicableItems) {
if (MathBN.lte(item.subtotal, 0)) {
continue
}
if (isTargetShippingMethod) {
item.quantity = 1
}
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0
const amount = calculateAdjustmentAmountFromPromotion(
item,
{
value: applicationMethod?.value ?? 0,
value: promotionValue,
applied_value: appliedPromoValue,
max_quantity: maxQuantity,
type: applicationMethod?.type!,
@@ -130,7 +153,6 @@ function applyPromotionToItems(
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
@@ -143,9 +165,7 @@ function applyPromotionToItems(
amount,
code: promotion.code!,
})
}
if (isTargetShippingMethod) {
} else if (isTargetShippingMethod) {
computedActions.push({
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: item.id,
@@ -164,24 +184,39 @@ function getValidItemsForPromotion(
| PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS],
promotion: PromotionTypes.PromotionDTO
) {
if (!items?.length || !promotion?.application_method) {
return []
}
const isTargetShippingMethod =
promotion.application_method?.target_type === TargetType.SHIPPING_METHODS
return (
items?.filter((item) => {
const isSubtotalPresent = "subtotal" in item
const isQuantityPresent = "quantity" in item
const isPromotionApplicableToItem = areRulesValidForContext(
promotion?.application_method?.target_rules!,
item,
ApplicationMethodTargetType.ITEMS
)
const targetRules = promotion.application_method?.target_rules ?? []
const hasTargetRules = targetRules.length > 0
return (
isPromotionApplicableToItem &&
(isQuantityPresent || isTargetShippingMethod) &&
isSubtotalPresent
)
}) || []
)
if (isTargetShippingMethod && !hasTargetRules) {
return items.filter(
(item) => item && "subtotal" in item && MathBN.gt(item.subtotal, 0)
)
}
return items.filter((item) => {
if (!item || !("subtotal" in item) || MathBN.lte(item.subtotal, 0)) {
return false
}
if (!isTargetShippingMethod && !("quantity" in item)) {
return false
}
if (!hasTargetRules) {
return true
}
return areRulesValidForContext(
promotion?.application_method?.target_rules!,
item,
ApplicationMethodTargetType.ITEMS
)
})
}
@@ -55,20 +55,44 @@ export function areRulesValidForContext(
context: Record<string, any>,
contextScope: ApplicationMethodTargetTypeValues
): boolean {
return rules.every((rule) => {
const validRuleValues = rule.values?.map((ruleValue) => ruleValue.value)
if (!rules?.length) {
return true
}
if (!rule.attribute) {
const isItemScope = contextScope === ApplicationMethodTargetType.ITEMS
const isShippingScope =
contextScope === ApplicationMethodTargetType.SHIPPING_METHODS
return rules.every((rule) => {
if (!rule.attribute || !rule.values?.length) {
return false
}
const valuesToCheck = pickValueFromObject(
fetchRuleAttributeForContext(rule.attribute, contextScope),
context
)
const validRuleValues = rule.values
.filter((v) => isString(v.value))
.map((v) => v.value as string)
if (!validRuleValues.length) {
return false
}
let ruleAttribute = rule.attribute
if (isItemScope) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.ITEMS}.`,
""
)
} else if (isShippingScope) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.SHIPPING_METHODS}.`,
""
)
}
const valuesToCheck = pickValueFromObject(ruleAttribute, context)
return evaluateRuleValueCondition(
validRuleValues.filter(isString),
validRuleValues,
rule.operator!,
valuesToCheck
)
@@ -76,83 +100,49 @@ export function areRulesValidForContext(
}
/*
The context here can either be either:
- a cart context
- an item context under a cart
- a shipping method context under a cart
The rule's attributes are set from the perspective of the cart context. For example: items.product.id
When the context here is item or shipping_method, we need to drop the "items."" or "shipping_method."
from the rule attribute string to accurate pick the values from the context.
Optimized evaluateRuleValueCondition by using early returns and cleaner approach
for evaluating rule conditions.
*/
function fetchRuleAttributeForContext(
ruleAttribute: string,
contextScope: ApplicationMethodTargetTypeValues
): string {
if (contextScope === ApplicationMethodTargetType.ITEMS) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.ITEMS}.`,
""
)
}
if (contextScope === ApplicationMethodTargetType.SHIPPING_METHODS) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.SHIPPING_METHODS}.`,
""
)
}
return ruleAttribute
}
export function evaluateRuleValueCondition(
ruleValues: string[],
operator: string,
ruleValuesToCheck: (string | number)[] | (string | number)
) {
if (!Array.isArray(ruleValuesToCheck)) {
ruleValuesToCheck = [ruleValuesToCheck]
}
): boolean {
const valuesToCheck = Array.isArray(ruleValuesToCheck)
? ruleValuesToCheck
: [ruleValuesToCheck]
if (!ruleValuesToCheck.length) {
if (!valuesToCheck.length) {
return false
}
return ruleValuesToCheck.every((ruleValueToCheck: string | number) => {
if (operator === "in" || operator === "eq") {
return ruleValues.some((ruleValue) => ruleValue === `${ruleValueToCheck}`)
switch (operator) {
case "in":
case "eq": {
const ruleValueSet = new Set(ruleValues)
return valuesToCheck.every((val) => ruleValueSet.has(`${val}`))
}
if (operator === "ne") {
return ruleValues.some((ruleValue) => ruleValue !== `${ruleValueToCheck}`)
case "ne": {
const ruleValueSet = new Set(ruleValues)
return valuesToCheck.every((val) => !ruleValueSet.has(`${val}`))
}
if (operator === "gt") {
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).gt(MathBN.convert(ruleValue))
case "gt":
return valuesToCheck.every((val) =>
ruleValues.some((ruleVal) => MathBN.gt(val, ruleVal))
)
}
if (operator === "gte") {
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).gte(MathBN.convert(ruleValue))
case "gte":
return valuesToCheck.every((val) =>
ruleValues.some((ruleVal) => MathBN.gte(val, ruleVal))
)
}
if (operator === "lt") {
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).lt(MathBN.convert(ruleValue))
case "lt":
return valuesToCheck.every((val) =>
ruleValues.some((ruleVal) => MathBN.lt(val, ruleVal))
)
}
if (operator === "lte") {
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).lte(MathBN.convert(ruleValue))
case "lte":
return valuesToCheck.every((val) =>
ruleValues.some((ruleVal) => MathBN.lte(val, ruleVal))
)
}
return false
})
default:
return false
}
}