feat(): prefilter top level promotion rules in db (#13524)
* feat(): promotion pre filtering rule from db * wip * feat(): promotion pre filtering rule from db * improve test readability * resolve conflict * fix automatic flag * add index on attribute and operator * add index on attribute and operator * finalize * cleanup * cleanup * cleanup * cleanup * Create purple-cars-design.md * fixes * fixes * simplify filters * fix filter * fix filter * further improvements * fixes * fixes * fixes * fix exclusion * fix comment * fix comment
This commit is contained in:
committed by
GitHub
parent
76497fd40a
commit
57897c232e
@@ -1,4 +1,7 @@
|
||||
import { IPromotionModuleService } from "@medusajs/framework/types"
|
||||
import {
|
||||
CreatePromotionDTO,
|
||||
IPromotionModuleService,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
ApplicationMethodType,
|
||||
Modules,
|
||||
@@ -22,6 +25,540 @@ moduleIntegrationTestRunner({
|
||||
await createCampaigns(MikroOrmWrapper.forkManager())
|
||||
})
|
||||
|
||||
it("should prefilter promotions by applicable rules", async () => {
|
||||
// 1. Promotion with NO rules (should always apply if automatic)
|
||||
await createDefaultPromotion(service, {
|
||||
code: "NO_RULES_PROMO",
|
||||
is_automatic: true,
|
||||
rules: [], // No global rules - always applicable
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt0"], // Only applies to product 0
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Promotion matching customer group VIP1
|
||||
await createDefaultPromotion(service, {
|
||||
code: "CUSTOMER_GROUP_PROMO",
|
||||
is_automatic: true,
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP1"], // Matches our test customer
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt1"], // Only applies to product 1
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Promotion with subtotal rule (should match items with subtotal > 50)
|
||||
await createDefaultPromotion(service, {
|
||||
code: "SUBTOTAL_PROMO",
|
||||
is_automatic: true,
|
||||
rules: [
|
||||
{
|
||||
attribute: "items.subtotal",
|
||||
operator: "gt",
|
||||
values: ["50"], // All our items have subtotal > 50
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [], // No target rules - applies to all items
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Promotion matching customer.id
|
||||
await createDefaultPromotion(service, {
|
||||
code: "CUSTOMER_ID_PROMO",
|
||||
is_automatic: true,
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.id",
|
||||
operator: "in",
|
||||
values: ["customer"], // Matches our test customer
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 250, // Different value to distinguish
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt9"], // Only applies to product 9
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Promotion that should NOT match (different customer group)
|
||||
await createDefaultPromotion(service, {
|
||||
code: "NO_MATCH_PROMO",
|
||||
is_automatic: true,
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP99"], // Different customer group - won't match
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt0"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// 6. Non-automatic promotion (should be excluded from automatic processing)
|
||||
await createDefaultPromotion(service, {
|
||||
code: "NON_AUTO_PROMO",
|
||||
is_automatic: false, // Not automatic
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP1"], // Would match but not automatic
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt0"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// 6. Non-automatic promotion that do not match any rules (should be excluded from automatic processing and internal pre filtering)
|
||||
await createDefaultPromotion(service, {
|
||||
code: "NON_AUTO_PROMO_2",
|
||||
is_automatic: false, // Not automatic
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP99"], // Would not match our customer group
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt0"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Spy on the internal promotion service to verify prefiltering
|
||||
let prefilterCallCount = 0
|
||||
let prefilteredPromotions: any[] = []
|
||||
const originalPromotionServiceList = (service as any).promotionService_
|
||||
.list
|
||||
|
||||
;(service as any).promotionService_.list = async (...args: any[]) => {
|
||||
const result = await originalPromotionServiceList.bind(
|
||||
(service as any).promotionService_
|
||||
)(...args)
|
||||
|
||||
if (prefilterCallCount === 0) {
|
||||
prefilteredPromotions = result
|
||||
}
|
||||
prefilterCallCount++
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Test Context: Customer with specific attributes
|
||||
const testContext = {
|
||||
currency_code: "usd",
|
||||
customer: {
|
||||
id: "customer", // Matches CUSTOMER_ID_PROMO
|
||||
customer_group: {
|
||||
id: "VIP1", // Matches CUSTOMER_GROUP_PROMO
|
||||
},
|
||||
},
|
||||
region_id: "region_1",
|
||||
items: [
|
||||
{
|
||||
id: "item_tshirt0",
|
||||
quantity: 1,
|
||||
subtotal: 100, // > 50, matches SUBTOTAL_PROMO
|
||||
product: { id: "prod_tshirt0" }, // Matches NO_RULES_PROMO target
|
||||
},
|
||||
{
|
||||
id: "item_tshirt1",
|
||||
quantity: 1,
|
||||
subtotal: 100, // > 50, matches SUBTOTAL_PROMO
|
||||
product: { id: "prod_tshirt1" }, // Matches CUSTOMER_GROUP_PROMO target
|
||||
},
|
||||
{
|
||||
id: "item_tshirt9",
|
||||
quantity: 5,
|
||||
subtotal: 750, // > 50, matches SUBTOTAL_PROMO
|
||||
product: { id: "prod_tshirt9" }, // Matches CUSTOMER_ID_PROMO target
|
||||
},
|
||||
{
|
||||
id: "item_unknown",
|
||||
quantity: 1,
|
||||
subtotal: 110, // > 50, matches SUBTOTAL_PROMO
|
||||
product: { id: "prod_unknown" }, // No specific target rules match
|
||||
},
|
||||
] as any,
|
||||
}
|
||||
|
||||
const actions = await service.computeActions([], testContext)
|
||||
|
||||
;(service as any).promotionService_.list = originalPromotionServiceList
|
||||
|
||||
// 1. Verify prefiltering worked - should include matching promotions
|
||||
expect(prefilteredPromotions).toHaveLength(4)
|
||||
const prefilteredCodes = prefilteredPromotions.map((p) => p.code)
|
||||
expect(prefilteredCodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"NO_RULES_PROMO", // No rules - always included
|
||||
"CUSTOMER_GROUP_PROMO", // customer.customer_group.id = VIP1
|
||||
"SUBTOTAL_PROMO", // items.subtotal > 50
|
||||
"CUSTOMER_ID_PROMO", // customer.id = customer
|
||||
])
|
||||
)
|
||||
|
||||
expect(actions).toHaveLength(4)
|
||||
|
||||
const actionsByCode = JSON.parse(JSON.stringify(actions)).reduce(
|
||||
(acc, action) => {
|
||||
if (!acc[action.code]) acc[action.code] = []
|
||||
acc[action.code].push(action)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any[]>
|
||||
)
|
||||
|
||||
// NO_RULES_PROMO: Applies to item_tshirt0 (product.id = prod_tshirt0)
|
||||
expect(actionsByCode["NO_RULES_PROMO"]).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_tshirt0",
|
||||
amount: 100,
|
||||
code: "NO_RULES_PROMO",
|
||||
}),
|
||||
])
|
||||
|
||||
// CUSTOMER_GROUP_PROMO: Applies to item_tshirt1 (product.id = prod_tshirt1)
|
||||
expect(actionsByCode["CUSTOMER_GROUP_PROMO"]).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_tshirt1",
|
||||
amount: 100,
|
||||
code: "CUSTOMER_GROUP_PROMO",
|
||||
}),
|
||||
])
|
||||
|
||||
// SUBTOTAL_PROMO: (no target rules)
|
||||
expect(actionsByCode["SUBTOTAL_PROMO"]).toHaveLength(1)
|
||||
expect(actionsByCode["SUBTOTAL_PROMO"]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ item_id: "item_unknown", amount: 100 }),
|
||||
])
|
||||
)
|
||||
|
||||
// CUSTOMER_ID_PROMO: Applies to item_tshirt9 (product.id = prod_tshirt9)
|
||||
expect(actionsByCode["CUSTOMER_ID_PROMO"]).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_tshirt9",
|
||||
amount: 750,
|
||||
code: "CUSTOMER_ID_PROMO",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle prefiltering of many automatic promotions targetting customers and regions with only one that is relevant", async () => {
|
||||
const promotionToCreate: CreatePromotionDTO[] = []
|
||||
// I ve also tested with 20k and the compute actions takes 200/300ms
|
||||
for (let i = 0; i < 100; i++) {
|
||||
promotionToCreate.push({
|
||||
code: "CUSTOMER_PROMO_" + i,
|
||||
is_automatic: true,
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.id",
|
||||
operator: "eq",
|
||||
values: ["customer" + i], // Matches our test customer1
|
||||
},
|
||||
{
|
||||
attribute: "region_id",
|
||||
operator: "eq",
|
||||
values: ["region_1"], // matches our region
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt0"], // Only applies to product 0
|
||||
},
|
||||
],
|
||||
},
|
||||
type: "standard",
|
||||
status: PromotionStatus.ACTIVE,
|
||||
campaign_id: "campaign-id-1",
|
||||
})
|
||||
}
|
||||
|
||||
await service.createPromotions(promotionToCreate)
|
||||
|
||||
// Spy on the internal promotion service to verify prefiltering
|
||||
let prefilterCallCount = 0
|
||||
let prefilteredPromotions: any[] = []
|
||||
const originalPromotionServiceList = (service as any).promotionService_
|
||||
.list
|
||||
|
||||
;(service as any).promotionService_.list = async (...args: any[]) => {
|
||||
const result = await originalPromotionServiceList.bind(
|
||||
(service as any).promotionService_
|
||||
)(...args)
|
||||
|
||||
if (prefilterCallCount === 0) {
|
||||
prefilteredPromotions = result
|
||||
}
|
||||
prefilterCallCount++
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Test Context: Customer with specific attributes
|
||||
const testContext = {
|
||||
currency_code: "usd",
|
||||
customer: {
|
||||
id: "customer1", // Matches CUSTOMER_PROMO_1
|
||||
},
|
||||
region_id: "region_1",
|
||||
items: [
|
||||
{
|
||||
id: "item_tshirt0",
|
||||
quantity: 1,
|
||||
subtotal: 100,
|
||||
product: { id: "prod_tshirt0" }, // Matches CUSTOMER_PROMO_1 target
|
||||
},
|
||||
{
|
||||
id: "item_tshirt1",
|
||||
quantity: 1,
|
||||
subtotal: 100,
|
||||
product: { id: "prod_tshirt1" },
|
||||
},
|
||||
{
|
||||
id: "item_tshirt9",
|
||||
quantity: 5,
|
||||
subtotal: 750,
|
||||
product: { id: "prod_tshirt9" },
|
||||
},
|
||||
{
|
||||
id: "item_unknown",
|
||||
quantity: 1,
|
||||
subtotal: 110,
|
||||
product: { id: "prod_unknown" },
|
||||
},
|
||||
] as any,
|
||||
}
|
||||
|
||||
const actions = await service.computeActions([], testContext)
|
||||
|
||||
;(service as any).promotionService_.list = originalPromotionServiceList
|
||||
|
||||
// 1. Verify prefiltering worked - should include matching promotion
|
||||
// We expect the prefilter to have return a single promotion that is being satisfied by the
|
||||
// context with the given customer id and region id
|
||||
expect(prefilteredPromotions).toHaveLength(1)
|
||||
const prefilteredCodes = prefilteredPromotions.map((p) => p.code)
|
||||
expect(prefilteredCodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"CUSTOMER_PROMO_1", // customer.id = customer1
|
||||
])
|
||||
)
|
||||
|
||||
expect(actions).toHaveLength(1)
|
||||
|
||||
const actionsByCode = JSON.parse(JSON.stringify(actions)).reduce(
|
||||
(acc, action) => {
|
||||
if (!acc[action.code]) acc[action.code] = []
|
||||
acc[action.code].push(action)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any[]>
|
||||
)
|
||||
|
||||
// CUSTOMER_PROMO_1: Applies to item_tshirt0 (product.id = prod_tshirt0)
|
||||
expect(actionsByCode["CUSTOMER_PROMO_1"]).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_tshirt0",
|
||||
amount: 100,
|
||||
code: "CUSTOMER_PROMO_1",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle prefiltering of many automatic promotions targetting customers and regions while no context attribute can satisfies any of those rules", async () => {
|
||||
const promotionToCreate: CreatePromotionDTO[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
promotionToCreate.push({
|
||||
code: "CUSTOMER_PROMO_" + i,
|
||||
is_automatic: true,
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer.id",
|
||||
operator: "eq",
|
||||
values: ["customer" + i], // Matches our test customer1
|
||||
},
|
||||
{
|
||||
attribute: "region_id",
|
||||
operator: "eq",
|
||||
values: ["region_1"], // matches our region
|
||||
},
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
max_quantity: 100000,
|
||||
value: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt0"], // Only applies to product 0
|
||||
},
|
||||
],
|
||||
},
|
||||
type: "standard",
|
||||
status: PromotionStatus.ACTIVE,
|
||||
campaign_id: "campaign-id-1",
|
||||
})
|
||||
}
|
||||
|
||||
await service.createPromotions(promotionToCreate)
|
||||
|
||||
// Spy on the internal promotion service to verify prefiltering
|
||||
let prefilterCallCount = 0
|
||||
let prefilteredPromotions: any[] = []
|
||||
const originalPromotionServiceList = (service as any).promotionService_
|
||||
.list
|
||||
|
||||
;(service as any).promotionService_.list = async (...args: any[]) => {
|
||||
const result = await originalPromotionServiceList.bind(
|
||||
(service as any).promotionService_
|
||||
)(...args)
|
||||
|
||||
if (prefilterCallCount === 0) {
|
||||
prefilteredPromotions = result
|
||||
}
|
||||
prefilterCallCount++
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Test Context
|
||||
const testContext = {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_tshirt0",
|
||||
quantity: 1,
|
||||
subtotal: 100,
|
||||
product: { id: "prod_tshirt0" }, // Matches CUSTOMER_PROMO_1 target
|
||||
},
|
||||
{
|
||||
id: "item_tshirt1",
|
||||
quantity: 1,
|
||||
subtotal: 100,
|
||||
product: { id: "prod_tshirt1" },
|
||||
},
|
||||
{
|
||||
id: "item_tshirt9",
|
||||
quantity: 5,
|
||||
subtotal: 750,
|
||||
product: { id: "prod_tshirt9" },
|
||||
},
|
||||
{
|
||||
id: "item_unknown",
|
||||
quantity: 1,
|
||||
subtotal: 110,
|
||||
product: { id: "prod_unknown" },
|
||||
},
|
||||
] as any,
|
||||
}
|
||||
|
||||
const actions = await service.computeActions([], testContext)
|
||||
|
||||
;(service as any).promotionService_.list = originalPromotionServiceList
|
||||
|
||||
// 1. Verify prefiltering worked - should include matching promotion
|
||||
// We expect the prefilter to have return 0 promotion that is being satisfied by the
|
||||
expect(prefilteredPromotions).toHaveLength(0)
|
||||
|
||||
expect(actions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return empty array when promotion is not active (draft or inactive)", async () => {
|
||||
const promotion = await createDefaultPromotion(service, {
|
||||
status: PromotionStatus.DRAFT,
|
||||
@@ -2865,7 +3402,7 @@ moduleIntegrationTestRunner({
|
||||
])
|
||||
})
|
||||
|
||||
it("should compute the correct shipping_method amendments when promotion is automatic and prevent_auto_promotions is false", async () => {
|
||||
it("should compute the correct shipping_method amendments when promotion is automatic and prevent_auto_promotions is true", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
rules: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user