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:
1 parent
76497fd40a
commit
57897c232e
10 files changed
+851
-7
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/promotion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Feat(): promo prepare top level rules filter
|
||||||
+539
-2
@@ -1,4 +1,7 @@
|
|||||||
import { IPromotionModuleService } from "@medusajs/framework/types"
|
import {
|
||||||
|
CreatePromotionDTO,
|
||||||
|
IPromotionModuleService,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
import {
|
import {
|
||||||
ApplicationMethodType,
|
ApplicationMethodType,
|
||||||
Modules,
|
Modules,
|
||||||
@@ -22,6 +25,540 @@ moduleIntegrationTestRunner({
|
|||||||
await createCampaigns(MikroOrmWrapper.forkManager())
|
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 () => {
|
it("should return empty array when promotion is not active (draft or inactive)", async () => {
|
||||||
const promotion = await createDefaultPromotion(service, {
|
const promotion = await createDefaultPromotion(service, {
|
||||||
status: PromotionStatus.DRAFT,
|
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, {
|
await createDefaultPromotion(service, {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -460,6 +460,15 @@
|
|||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_unique_promotion_code\" ON \"promotion\" (code) WHERE deleted_at IS NULL"
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_unique_promotion_code\" ON \"promotion\" (code) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_promotion_is_automatic",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_is_automatic\" ON \"promotion\" (is_automatic) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"keyName": "promotion_pkey",
|
"keyName": "promotion_pkey",
|
||||||
"columnNames": [
|
"columnNames": [
|
||||||
@@ -832,6 +841,15 @@
|
|||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_deleted_at\" ON \"promotion_rule\" (deleted_at) WHERE deleted_at IS NULL"
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_deleted_at\" ON \"promotion_rule\" (deleted_at) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_promotion_rule_attribute_operator",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_attribute_operator\" ON \"promotion_rule\" (attribute, operator) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"keyName": "promotion_rule_pkey",
|
"keyName": "promotion_rule_pkey",
|
||||||
"columnNames": [
|
"columnNames": [
|
||||||
@@ -1131,6 +1149,24 @@
|
|||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_deleted_at\" ON \"promotion_rule_value\" (deleted_at) WHERE deleted_at IS NULL"
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_deleted_at\" ON \"promotion_rule_value\" (deleted_at) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_promotion_rule_value_rule_id_value",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_rule_id_value\" ON \"promotion_rule_value\" (promotion_rule_id, value) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_promotion_rule_value_value",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_value\" ON \"promotion_rule_value\" (value) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"keyName": "promotion_rule_value_pkey",
|
"keyName": "promotion_rule_value_pkey",
|
||||||
"columnNames": [
|
"columnNames": [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
|
||||||
|
export class Migration20250916120552 extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_attribute_operator" ON "promotion_rule" (attribute, operator) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop index if exists "IDX_promotion_rule_attribute_operator";`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20250917143818 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_is_automatic" ON "promotion" (is_automatic) WHERE deleted_at IS NULL;`);
|
||||||
|
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_value_rule_id_value" ON "promotion_rule_value" (promotion_rule_id, value) WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_value_value" ON "promotion_rule_value" (value) WHERE deleted_at IS NULL;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop index if exists "IDX_promotion_is_automatic";`);
|
||||||
|
|
||||||
|
this.addSql(`drop index if exists "IDX_promotion_rule_value_rule_id_value";`);
|
||||||
|
this.addSql(`drop index if exists "IDX_promotion_rule_value_value";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { model } from "@medusajs/framework/utils"
|
import { model } from "@medusajs/framework/utils"
|
||||||
import PromotionRule from "./promotion-rule"
|
import PromotionRule from "./promotion-rule"
|
||||||
|
|
||||||
const PromotionRuleValue = model.define(
|
const PromotionRuleValue = model
|
||||||
|
.define(
|
||||||
{ name: "PromotionRuleValue", tableName: "promotion_rule_value" },
|
{ name: "PromotionRuleValue", tableName: "promotion_rule_value" },
|
||||||
{
|
{
|
||||||
id: model.id({ prefix: "prorulval" }).primaryKey(),
|
id: model.id({ prefix: "prorulval" }).primaryKey(),
|
||||||
@@ -11,5 +12,19 @@ const PromotionRuleValue = model.define(
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.indexes([
|
||||||
|
{
|
||||||
|
name: "IDX_promotion_rule_value_rule_id_value",
|
||||||
|
on: ["promotion_rule_id", "value"],
|
||||||
|
unique: false,
|
||||||
|
where: "deleted_at IS NULL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDX_promotion_rule_value_value",
|
||||||
|
on: ["value"],
|
||||||
|
unique: false,
|
||||||
|
where: "deleted_at IS NULL",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
export default PromotionRuleValue
|
export default PromotionRuleValue
|
||||||
@@ -30,6 +30,13 @@ const PromotionRule = model
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.indexes([
|
||||||
|
{
|
||||||
|
on: ["attribute", "operator"],
|
||||||
|
unique: false,
|
||||||
|
where: "deleted_at IS NULL",
|
||||||
|
},
|
||||||
|
])
|
||||||
.cascades({
|
.cascades({
|
||||||
delete: ["values"],
|
delete: ["values"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ const Promotion = model
|
|||||||
where: "deleted_at IS NULL",
|
where: "deleted_at IS NULL",
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "IDX_promotion_is_automatic",
|
||||||
|
on: ["is_automatic"],
|
||||||
|
unique: false,
|
||||||
|
where: "deleted_at IS NULL",
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
export default Promotion
|
export default Promotion
|
||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
} from "@utils"
|
} from "@utils"
|
||||||
import { joinerConfig } from "../joiner-config"
|
import { joinerConfig } from "../joiner-config"
|
||||||
import { CreatePromotionRuleValueDTO } from "../types/promotion-rule-value"
|
import { CreatePromotionRuleValueDTO } from "../types/promotion-rule-value"
|
||||||
|
import { buildPromotionRuleQueryFilterFromContext } from "../utils/compute-actions/build-promotion-rule-query-filter-from-context"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
baseRepository: DAL.RepositoryService
|
baseRepository: DAL.RepositoryService
|
||||||
@@ -461,10 +462,39 @@ export default class PromotionModuleService
|
|||||||
|
|
||||||
const uniquePromotionCodes = Array.from(new Set(promotionCodesToApply))
|
const uniquePromotionCodes = Array.from(new Set(promotionCodesToApply))
|
||||||
|
|
||||||
const queryFilter = preventAutoPromotions
|
let queryFilter: DAL.FilterQuery<any> = { code: uniquePromotionCodes }
|
||||||
? { code: uniquePromotionCodes }
|
|
||||||
: {
|
if (!preventAutoPromotions) {
|
||||||
$or: [{ code: uniquePromotionCodes }, { is_automatic: true }],
|
const rulePrefilteringFilters =
|
||||||
|
buildPromotionRuleQueryFilterFromContext(applicationContext)
|
||||||
|
|
||||||
|
let prefilteredAutomaticPromotionIds: string[] = []
|
||||||
|
|
||||||
|
if (rulePrefilteringFilters) {
|
||||||
|
const promotions = await this.promotionService_.list(
|
||||||
|
{
|
||||||
|
$and: [{ is_automatic: true }, rulePrefilteringFilters],
|
||||||
|
},
|
||||||
|
{ select: ["id"] },
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
prefilteredAutomaticPromotionIds = promotions.map(
|
||||||
|
(promotion) => promotion.id!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const automaticPromotionFilter = rulePrefilteringFilters
|
||||||
|
? {
|
||||||
|
id: { $in: prefilteredAutomaticPromotionIds },
|
||||||
|
}
|
||||||
|
: { is_automatic: true }
|
||||||
|
|
||||||
|
queryFilter = automaticPromotionFilter
|
||||||
|
? {
|
||||||
|
$or: [{ code: uniquePromotionCodes }, automaticPromotionFilter],
|
||||||
|
}
|
||||||
|
: queryFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
const promotions = await this.listActivePromotions_(
|
const promotions = await this.listActivePromotions_(
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
ComputeActionContext,
|
||||||
|
ComputeActionItemLine,
|
||||||
|
ComputeActionShippingLine,
|
||||||
|
DAL,
|
||||||
|
PromotionTypes,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
|
import { flattenObjectToKeyValuePairs } from "@medusajs/framework/utils"
|
||||||
|
import { raw } from "@mikro-orm/postgresql"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a query filter for promotion rules based on the context.
|
||||||
|
* This is used to prefilter promotions before computing actions.
|
||||||
|
* The idea is that we first retrieve from the database the promotions where all rules can be
|
||||||
|
* satisfied by the given context. We exclude promotions that have any rule that cannot be satisfied.
|
||||||
|
*
|
||||||
|
* @param context
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function buildPromotionRuleQueryFilterFromContext(
|
||||||
|
context: PromotionTypes.ComputeActionContext
|
||||||
|
): DAL.FilterQuery<any> | null {
|
||||||
|
const {
|
||||||
|
items = [],
|
||||||
|
shipping_methods: shippingMethods = [],
|
||||||
|
...restContext
|
||||||
|
} = context
|
||||||
|
|
||||||
|
let flattenItemsPropsValuesArray = flattenObjectToKeyValuePairs(
|
||||||
|
items
|
||||||
|
) as Record<keyof ComputeActionItemLine & string, any>
|
||||||
|
flattenItemsPropsValuesArray = Object.fromEntries(
|
||||||
|
Object.entries(flattenItemsPropsValuesArray).map(([k, v]) => [
|
||||||
|
`items.${k}`,
|
||||||
|
v,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
let flattenShippingMethodsPropsValuesArray = flattenObjectToKeyValuePairs(
|
||||||
|
shippingMethods
|
||||||
|
) as Record<keyof ComputeActionShippingLine & string, any>
|
||||||
|
flattenShippingMethodsPropsValuesArray = Object.fromEntries(
|
||||||
|
Object.entries(flattenShippingMethodsPropsValuesArray).map(([k, v]) => [
|
||||||
|
`shipping_methods.${k}`,
|
||||||
|
v,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
const flattenRestContextPropsValuesArray = flattenObjectToKeyValuePairs(
|
||||||
|
restContext
|
||||||
|
) as Record<keyof ComputeActionContext & string, any>
|
||||||
|
|
||||||
|
const attributeValueMap = new Map<string, Set<any>>()
|
||||||
|
|
||||||
|
;[
|
||||||
|
flattenItemsPropsValuesArray,
|
||||||
|
flattenShippingMethodsPropsValuesArray,
|
||||||
|
flattenRestContextPropsValuesArray,
|
||||||
|
].forEach((flattenedArray) => {
|
||||||
|
Object.entries(flattenedArray).forEach(([prop, value]) => {
|
||||||
|
if (!attributeValueMap.has(prop)) {
|
||||||
|
attributeValueMap.set(prop, new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Array.isArray(value) ? value : [value]
|
||||||
|
values.forEach((v) => attributeValueMap.get(prop)!.add(v))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build conditions for a NOT EXISTS subquery to exclude promotions with unsatisfiable rules
|
||||||
|
const sqlConditions: string[] = []
|
||||||
|
|
||||||
|
// First, check for rules where the attribute doesn't exist in context at all
|
||||||
|
// These rules can never be satisfied
|
||||||
|
sqlConditions.push(
|
||||||
|
`pr.attribute NOT IN (${Array.from(attributeValueMap.keys())
|
||||||
|
.map((attr) => `'${attr.replace(/'/g, "''")}'`)
|
||||||
|
.join(",")})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then, for attributes that exist in context, check if the values don't satisfy the rules
|
||||||
|
attributeValueMap.forEach((valueSet, attribute) => {
|
||||||
|
const values = Array.from(valueSet)
|
||||||
|
const stringValues = values
|
||||||
|
.map((v) => `'${v.toString().replace(/'/g, "''")}'`)
|
||||||
|
.join(",")
|
||||||
|
|
||||||
|
const numericValues = values
|
||||||
|
.map((v) => {
|
||||||
|
const num = Number(v)
|
||||||
|
return !isNaN(num) ? num : null
|
||||||
|
})
|
||||||
|
.filter((v) => v !== null) as number[]
|
||||||
|
|
||||||
|
// Escape attribute name to prevent SQL injection
|
||||||
|
const escapedAttribute = `'${attribute.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
// For 'in' and 'eq' operators - rule is unsatisfiable if NO rule values overlap with context
|
||||||
|
// This requires checking that ALL rule values for a given rule are not in context
|
||||||
|
if (stringValues.length) {
|
||||||
|
sqlConditions.push(
|
||||||
|
`(pr.attribute = ${escapedAttribute} AND pr.operator IN ('in', 'eq') AND pr.id NOT IN (
|
||||||
|
SELECT DISTINCT prv_inner.promotion_rule_id
|
||||||
|
FROM promotion_rule_value prv_inner
|
||||||
|
WHERE prv_inner.value IN (${stringValues})
|
||||||
|
))`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericValues.length) {
|
||||||
|
const minValue = Math.min(...numericValues)
|
||||||
|
const maxValue = Math.max(...numericValues)
|
||||||
|
|
||||||
|
// For gt - rule is unsatisfiable if rule_value >= context_max_value
|
||||||
|
sqlConditions.push(
|
||||||
|
`(pr.attribute = ${escapedAttribute} AND pr.operator = 'gt' AND CAST(prv.value AS DECIMAL) >= ${maxValue})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// For gte - rule is unsatisfiable if rule_value > context_max_value
|
||||||
|
sqlConditions.push(
|
||||||
|
`(pr.attribute = ${escapedAttribute} AND pr.operator = 'gte' AND prv.value NOT IN (${stringValues}) AND CAST(prv.value AS DECIMAL) > ${maxValue})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// For lt - rule is unsatisfiable if rule_value <= context_min_value
|
||||||
|
sqlConditions.push(
|
||||||
|
`(pr.attribute = ${escapedAttribute} AND pr.operator = 'lt' AND CAST(prv.value AS DECIMAL) <= ${minValue})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// For lte - rule is unsatisfiable if rule_value < context_min_value
|
||||||
|
sqlConditions.push(
|
||||||
|
`(pr.attribute = ${escapedAttribute} AND pr.operator = 'lte' AND prv.value NOT IN (${stringValues}) AND CAST(prv.value AS DECIMAL) < ${minValue})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle the case where context has no attributes at all, it means
|
||||||
|
// that any promotion that have a rule cant be satisfied by the context
|
||||||
|
if (attributeValueMap.size === 0) {
|
||||||
|
// If context has no attributes, exclude all promotions that have any rules
|
||||||
|
const notExistsSubquery = (alias: string) =>
|
||||||
|
`
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM promotion_promotion_rule ppr
|
||||||
|
WHERE ppr.promotion_id = ${alias}.id
|
||||||
|
)
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
[raw((alias) => notExistsSubquery(alias))]: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinedConditions = sqlConditions.join(" OR ")
|
||||||
|
const queryEstimatedSize = joinedConditions.length
|
||||||
|
const maxQuerySize = 2147483648 * 0.9
|
||||||
|
|
||||||
|
if (queryEstimatedSize > maxQuerySize) {
|
||||||
|
// generated query could be too long
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const notExistsSubquery = (alias: string) =>
|
||||||
|
`
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM promotion_promotion_rule ppr
|
||||||
|
JOIN promotion_rule pr ON ppr.promotion_rule_id = pr.id
|
||||||
|
LEFT JOIN promotion_rule_value prv ON prv.promotion_rule_id = pr.id
|
||||||
|
WHERE ppr.promotion_id = ${alias}.id
|
||||||
|
AND (${joinedConditions})
|
||||||
|
)
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
[raw((alias) => notExistsSubquery(alias))]: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user