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

View File

@@ -0,0 +1,5 @@
---
"@medusajs/promotion": patch
---
chore(promotion): Improve performances [1]

View File

@@ -144,24 +144,25 @@ export default class PromotionModuleService
config?: FindConfig<PromotionDTO>,
sharedContext?: Context
): Promise<PromotionDTO[]> {
// Ensure we share the same now date across all filters
const now = new Date()
const activeFilters = {
status: PromotionStatus.ACTIVE,
$or: [
{
status: PromotionStatus.ACTIVE,
campaign_id: null,
...filters,
},
{
status: PromotionStatus.ACTIVE,
...filters,
campaign: {
...filters?.campaign,
$and: [
{
$or: [{ starts_at: null }, { starts_at: { $lte: new Date() } }],
$or: [{ starts_at: null }, { starts_at: { $lte: now } }],
},
{
$or: [{ ends_at: null }, { ends_at: { $gt: new Date() } }],
$or: [{ ends_at: null }, { ends_at: { $gt: now } }],
},
],
},
@@ -172,12 +173,10 @@ export default class PromotionModuleService
return this.listPromotions(activeFilters, config, sharedContext)
}
@InjectManager()
@InjectTransactionManager()
async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext()
@MedusaContext()
sharedContext: Context = {}
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionCodes = computedActions
.map((computedAction) => computedAction.code)
@@ -225,16 +224,19 @@ export default class PromotionModuleService
continue
}
campaignBudgetData.used = MathBN.add(
// Calculate the new budget value
const newUsedValue = MathBN.add(
campaignBudgetData.used ?? 0,
computedAction.amount
)
if (
campaignBudget.limit &&
MathBN.gt(campaignBudgetData.used, campaignBudget.limit)
MathBN.gt(newUsedValue, campaignBudget.limit)
) {
continue
} else {
campaignBudgetData.used = newUsedValue
}
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
@@ -248,25 +250,30 @@ export default class PromotionModuleService
continue
}
const campaignBudgetData = {
id: campaignBudget.id,
used: MathBN.add(campaignBudget.used ?? 0, 1),
}
const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
// Check if it exceeds the limit and cap it if necessary
if (
campaignBudget.limit &&
MathBN.gt(campaignBudgetData.used, campaignBudget.limit)
MathBN.gt(newUsedValue, campaignBudget.limit)
) {
continue
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: campaignBudget.limit,
})
} else {
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: newUsedValue,
})
}
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
promotionCodeUsageMap.set(promotion.code!, true)
}
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData)
}
@@ -278,7 +285,7 @@ export default class PromotionModuleService
}
}
@InjectManager()
@InjectTransactionManager()
async revertUsage(
computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext() sharedContext: Context = {}
@@ -329,11 +336,13 @@ export default class PromotionModuleService
continue
}
campaignBudgetData.used = MathBN.sub(
// Calculate new used value and ensure it doesn't go below 0
const newUsedValue = MathBN.sub(
campaignBudgetData.used ?? 0,
computedAction.amount
)
campaignBudgetData.used = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
}
@@ -345,16 +354,21 @@ export default class PromotionModuleService
continue
}
// Calculate new used value and ensure it doesn't go below 0
const newUsedValue = MathBN.sub(campaignBudget.used ?? 0, 1)
const usedValue = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: MathBN.sub(campaignBudget.used ?? 0, 1),
used: usedValue,
})
promotionCodeUsageMap.set(promotion.code!, true)
}
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData)
}
@@ -377,23 +391,48 @@ export default class PromotionModuleService
const computedActions: PromotionTypes.ComputeActions[] = []
const { items = [], shipping_methods: shippingMethods = [] } =
applicationContext
const appliedItemCodes: string[] = []
const appliedShippingCodes: string[] = []
const codeAdjustmentMap = new Map<
string,
PromotionTypes.ComputeActionAdjustmentLine[]
{
items: PromotionTypes.ComputeActionAdjustmentLine[]
shipping: PromotionTypes.ComputeActionAdjustmentLine[]
}
>()
// Pre-process items and shipping methods to build adjustment map efficiently
for (const item of items) {
if (!item.adjustments?.length) continue
for (const adjustment of item.adjustments) {
if (!isString(adjustment.code)) continue
if (!codeAdjustmentMap.has(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, { items: [], shipping: [] })
}
codeAdjustmentMap.get(adjustment.code)!.items.push(adjustment)
}
}
for (const shippingMethod of shippingMethods) {
if (!shippingMethod.adjustments?.length) continue
for (const adjustment of shippingMethod.adjustments) {
if (!isString(adjustment.code)) continue
if (!codeAdjustmentMap.has(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, { items: [], shipping: [] })
}
codeAdjustmentMap.get(adjustment.code)!.shipping.push(adjustment)
}
}
const appliedCodes = Array.from(codeAdjustmentMap.keys())
const methodIdPromoValueMap = new Map<string, number>()
// Keeps a map of all elgible items in the buy section and its eligible quantity
const eligibleBuyItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
// Keeps a map of all elgible items in the target section and its eligible quantity
const eligibleTargetItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
const automaticPromotions = preventAutoPromotions
? []
: await this.listActivePromotions(
@@ -402,48 +441,17 @@ export default class PromotionModuleService
sharedContext
)
// Promotions we need to apply includes all the codes that are passed as an argument
// to this method, along with any automatic promotions that can be applied to the context
const automaticPromotionCodes = automaticPromotions.map((p) => p.code!)
const promotionCodesToApply = [
...promotionCodes,
...automaticPromotionCodes,
...appliedCodes,
]
items.forEach((item) => {
item.adjustments?.forEach((adjustment) => {
if (isString(adjustment.code)) {
const adjustments = codeAdjustmentMap.get(adjustment.code) || []
adjustments.push(adjustment)
codeAdjustmentMap.set(adjustment.code, adjustments)
appliedItemCodes.push(adjustment.code)
}
})
})
shippingMethods.forEach((shippingMethod) => {
shippingMethod.adjustments?.forEach((adjustment) => {
if (isString(adjustment.code)) {
const adjustments = codeAdjustmentMap.get(adjustment.code) || []
adjustments.push(adjustment)
codeAdjustmentMap.set(adjustment.code, adjustments)
appliedShippingCodes.push(adjustment.code)
}
})
})
const uniquePromotionCodes = Array.from(new Set(promotionCodesToApply))
const promotions = await this.listActivePromotions(
{
code: [
...promotionCodesToApply,
...appliedItemCodes,
...appliedShippingCodes,
],
},
{ code: uniquePromotionCodes },
{
take: null,
order: { application_method: { value: "DESC" } },
@@ -458,42 +466,51 @@ export default class PromotionModuleService
"campaign",
"campaign.budget",
],
}
},
sharedContext
)
const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
promotions.map((promotion) => [promotion.code!, promotion])
)
// We look at any existing promo codes applied in the context and recommend
// them to be removed to start calculations from the beginning and refresh
// the adjustments if they are requested to be applied again
const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes]
for (const appliedCode of appliedCodes) {
const adjustments = codeAdjustmentMap.get(appliedCode) || []
const action = appliedShippingCodes.includes(appliedCode)
? ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT
: ComputedActions.REMOVE_ITEM_ADJUSTMENT
adjustments.forEach((adjustment) =>
for (const [code, adjustments] of codeAdjustmentMap.entries()) {
for (const adjustment of adjustments.items) {
computedActions.push({
action,
action: ComputedActions.REMOVE_ITEM_ADJUSTMENT,
adjustment_id: adjustment.id,
code: appliedCode,
code,
})
)
}
for (const adjustment of adjustments.shipping) {
computedActions.push({
action: ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT,
adjustment_id: adjustment.id,
code,
})
}
}
// We sort the promo codes to apply with buy get type first as they
// are likely to be most valuable.
const sortedPermissionsToApply = promotions
.filter((p) => promotionCodesToApply.includes(p.code!))
const sortedPromotionsToApply = promotions
.filter(
(p) =>
promotionCodes.includes(p.code!) ||
automaticPromotionCodes.includes(p.code!)
)
.sort(ComputeActionUtils.sortByBuyGetType)
for (const promotionToApply of sortedPermissionsToApply) {
const promotion = existingPromotionsMap.get(promotionToApply.code!)!
const eligibleBuyItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
const eligibleTargetItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
for (const promotionToApply of sortedPromotionsToApply) {
const promotion = existingPromotionsMap.get(promotionToApply.code!)!
const {
application_method: applicationMethod,
rules: promotionRules = [],
@@ -524,9 +541,7 @@ export default class PromotionModuleService
)
computedActions.push(...computedActionsForItems)
}
if (promotion.type === PromotionType.STANDARD) {
} else if (promotion.type === PromotionType.STANDARD) {
const isTargetOrder =
applicationMethod.target_type === ApplicationMethodTargetType.ORDER
const isTargetItems =
@@ -620,11 +635,20 @@ export default class PromotionModuleService
const promotionsData: CreatePromotionDTO[] = []
const applicationMethodsData: CreateApplicationMethodDTO[] = []
const campaignsData: CreateCampaignDTO[] = []
const existingCampaigns = await this.campaignService_.list(
{ id: data.map((d) => d.campaign_id).filter((id) => isString(id)) },
{ relations: ["budget"] },
sharedContext
)
const campaignIds = data
.filter((d) => d.campaign_id)
.map((d) => d.campaign_id)
.filter((id): id is string => isString(id))
const existingCampaigns =
campaignIds.length > 0
? await this.campaignService_.list(
{ id: campaignIds },
{ relations: ["budget"] },
sharedContext
)
: []
const promotionCodeApplicationMethodDataMap = new Map<
string,
@@ -672,7 +696,6 @@ export default class PromotionModuleService
if (!campaignData && !campaignId) {
promotionsData.push({ ...promotionData })
continue
}
@@ -783,24 +806,28 @@ export default class PromotionModuleService
}
}
await this.createPromotionRulesAndValues_(
promotionCodeRulesDataMap.get(promotion.code) || [],
"promotions",
promotion,
sharedContext
)
if (promotionCodeRulesDataMap.has(promotion.code)) {
await this.createPromotionRulesAndValues_(
promotionCodeRulesDataMap.get(promotion.code) || [],
"promotions",
promotion,
sharedContext
)
}
}
const createdApplicationMethods =
await this.applicationMethodService_.create(
applicationMethodsData,
sharedContext
)
applicationMethodsData.length > 0
? await this.applicationMethodService_.create(
applicationMethodsData,
sharedContext
)
: []
const createdCampaigns = await this.createCampaigns(
campaignsData,
sharedContext
)
const createdCampaigns =
campaignsData.length > 0
? await this.createCampaigns(campaignsData, sharedContext)
: []
for (const campaignData of campaignsData) {
const promotions = campaignData.promotions
@@ -808,30 +835,36 @@ export default class PromotionModuleService
(c) => c.campaign_identifier === campaignData.campaign_identifier
)
if (!campaign || !promotions || !promotions.length) {
continue
if (campaign && promotions && promotions.length) {
await this.addPromotionsToCampaign(
{ id: campaign.id, promotion_ids: promotions.map((p) => p.id) },
sharedContext
)
}
await this.addPromotionsToCampaign(
{ id: campaign.id, promotion_ids: promotions.map((p) => p.id) },
sharedContext
)
}
for (const applicationMethod of createdApplicationMethods) {
await this.createPromotionRulesAndValues_(
methodTargetRulesMap.get(applicationMethod.promotion.id) || [],
"method_target_rules",
applicationMethod,
sharedContext
const targetRules = methodTargetRulesMap.get(
applicationMethod.promotion.id
)
if (targetRules && targetRules.length > 0) {
await this.createPromotionRulesAndValues_(
targetRules,
"method_target_rules",
applicationMethod,
sharedContext
)
}
await this.createPromotionRulesAndValues_(
methodBuyRulesMap.get(applicationMethod.promotion.id) || [],
"method_buy_rules",
applicationMethod,
sharedContext
)
const buyRules = methodBuyRulesMap.get(applicationMethod.promotion.id)
if (buyRules && buyRules.length > 0) {
await this.createPromotionRulesAndValues_(
buyRules,
"method_buy_rules",
applicationMethod,
sharedContext
)
}
}
return createdPromotions

View File

@@ -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,

View File

@@ -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
)
})
}

View File

@@ -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
}
}