fix(promotion): handle promotion buy X get X scenario (#9002)

* fix(promotion): handle promotion buy X get X scenario

* chore: fix qualifiication rules
This commit is contained in:
Riqwan Thamir
2024-09-10 15:12:56 +02:00
committed by GitHub
parent e9b5f76f9a
commit 3593bdfebe
3 changed files with 571 additions and 53 deletions

View File

@@ -1,28 +1,63 @@
import { BigNumberInput, PromotionTypes } from "@medusajs/types"
import {
BigNumberInput,
ComputeActionItemLine,
PromotionTypes,
} from "@medusajs/types"
import {
ApplicationMethodTargetType,
ComputedActions,
MathBN,
MedusaError,
PromotionType,
isPresent,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
// TODO: calculations should eventually move to a totals util outside of the module
export type EligibleItem = {
item_id: string
quantity: BigNumberInput
}
function sortByPrice(a: ComputeActionItemLine, b: ComputeActionItemLine) {
return MathBN.lt(a.subtotal, b.subtotal) ? 1 : -1
}
/*
Grabs all the items in the context where the rules apply
We then sort by price to prioritize most valuable item
*/
function filterItemsByPromotionRules(
itemsContext: ComputeActionItemLine[],
rules?: PromotionTypes.PromotionRuleDTO[]
) {
return itemsContext
.filter((item) =>
areRulesValidForContext(
rules || [],
item,
ApplicationMethodTargetType.ITEMS
)
)
.sort(sortByPrice)
}
export function getComputedActionsForBuyGet(
promotion: PromotionTypes.PromotionDTO,
itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap: Map<string, BigNumberInput>
itemsContext: ComputeActionItemLine[],
methodIdPromoValueMap: Map<string, BigNumberInput>,
eligibleBuyItemMap: Map<string, EligibleItem[]>,
eligibleTargetItemMap: Map<string, EligibleItem[]>
): PromotionTypes.ComputeActions[] {
const buyRulesMinQuantity =
promotion.application_method?.buy_rules_min_quantity
const applyToQuantity = promotion.application_method?.apply_to_quantity
const buyRules = promotion.application_method?.buy_rules
const targetRules = promotion.application_method?.target_rules
const computedActions: PromotionTypes.ComputeActions[] = []
const minimumBuyQuantity = MathBN.convert(
promotion.application_method?.buy_rules_min_quantity ?? 0
)
const itemsMap = new Map<string, ComputeActionItemLine>(
itemsContext.map((i) => [i.id, i])
)
if (!itemsContext) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -30,56 +65,146 @@ export function getComputedActionsForBuyGet(
)
}
if (!Array.isArray(buyRules) || !Array.isArray(targetRules)) {
return []
}
const validQuantity = MathBN.sum(
...itemsContext
.filter((item) =>
areRulesValidForContext(
buyRules,
item,
ApplicationMethodTargetType.ITEMS
)
)
.map((item) => item.quantity)
const eligibleBuyItems = filterItemsByPromotionRules(
itemsContext,
promotion.application_method?.buy_rules
)
if (
!buyRulesMinQuantity ||
!applyToQuantity ||
MathBN.gt(buyRulesMinQuantity, validQuantity)
) {
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
does not match up to the minimum buy quantity set on the promotion, return early.
*/
if (MathBN.gt(minimumBuyQuantity, eligibleBuyItemQuantity)) {
return []
}
const validItemsForTargetRules = itemsContext
.filter((item) =>
areRulesValidForContext(
targetRules,
item,
ApplicationMethodTargetType.ITEMS
)
/*
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
the target item.
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)
)
.filter((item) => isPresent(item.subtotal) && isPresent(item.quantity))
.sort((a, b) => {
const divA = MathBN.eq(a.quantity, 0) ? 1 : a.quantity
const divB = MathBN.eq(b.quantity, 0) ? 1 : b.quantity
const aPrice = MathBN.div(a.subtotal, divA)
const bPrice = MathBN.div(b.subtotal, divB)
// 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
}
return MathBN.lt(bPrice, aPrice) ? -1 : 1
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)
)
// If we have reached the required minimum quantity, we break the loop early
if (MathBN.lte(reservableQuantity, 0)) {
break
}
eligibleItemsByPromotion.push({
item_id: eligibleBuyItem.id,
quantity: MathBN.min(
eligibleBuyItem.quantity,
reservableQuantity
).toNumber(),
})
let remainingQtyToApply = MathBN.convert(applyToQuantity)
eligibleBuyItemMap.set(promotion.code!, eligibleItemsByPromotion)
}
const eligibleTargetItems = filterItemsByPromotionRules(
itemsContext,
promotion.application_method?.target_rules
)
const targetQuantity = MathBN.convert(
promotion.application_method?.apply_to_quantity ?? 0
)
/*
In this loop, we build a map of eligible target items and quantity applicable to these items.
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)
)
const applicableQuantity = MathBN.sub(
eligibleTargetItem.quantity,
inapplicableQuantity
)
const fulfillableQuantity = MathBN.min(targetQuantity, 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!) || []
targetItemsByPromotion.push({
item_id: eligibleTargetItem.id,
quantity: MathBN.min(fulfillableQuantity, targetQuantity).toNumber(),
})
eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion)
}
const targetItemsByPromotion =
eligibleTargetItemMap.get(promotion.code!) || []
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 []
}
let remainingQtyToApply = MathBN.convert(targetQuantity)
for (const targetItem of targetItemsByPromotion) {
const item = itemsMap.get(targetItem.item_id)!
const appliedPromoValue =
methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0)
const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply)
const amount = MathBN.mult(
MathBN.div(item.subtotal, item.quantity),
multiplier
)
for (const method of validItemsForTargetRules) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
const multiplier = MathBN.min(method.quantity, remainingQtyToApply)
const div = MathBN.eq(method.quantity, 0) ? 1 : method.quantity
const amount = MathBN.mult(MathBN.div(method.subtotal, div), multiplier)
const newRemainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier)
if (MathBN.lt(newRemainingQtyToApply, 0) || MathBN.lte(amount, 0)) {
@@ -99,11 +224,14 @@ export function getComputedActionsForBuyGet(
continue
}
methodIdPromoValueMap.set(method.id, MathBN.add(appliedPromoValue, amount))
methodIdPromoValueMap.set(
item.id,
MathBN.add(appliedPromoValue, amount).toNumber()
)
computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: method.id,
item_id: item.id,
amount,
code: promotion.code!,
})