feat(): added compute actions for buyget promotions (#6255)

what:

- computes actions for buyget promotion (RESOLVES CORE-1700)
This commit is contained in:
Riqwan Thamir
2024-01-30 11:32:16 +01:00
committed by GitHub
parent 1c27f7cb34
commit 328eb85a8b
4 changed files with 542 additions and 66 deletions

View File

@@ -38,44 +38,40 @@ describe("Promotion Service: computeActions", () => {
})
describe("when code is not present in database", () => {
it("should throw error when code in promotions array does not exist", async () => {
const error = await service
.computeActions(["DOES_NOT_EXIST"], {
customer: {
customer_group: {
id: "VIP",
it("should return empty array when promotion does not exist", async () => {
const response = await service.computeActions(["DOES_NOT_EXIST"], {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 1,
unit_price: 100,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 1,
unit_price: 100,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
{
id: "item_cotton_sweater",
quantity: 5,
unit_price: 150,
product_category: {
id: "catg_cotton",
},
{
id: "item_cotton_sweater",
quantity: 5,
unit_price: 150,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_sweater",
},
product: {
id: "prod_sweater",
},
],
})
.catch((e) => e)
},
],
})
expect(error.message).toContain(
"Promotion for code (DOES_NOT_EXIST) not found"
)
expect(response).toEqual([])
})
it("should throw error when code in items adjustment does not exist", async () => {
@@ -2315,4 +2311,366 @@ describe("Promotion Service: computeActions", () => {
])
})
})
describe("when promotion of type buyget", () => {
it("should compute adjustment when target and buy rules match", async () => {
const context = {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 2,
unit_price: 500,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_1",
},
},
{
id: "item_cotton_tshirt2",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_2",
},
},
{
id: "item_cotton_sweater",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_sweater",
},
product: {
id: "prod_sweater_1",
},
},
],
}
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
max_quantity: 1,
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_tshirt"],
},
],
buy_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_sweater"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], context)
expect(result).toEqual([
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt2",
amount: 1000,
code: "PROMOTION_TEST",
},
])
})
it("should return empty array when conditions for minimum qty aren't met", async () => {
const context = {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 2,
unit_price: 500,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_1",
},
},
{
id: "item_cotton_tshirt2",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_2",
},
},
{
id: "item_cotton_sweater",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_sweater",
},
product: {
id: "prod_sweater_1",
},
},
],
}
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
max_quantity: 1,
apply_to_quantity: 1,
buy_rules_min_quantity: 4,
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_tshirt"],
},
],
buy_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_sweater"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], context)
expect(result).toEqual([])
})
it("should compute actions for multiple items when conditions for target qty exceed one item", async () => {
const context = {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 2,
unit_price: 500,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_1",
},
},
{
id: "item_cotton_tshirt2",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_2",
},
},
{
id: "item_cotton_sweater",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_sweater",
},
product: {
id: "prod_sweater_1",
},
},
],
}
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
max_quantity: 1,
apply_to_quantity: 4,
buy_rules_min_quantity: 1,
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_tshirt"],
},
],
buy_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_sweater"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], context)
expect(result).toEqual([
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt2",
amount: 2000,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 1000,
code: "PROMOTION_TEST",
},
])
})
it("should return empty array when target rules arent met with context", async () => {
const context = {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 2,
unit_price: 500,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_1",
},
},
{
id: "item_cotton_tshirt2",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_tshirt",
},
product: {
id: "prod_tshirt_2",
},
},
{
id: "item_cotton_sweater",
quantity: 2,
unit_price: 1000,
product_category: {
id: "catg_sweater",
},
product: {
id: "prod_sweater_1",
},
},
],
}
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
max_quantity: 1,
apply_to_quantity: 4,
buy_rules_min_quantity: 1,
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_not-found"],
},
],
buy_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_sweater"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], context)
expect(result).toEqual([])
})
})
})

View File

