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:
committed by
GitHub
parent
31abba8cde
commit
d87b25203c
5
.changeset/nine-otters-carry.md
Normal file
5
.changeset/nine-otters-carry.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/promotion": patch
|
||||
---
|
||||
|
||||
chore(promotion): Improve performances [1]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user