@@ -263,6 +263,8 @@ export default class PromotionModuleService<
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
"application_method.buy_rules",
"application_method.buy_rules.values",
"rules",
"rules.values",
"campaign",
@@ -271,6 +273,10 @@ export default class PromotionModuleService<
}
)
const sortedPermissionsToApply = promotions
.filter((p) => promotionCodesToApply.includes(p.code!))
.sort(ComputeActionUtils.sortByBuyGetType)
const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
promotions.map((promotion) => [promotion.code!, promotion])
)
@@ -306,15 +312,8 @@ export default class PromotionModuleService<
}
}
for (const promotionCode of promotionCodesToApply) {
const promotion = existingPromotionsMap.get(promotionCode)
if (!promotion) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Promotion for code (${promotionCode}) not found`
)
}
for (const promotionToApply of sortedPermissionsToApply) {
const promotion = existingPromotionsMap.get(promotionToApply.code!)!
const {
application_method: applicationMethod,
@@ -334,20 +333,9 @@ export default class PromotionModuleService<
continue
}
if (applicationMethod.target_type === ApplicationMethodTargetType.ORDER) {
if (promotion.type === PromotionType.BUYGET) {
const computedActionsForItems =
ComputeActionUtils.getComputedActionsForOrder(
promotion,
applicationContext,
methodIdPromoValueMap
)
computedActions.push(...computedActionsForItems)
}
if (applicationMethod.target_type === ApplicationMethodTargetType.ITEMS) {
const computedActionsForItems =
ComputeActionUtils.getComputedActionsForItems(
ComputeActionUtils.getComputedActionsForBuyGet(
promotion,
applicationContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap
@@ -356,18 +344,46 @@ export default class PromotionModuleService<
computedActions.push(...computedActionsForItems)
}
if (
applicationMethod.target_type ===
ApplicationMethodTargetType.SHIPPING_METHODS
) {
const computedActionsForShippingMethods =
ComputeActionUtils.getComputedActionsForShippingMethods(
promotion,
applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS],
methodIdPromoValueMap
)
if (promotion.type === PromotionType.STANDARD) {
if (
applicationMethod.target_type === ApplicationMethodTargetType.ORDER
) {
const computedActionsForItems =
ComputeActionUtils.getComputedActionsForOrder(
promotion,
applicationContext,
methodIdPromoValueMap
)
computedActions.push(...computedActionsForShippingMethods)
computedActions.push(...computedActionsForItems)
}
if (
applicationMethod.target_type === ApplicationMethodTargetType.ITEMS
) {
const computedActionsForItems =
ComputeActionUtils.getComputedActionsForItems(
promotion,
applicationContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap
)
computedActions.push(...computedActionsForItems)
}
if (
applicationMethod.target_type ===
ApplicationMethodTargetType.SHIPPING_METHODS
) {
const computedActionsForShippingMethods =
ComputeActionUtils.getComputedActionsForShippingMethods(
promotion,
applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS],
methodIdPromoValueMap
)
computedActions.push(...computedActionsForShippingMethods)
}
}
}

View File

@@ -0,0 +1,101 @@
import { PromotionTypes } from "@medusajs/types"
import {
ApplicationMethodTargetType,
ComputedActions,
MedusaError,
PromotionType,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations/promotion-rule"
import { computeActionForBudgetExceeded } from "./usage"
export function getComputedActionsForBuyGet(
promotion: PromotionTypes.PromotionDTO,
itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap: Map<string, number>
): 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[] = []
if (!itemsContext) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"items" should be present as an array in the context to compute actions`
)
}
if (!Array.isArray(buyRules) || !Array.isArray(targetRules)) {
return []
}
const validQuantity = itemsContext
.filter((item) => areRulesValidForContext(buyRules, item))
.reduce((acc, next) => acc + next.quantity, 0)
if (
!buyRulesMinQuantity ||
!applyToQuantity ||
buyRulesMinQuantity > validQuantity
) {
return []
}
const validItemsForTargetRules = itemsContext
.filter((item) => areRulesValidForContext(targetRules, item))
.sort((a, b) => {
return b.unit_price - a.unit_price
})
let remainingQtyToApply = applyToQuantity
for (const method of validItemsForTargetRules) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
const multiplier = Math.min(method.quantity, remainingQtyToApply)
const amount = method.unit_price * multiplier
const newRemainingQtyToApply = remainingQtyToApply - multiplier
if (newRemainingQtyToApply < 0 || amount <= 0) {
break
} else {
remainingQtyToApply = newRemainingQtyToApply
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: method.id,
amount,
code: promotion.code!,
})
}
return computedActions
}
export function sortByBuyGetType(a, b) {
if (a.type === PromotionType.BUYGET && b.type !== PromotionType.BUYGET) {
return -1
} else if (
a.type !== PromotionType.BUYGET &&
b.type === PromotionType.BUYGET
) {
return 1
} else {
return 0
}
}

View File

@@ -1,3 +1,4 @@
export * from "./buy-get"
export * from "./items"
export * from "./order"
export * from "./shipping-methods"