@pisell/pisellos 2.2.259 → 2.2.261

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/model/strategy/adapter/promotion/adapter.d.ts +4 -0
  2. package/dist/model/strategy/adapter/promotion/adapter.js +23 -0
  3. package/dist/model/strategy/adapter/promotion/evaluator.d.ts +17 -0
  4. package/dist/model/strategy/adapter/promotion/evaluator.js +279 -6
  5. package/dist/model/strategy/adapter/promotion/examples.d.ts +86 -0
  6. package/dist/model/strategy/adapter/promotion/examples.js +99 -0
  7. package/dist/model/strategy/adapter/promotion/index.d.ts +1 -1
  8. package/dist/model/strategy/adapter/promotion/index.js +1 -1
  9. package/dist/model/strategy/adapter/promotion/type.d.ts +90 -2
  10. package/dist/model/strategy/adapter/promotion/type.js +45 -0
  11. package/dist/modules/Order/index.d.ts +1 -1
  12. package/dist/modules/Order/utils.js +2 -1
  13. package/dist/modules/Product/index.d.ts +1 -1
  14. package/dist/modules/Rules/index.d.ts +1 -0
  15. package/dist/modules/Rules/index.js +34 -14
  16. package/dist/solution/BaseSales/utils/cartPromotion.d.ts +3 -1
  17. package/dist/solution/BaseSales/utils/cartPromotion.js +74 -29
  18. package/dist/solution/BookingByStep/index.d.ts +1 -1
  19. package/dist/solution/BookingTicket/index.d.ts +1 -1
  20. package/dist/solution/ScanOrder/index.js +31 -20
  21. package/dist/solution/VenueBooking/index.js +30 -19
  22. package/lib/model/strategy/adapter/promotion/adapter.d.ts +4 -0
  23. package/lib/model/strategy/adapter/promotion/adapter.js +20 -0
  24. package/lib/model/strategy/adapter/promotion/evaluator.d.ts +17 -0
  25. package/lib/model/strategy/adapter/promotion/evaluator.js +235 -0
  26. package/lib/model/strategy/adapter/promotion/examples.d.ts +86 -0
  27. package/lib/model/strategy/adapter/promotion/examples.js +91 -0
  28. package/lib/model/strategy/adapter/promotion/index.d.ts +1 -1
  29. package/lib/model/strategy/adapter/promotion/index.js +2 -0
  30. package/lib/model/strategy/adapter/promotion/type.d.ts +90 -2
  31. package/lib/model/strategy/adapter/promotion/type.js +2 -0
  32. package/lib/modules/Order/index.d.ts +1 -1
  33. package/lib/modules/Order/utils.js +6 -1
  34. package/lib/modules/Product/index.d.ts +1 -1
  35. package/lib/modules/Rules/index.d.ts +1 -0
  36. package/lib/modules/Rules/index.js +23 -4
  37. package/lib/solution/BaseSales/utils/cartPromotion.d.ts +3 -1
  38. package/lib/solution/BaseSales/utils/cartPromotion.js +59 -16
  39. package/lib/solution/BookingByStep/index.d.ts +1 -1
  40. package/lib/solution/BookingTicket/index.d.ts +1 -1
  41. package/lib/solution/ScanOrder/index.js +8 -0
  42. package/lib/solution/VenueBooking/index.js +8 -0
  43. package/package.json +1 -1
@@ -133,6 +133,9 @@ var PromotionEvaluator = class {
133
133
  for (const product of products) {
134
134
  const applicablePromotions = [];
135
135
  for (const config of configs) {
136
+ if (this.shouldSkipStrategyForProduct(product, config)) {
137
+ continue;
138
+ }
136
139
  const matchInfo = this.getProductMatchInfo(product, config);
137
140
  if (!matchInfo.isMatch) {
138
141
  continue;
@@ -296,6 +299,16 @@ var PromotionEvaluator = class {
296
299
  if (result.giftInfo) {
297
300
  gifts.push(result.giftInfo);
298
301
  }
302
+ } else if (actionType === import_type.PROMOTION_ACTION_TYPES.ITEM_REWARD) {
303
+ const result = this.processItemReward(
304
+ group,
305
+ processedQuantityMap,
306
+ products,
307
+ matchedBundleIndexMap
308
+ );
309
+ pricedProducts.push(...result.products);
310
+ totalOriginalAmount = totalOriginalAmount.plus(result.originalAmount);
311
+ totalFinalAmount = totalFinalAmount.plus(result.finalAmount);
299
312
  }
300
313
  }
301
314
  const remainingProducts = this.getRemainingProducts(
@@ -379,6 +392,9 @@ var PromotionEvaluator = class {
379
392
  for (const product of products) {
380
393
  const applicableStrategies = [];
381
394
  for (const config of configs) {
395
+ if (this.shouldSkipStrategyForProduct(product, config)) {
396
+ continue;
397
+ }
382
398
  let contextProduct = product;
383
399
  if (!matchVariant) {
384
400
  const productMatchRule = this.findProductMatchRule(config);
@@ -455,6 +471,9 @@ var PromotionEvaluator = class {
455
471
  } else if (actionType === "X_ITEMS_FOR_Y_PRICE") {
456
472
  const detail = actionDetail;
457
473
  requiredQuantity = detail.x || 0;
474
+ } else if (actionType === "ITEM_REWARD") {
475
+ const detail = actionDetail;
476
+ requiredQuantity = detail.triggerQuantity || 1;
458
477
  }
459
478
  const productMatchRule = this.findProductMatchRule(config);
460
479
  const eligibleProducts = (productMatchRule == null ? void 0 : productMatchRule.value) || [];
@@ -768,6 +787,213 @@ var PromotionEvaluator = class {
768
787
  giftInfo
769
788
  };
770
789
  }
790
+ /**
791
+ * 处理 商品奖励 促销
792
+ *
793
+ * 1. 使用 group.products 统计触发条件商品数量
794
+ * 2. 按 quantityRule 计算最多奖励的目标商品数量
795
+ * 3. 在全购物车商品中筛选 rewardTargets
796
+ * 4. 按消费者最优(可节省金额从高到低)选择目标单位
797
+ * 5. 对目标商品拆分并写入 finalPrice,触发商品本身不改价
798
+ */
799
+ processItemReward(group, processedQuantityMap, allProducts, matchedBundleIndexMap) {
800
+ const {
801
+ strategyId,
802
+ strategyMetadata,
803
+ actionDetail,
804
+ products: triggerProducts
805
+ } = group;
806
+ const detail = actionDetail;
807
+ const triggerQuantity = this.normalizePositiveInteger(detail.triggerQuantity, 1);
808
+ const rewardQuantity = this.normalizePositiveInteger(detail.rewardQuantity, 1);
809
+ const conditionQuantity = this.getAvailablePromotionQuantity(
810
+ triggerProducts,
811
+ processedQuantityMap,
812
+ matchedBundleIndexMap
813
+ );
814
+ if (conditionQuantity < triggerQuantity) {
815
+ return { products: [], originalAmount: 0, finalAmount: 0 };
816
+ }
817
+ const targetProducts = allProducts.filter(
818
+ (product) => this.isProductMatch(product, detail.rewardTargets || [])
819
+ );
820
+ const targetUnitCandidates = this.buildItemRewardTargetUnits(
821
+ targetProducts,
822
+ processedQuantityMap,
823
+ detail
824
+ );
825
+ if (targetUnitCandidates.length === 0) {
826
+ return { products: [], originalAmount: 0, finalAmount: 0 };
827
+ }
828
+ const rewardLimit = this.resolveItemRewardLimit(
829
+ detail,
830
+ conditionQuantity,
831
+ triggerQuantity,
832
+ rewardQuantity,
833
+ targetUnitCandidates.length
834
+ );
835
+ if (rewardLimit <= 0) {
836
+ return { products: [], originalAmount: 0, finalAmount: 0 };
837
+ }
838
+ const selectedUnits = [...targetUnitCandidates].sort((a, b) => {
839
+ const discountDiff = b.discountAmount.minus(a.discountAmount).toNumber();
840
+ if (discountDiff !== 0)
841
+ return discountDiff;
842
+ return b.originalPrice.minus(a.originalPrice).toNumber();
843
+ }).slice(0, rewardLimit);
844
+ const selectedByProductId = /* @__PURE__ */ new Map();
845
+ for (const unit of selectedUnits) {
846
+ const existing = selectedByProductId.get(unit.product.id);
847
+ if (existing) {
848
+ existing.quantity += 1;
849
+ } else {
850
+ selectedByProductId.set(unit.product.id, {
851
+ quantity: 1,
852
+ finalPrice: unit.finalPrice,
853
+ originalPrice: unit.originalPrice
854
+ });
855
+ }
856
+ }
857
+ const result = [];
858
+ let originalAmount = new import_decimal.default(0);
859
+ let finalAmount = new import_decimal.default(0);
860
+ for (const product of targetProducts) {
861
+ const key = this.getProductKey(product);
862
+ const processedQty = processedQuantityMap.get(key) || 0;
863
+ const availableQty = product.quantity - processedQty;
864
+ if (availableQty <= 0)
865
+ continue;
866
+ const selected = selectedByProductId.get(product.id);
867
+ const selectedQty = (selected == null ? void 0 : selected.quantity) || 0;
868
+ const remainingQty = availableQty - selectedQty;
869
+ if (selectedQty <= 0)
870
+ continue;
871
+ if (selectedQty > 0) {
872
+ result.push({
873
+ ...product,
874
+ id: selectedQty === availableQty ? product.id : this.generateRandomId(),
875
+ originalId: product.id,
876
+ quantity: selectedQty,
877
+ finalPrice: this.formatPrice(selected.finalPrice),
878
+ strategyId,
879
+ strategyMetadata,
880
+ inPromotion: true,
881
+ isSplit: selectedQty !== product.quantity
882
+ });
883
+ originalAmount = originalAmount.plus(
884
+ selected.originalPrice.mul(selectedQty)
885
+ );
886
+ finalAmount = finalAmount.plus(selected.finalPrice.mul(selectedQty));
887
+ }
888
+ if (remainingQty > 0) {
889
+ result.push({
890
+ ...product,
891
+ quantity: remainingQty,
892
+ finalPrice: this.formatPrice(product.price),
893
+ strategyId: void 0,
894
+ strategyMetadata: void 0,
895
+ inPromotion: false,
896
+ isSplit: selectedQty > 0
897
+ });
898
+ const remainingAmount = new import_decimal.default(product.price).mul(remainingQty);
899
+ originalAmount = originalAmount.plus(remainingAmount);
900
+ finalAmount = finalAmount.plus(remainingAmount);
901
+ }
902
+ processedQuantityMap.set(key, processedQty + availableQty);
903
+ }
904
+ return {
905
+ products: result,
906
+ originalAmount: originalAmount.toNumber(),
907
+ finalAmount: finalAmount.toNumber()
908
+ };
909
+ }
910
+ normalizePositiveInteger(value, fallback) {
911
+ const n = Math.floor(Number(value));
912
+ return Number.isFinite(n) && n > 0 ? n : fallback;
913
+ }
914
+ getAvailablePromotionQuantity(products, processedQuantityMap, matchedBundleIndexMap) {
915
+ let total = 0;
916
+ for (const product of products) {
917
+ const key = this.getProductKey(product);
918
+ const processedQty = processedQuantityMap.get(key) || 0;
919
+ const availableQty = product.quantity - processedQty;
920
+ if (availableQty <= 0)
921
+ continue;
922
+ const matchedBundleIndex = matchedBundleIndexMap == null ? void 0 : matchedBundleIndexMap.get(product.id);
923
+ if (matchedBundleIndex !== void 0 && product.bundle) {
924
+ const bundleItem = product.bundle[matchedBundleIndex];
925
+ total += availableQty * ((bundleItem == null ? void 0 : bundleItem.quantity) || 1);
926
+ } else {
927
+ total += availableQty;
928
+ }
929
+ }
930
+ return total;
931
+ }
932
+ buildItemRewardTargetUnits(products, processedQuantityMap, detail) {
933
+ const units = [];
934
+ for (const product of products) {
935
+ const key = this.getProductKey(product);
936
+ const processedQty = processedQuantityMap.get(key) || 0;
937
+ const availableQty = product.quantity - processedQty;
938
+ if (availableQty <= 0)
939
+ continue;
940
+ const originalPrice = new import_decimal.default(product.price || 0);
941
+ const finalPrice = this.resolveItemRewardFinalPrice(originalPrice, detail);
942
+ const discountAmount = originalPrice.minus(finalPrice);
943
+ if (discountAmount.lte(0))
944
+ continue;
945
+ for (let i = 0; i < availableQty; i++) {
946
+ units.push({
947
+ product,
948
+ originalPrice,
949
+ finalPrice,
950
+ discountAmount
951
+ });
952
+ }
953
+ }
954
+ return units;
955
+ }
956
+ resolveItemRewardFinalPrice(originalPrice, detail) {
957
+ const rewardValue = new import_decimal.default(Number(detail.rewardValue) || 0);
958
+ let finalPrice;
959
+ switch (detail.rewardMethod) {
960
+ case "percent_off": {
961
+ const percent = import_decimal.default.min(100, import_decimal.default.max(0, rewardValue));
962
+ finalPrice = originalPrice.mul(new import_decimal.default(100).minus(percent)).div(100);
963
+ break;
964
+ }
965
+ case "amount_off":
966
+ finalPrice = originalPrice.minus(import_decimal.default.max(0, rewardValue));
967
+ break;
968
+ case "fixed_price":
969
+ finalPrice = import_decimal.default.max(0, rewardValue);
970
+ break;
971
+ case "free":
972
+ default:
973
+ finalPrice = new import_decimal.default(0);
974
+ break;
975
+ }
976
+ return import_decimal.default.max(0, import_decimal.default.min(originalPrice, finalPrice)).toDecimalPlaces(2, import_decimal.default.ROUND_HALF_UP);
977
+ }
978
+ resolveItemRewardLimit(detail, conditionQuantity, triggerQuantity, rewardQuantity, targetUnitCount) {
979
+ let limit = 0;
980
+ switch (detail.quantityRule) {
981
+ case "all_targets":
982
+ limit = targetUnitCount;
983
+ break;
984
+ case "max_per_order":
985
+ limit = this.normalizePositiveInteger(
986
+ detail.maxRewardQuantity ?? detail.rewardQuantity,
987
+ rewardQuantity
988
+ );
989
+ break;
990
+ case "per_condition_quantity":
991
+ default:
992
+ limit = Math.floor(conditionQuantity / triggerQuantity) * rewardQuantity;
993
+ break;
994
+ }
995
+ return Math.min(Math.max(0, limit), targetUnitCount);
996
+ }
771
997
  /**
772
998
  * 获取未参与任何促销的商品
773
999
  */
@@ -839,8 +1065,17 @@ var PromotionEvaluator = class {
839
1065
  * 检查商品是否在策略的适用范围内
840
1066
  */
841
1067
  isProductInStrategy(product, config) {
1068
+ if (this.shouldSkipStrategyForProduct(product, config)) {
1069
+ return false;
1070
+ }
842
1071
  return this.getProductMatchInfo(product, config).isMatch;
843
1072
  }
1073
+ shouldSkipStrategyForProduct(product, config) {
1074
+ return Boolean(product._promotionOnlyForItemReward) && !this.hasActionType(config, import_type.PROMOTION_ACTION_TYPES.ITEM_REWARD);
1075
+ }
1076
+ hasActionType(config, actionType) {
1077
+ return (config.actions || []).some((action) => (action == null ? void 0 : action.type) === actionType);
1078
+ }
844
1079
  /**
845
1080
  * 查找商品匹配规则
846
1081
  */
@@ -136,3 +136,89 @@ export declare const BUY_X_GET_Y_FREE_STRATEGY: {
136
136
  };
137
137
  }[];
138
138
  };
139
+ /**
140
+ * ITEM_REWARD 策略配置示例:儿童票触发婴儿票免费
141
+ *
142
+ * 业务规则:
143
+ * - 条件商品:儿童票(conditions.rules.productIdAndVariantId)
144
+ * - 奖励商品:购物车/订单内已经存在的婴儿票(actions[0].config.rewardTargets)
145
+ * - 本 action 不自动新增婴儿票;每次购物车变更后由统一促销链路重算
146
+ *
147
+ * 字段说明:
148
+ * - type: "ITEM_REWARD" 表示商品奖励 action
149
+ * - target: "product" 表示本期只奖励商品行
150
+ * - config.rewardTargets: 奖励对象数组,支持 { product_id, product_variant_id }
151
+ * - config.targetSelection: "consumer_best" 表示目标商品超额时选择节省金额最高的商品
152
+ * - value.rewardMethod:
153
+ * - "free": 目标商品最终价为 0
154
+ * - "percent_off": 百分比折扣,rewardValue 为 0-100
155
+ * - "amount_off": 每个目标商品固定减免 rewardValue
156
+ * - "fixed_price": 目标商品最终单价为 rewardValue
157
+ * - value.quantityRule:
158
+ * - "per_condition_quantity": 每满足 triggerQuantity 个条件商品,奖励 rewardQuantity 个目标商品
159
+ * - "all_targets": 满足条件后奖励全部目标商品
160
+ * - "max_per_order": 满足条件后每单最多奖励 maxRewardQuantity 个目标商品
161
+ */
162
+ export declare const ITEM_REWARD_STRATEGY: {
163
+ metadata: {
164
+ id: string;
165
+ name: {
166
+ en: string;
167
+ 'zh-CN': string;
168
+ 'zh-HK': string;
169
+ };
170
+ type: string;
171
+ custom: {
172
+ display: {
173
+ product_card: {
174
+ text: {
175
+ en: string;
176
+ 'zh-CN': string;
177
+ 'zh-HK': string;
178
+ };
179
+ type: string;
180
+ };
181
+ };
182
+ };
183
+ };
184
+ conditions: {
185
+ operator: string;
186
+ rules: ({
187
+ type: string;
188
+ field: string;
189
+ value: string;
190
+ operator: string;
191
+ } | {
192
+ type: string;
193
+ field: string;
194
+ value: {
195
+ product_id: number;
196
+ product_variant_id: number;
197
+ }[];
198
+ operator: string;
199
+ })[];
200
+ actionIds: string[];
201
+ };
202
+ actions: {
203
+ id: string;
204
+ type: string;
205
+ value: {
206
+ rewardMethod: string;
207
+ rewardValue: number;
208
+ quantityRule: string;
209
+ triggerQuantity: number;
210
+ rewardQuantity: number;
211
+ maxRewardQuantity: number;
212
+ };
213
+ valueType: string;
214
+ target: string;
215
+ priority: number;
216
+ config: {
217
+ rewardTargets: {
218
+ product_id: number;
219
+ product_variant_id: number;
220
+ }[];
221
+ targetSelection: string;
222
+ };
223
+ }[];
224
+ };
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  var examples_exports = {};
21
21
  __export(examples_exports, {
22
22
  BUY_X_GET_Y_FREE_STRATEGY: () => BUY_X_GET_Y_FREE_STRATEGY,
23
+ ITEM_REWARD_STRATEGY: () => ITEM_REWARD_STRATEGY,
23
24
  X_ITEMS_FOR_Y_PRICE_STRATEGY: () => X_ITEMS_FOR_Y_PRICE_STRATEGY
24
25
  });
25
26
  module.exports = __toCommonJS(examples_exports);
@@ -185,8 +186,98 @@ var BUY_X_GET_Y_FREE_STRATEGY = {
185
186
  }
186
187
  ]
187
188
  };
189
+ var ITEM_REWARD_STRATEGY = {
190
+ metadata: {
191
+ id: "STRATEGY_003",
192
+ name: {
193
+ en: "Infant ticket free with child ticket",
194
+ "zh-CN": "儿童票触发婴儿票免费",
195
+ "zh-HK": "兒童票觸發嬰兒票免費"
196
+ },
197
+ type: "promotion",
198
+ custom: {
199
+ display: {
200
+ product_card: {
201
+ text: {
202
+ en: "Infant ticket free",
203
+ "zh-CN": "婴儿票免费",
204
+ "zh-HK": "嬰兒票免費"
205
+ },
206
+ type: "tag"
207
+ }
208
+ }
209
+ }
210
+ },
211
+ conditions: {
212
+ operator: "and",
213
+ rules: [
214
+ {
215
+ type: "operator",
216
+ field: "currentDateTime",
217
+ value: "2023-01-01 00:00:00",
218
+ operator: ">="
219
+ },
220
+ {
221
+ type: "operator",
222
+ field: "currentDateTime",
223
+ value: "2030-01-01 00:00:00",
224
+ operator: "<="
225
+ },
226
+ {
227
+ type: "operator",
228
+ field: "productIdAndVariantId",
229
+ value: [
230
+ {
231
+ product_id: 1001,
232
+ // 条件商品:儿童票
233
+ product_variant_id: 0
234
+ // 0 = 匹配任意变体
235
+ }
236
+ ],
237
+ operator: "product_match"
238
+ }
239
+ ],
240
+ actionIds: ["1"]
241
+ },
242
+ actions: [
243
+ {
244
+ id: "1",
245
+ type: "ITEM_REWARD",
246
+ value: {
247
+ rewardMethod: "free",
248
+ // free | percent_off | amount_off | fixed_price
249
+ rewardValue: 0,
250
+ // free 可省略;其他 rewardMethod 使用
251
+ quantityRule: "per_condition_quantity",
252
+ // per_condition_quantity | all_targets | max_per_order
253
+ triggerQuantity: 1,
254
+ // N:每满足 N 个条件商品
255
+ rewardQuantity: 1,
256
+ // Y:奖励 Y 个目标商品
257
+ maxRewardQuantity: 1
258
+ // max_per_order 使用;本例保留为配置示意
259
+ },
260
+ valueType: "object",
261
+ target: "product",
262
+ priority: 1,
263
+ config: {
264
+ rewardTargets: [
265
+ {
266
+ product_id: 2001,
267
+ // 奖励对象:婴儿票
268
+ product_variant_id: 0
269
+ // 0 = 匹配任意变体
270
+ }
271
+ ],
272
+ targetSelection: "consumer_best"
273
+ // consumer_best = 按可节省金额从高到低选择
274
+ }
275
+ }
276
+ ]
277
+ };
188
278
  // Annotate the CommonJS export names for ESM import in node:
189
279
  0 && (module.exports = {
190
280
  BUY_X_GET_Y_FREE_STRATEGY,
281
+ ITEM_REWARD_STRATEGY,
191
282
  X_ITEMS_FOR_Y_PRICE_STRATEGY
192
283
  });
@@ -1,4 +1,4 @@
1
1
  export { PromotionEvaluator } from './evaluator';
2
2
  export { PromotionAdapter } from './adapter';
3
3
  export { default } from './adapter';
4
- export { X_ITEMS_FOR_Y_PRICE_STRATEGY, BUY_X_GET_Y_FREE_STRATEGY, } from './examples';
4
+ export { X_ITEMS_FOR_Y_PRICE_STRATEGY, BUY_X_GET_Y_FREE_STRATEGY, ITEM_REWARD_STRATEGY, } from './examples';
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  var promotion_exports = {};
31
31
  __export(promotion_exports, {
32
32
  BUY_X_GET_Y_FREE_STRATEGY: () => import_examples.BUY_X_GET_Y_FREE_STRATEGY,
33
+ ITEM_REWARD_STRATEGY: () => import_examples.ITEM_REWARD_STRATEGY,
33
34
  PromotionAdapter: () => import_adapter.PromotionAdapter,
34
35
  PromotionEvaluator: () => import_evaluator.PromotionEvaluator,
35
36
  X_ITEMS_FOR_Y_PRICE_STRATEGY: () => import_examples.X_ITEMS_FOR_Y_PRICE_STRATEGY,
@@ -43,6 +44,7 @@ var import_examples = require("./examples");
43
44
  // Annotate the CommonJS export names for ESM import in node:
44
45
  0 && (module.exports = {
45
46
  BUY_X_GET_Y_FREE_STRATEGY,
47
+ ITEM_REWARD_STRATEGY,
46
48
  PromotionAdapter,
47
49
  PromotionEvaluator,
48
50
  X_ITEMS_FOR_Y_PRICE_STRATEGY
@@ -12,6 +12,8 @@ export declare const PROMOTION_ACTION_TYPES: {
12
12
  readonly X_ITEMS_FOR_Y_PRICE: "X_ITEMS_FOR_Y_PRICE";
13
13
  /** 买X送Y(买X件送Y件,可累计) */
14
14
  readonly BUY_X_GET_Y_FREE: "BUY_X_GET_Y_FREE";
15
+ /** 商品奖励(满足条件后,订单内指定目标商品享优惠) */
16
+ readonly ITEM_REWARD: "ITEM_REWARD";
15
17
  /** 固定折扣 */
16
18
  readonly DISCOUNT_RATE: "DISCOUNT_RATE";
17
19
  /** 固定减价 */
@@ -66,6 +68,59 @@ export interface BuyXGetYFreeConfig {
66
68
  /** 可选的赠品列表 */
67
69
  giftProducts?: ProductIdentifier[];
68
70
  }
71
+ /**
72
+ * 商品奖励优惠方式
73
+ *
74
+ * - free: 目标商品最终价为 0
75
+ * - percent_off: 目标商品按百分比折扣,rewardValue 为 0-100
76
+ * - amount_off: 每个目标商品固定减免 rewardValue
77
+ * - fixed_price: 目标商品改为固定价 rewardValue
78
+ */
79
+ export type ItemRewardMethod = 'free' | 'percent_off' | 'amount_off' | 'fixed_price';
80
+ /**
81
+ * 商品奖励数量规则
82
+ *
83
+ * - per_condition_quantity: 每满足 N 个条件商品,奖励 Y 个目标商品
84
+ * - all_targets: 满足条件后奖励全部目标商品
85
+ * - max_per_order: 满足条件后每单最多奖励固定数量目标商品
86
+ */
87
+ export type ItemRewardQuantityRule = 'per_condition_quantity' | 'all_targets' | 'max_per_order';
88
+ /** 商品奖励目标选择规则 */
89
+ export type ItemRewardTargetSelection = 'consumer_best';
90
+ /**
91
+ * 商品奖励 - Action Value
92
+ *
93
+ * @example
94
+ * value: {
95
+ * rewardMethod: 'free',
96
+ * quantityRule: 'per_condition_quantity',
97
+ * triggerQuantity: 1,
98
+ * rewardQuantity: 1,
99
+ * }
100
+ */
101
+ export interface ItemRewardValue {
102
+ /** 奖励方式 */
103
+ rewardMethod: ItemRewardMethod;
104
+ /** 奖励值:percent_off/amount_off/fixed_price 使用,free 可省略 */
105
+ rewardValue?: number;
106
+ /** 奖励数量规则 */
107
+ quantityRule: ItemRewardQuantityRule;
108
+ /** 每满足 N 个条件商品;默认 1 */
109
+ triggerQuantity?: number;
110
+ /** 奖励 Y 个目标商品;默认 1 */
111
+ rewardQuantity?: number;
112
+ /** 每单最多奖励 Y 个目标商品;max_per_order 使用 */
113
+ maxRewardQuantity?: number;
114
+ }
115
+ /**
116
+ * 商品奖励 - Action Config
117
+ */
118
+ export interface ItemRewardConfig {
119
+ /** 奖励对象,本期支持指定商品/变体;product_variant_id=0 表示任意变体 */
120
+ rewardTargets?: ProductIdentifier[];
121
+ /** 目标商品超额时的选择规则;本期支持 consumer_best */
122
+ targetSelection?: ItemRewardTargetSelection;
123
+ }
69
124
  /**
70
125
  * 商品标识
71
126
  *
@@ -102,10 +157,19 @@ export interface BuyXGetYFreeAction extends Omit<ActionEffect, 'type' | 'value'
102
157
  valueType: 'object';
103
158
  config?: BuyXGetYFreeConfig;
104
159
  }
160
+ /**
161
+ * 商品奖励 Action
162
+ */
163
+ export interface ItemRewardAction extends Omit<ActionEffect, 'type' | 'value' | 'config'> {
164
+ type: typeof PROMOTION_ACTION_TYPES.ITEM_REWARD;
165
+ value: ItemRewardValue;
166
+ valueType: 'object';
167
+ config?: ItemRewardConfig;
168
+ }
105
169
  /**
106
170
  * 促销活动 Action 联合类型
107
171
  */
108
- export type PromotionAction = XItemsForYPriceAction | BuyXGetYFreeAction;
172
+ export type PromotionAction = XItemsForYPriceAction | BuyXGetYFreeAction | ItemRewardAction;
109
173
  /**
110
174
  * 促销相关运算符
111
175
  */
@@ -170,6 +234,8 @@ export interface PromotionProduct {
170
234
  quantity: number;
171
235
  /** 原价 */
172
236
  original_price?: number;
237
+ /** 内部字段:编辑态订单行只允许参与 ITEM_REWARD,避免旧促销误算历史行 */
238
+ _promotionOnlyForItemReward?: boolean;
173
239
  }
174
240
  /**
175
241
  * 促销活动适配器转换结果
@@ -195,7 +261,7 @@ export interface PromotionTransformResult {
195
261
  /**
196
262
  * Action 详情联合类型
197
263
  */
198
- export type PromotionActionDetail = XItemsForYPriceActionDetail | BuyXGetYFreeActionDetail;
264
+ export type PromotionActionDetail = XItemsForYPriceActionDetail | BuyXGetYFreeActionDetail | ItemRewardActionDetail;
199
265
  /**
200
266
  * X件Y元 Action 详情
201
267
  */
@@ -226,6 +292,28 @@ export interface BuyXGetYFreeActionDetail {
226
292
  /** 可选的赠品列表 */
227
293
  giftProducts: ProductIdentifier[];
228
294
  }
295
+ /**
296
+ * 商品奖励 Action 详情
297
+ */
298
+ export interface ItemRewardActionDetail {
299
+ type: typeof PROMOTION_ACTION_TYPES.ITEM_REWARD;
300
+ /** 奖励方式 */
301
+ rewardMethod: ItemRewardMethod;
302
+ /** 奖励值:percent_off/amount_off/fixed_price 使用,free 为 0 */
303
+ rewardValue: number;
304
+ /** 奖励数量规则 */
305
+ quantityRule: ItemRewardQuantityRule;
306
+ /** 每满足 N 个条件商品;默认 1 */
307
+ triggerQuantity: number;
308
+ /** 奖励 Y 个目标商品;默认 1 */
309
+ rewardQuantity: number;
310
+ /** 每单最多奖励 Y 个目标商品;max_per_order 使用 */
311
+ maxRewardQuantity?: number;
312
+ /** 奖励对象,本期支持指定商品/变体 */
313
+ rewardTargets: ProductIdentifier[];
314
+ /** 目标商品超额时的选择规则 */
315
+ targetSelection: ItemRewardTargetSelection;
316
+ }
229
317
  /**
230
318
  * X件Y元 - 业务层计算结果
231
319
  *
@@ -28,6 +28,8 @@ var PROMOTION_ACTION_TYPES = {
28
28
  X_ITEMS_FOR_Y_PRICE: "X_ITEMS_FOR_Y_PRICE",
29
29
  /** 买X送Y(买X件送Y件,可累计) */
30
30
  BUY_X_GET_Y_FREE: "BUY_X_GET_Y_FREE",
31
+ /** 商品奖励(满足条件后,订单内指定目标商品享优惠) */
32
+ ITEM_REWARD: "ITEM_REWARD",
31
33
  /** 固定折扣 */
32
34
  DISCOUNT_RATE: "DISCOUNT_RATE",
33
35
  /** 固定减价 */
@@ -461,7 +461,7 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
461
461
  generateIdempotencyToken(): string;
462
462
  syncPaymentsToOrder<T = any>(params: SyncPaymentsToOrderParams): Promise<SyncPaymentsToOrderResult<T>>;
463
463
  createOrder(params: CommitOrderParams['query']): {
464
- type: "virtual" | "appointment_booking";
464
+ type: "appointment_booking" | "virtual";
465
465
  platform: string;
466
466
  sales_channel: string;
467
467
  order_sales_channel: string;
@@ -446,7 +446,12 @@ function resolveEffectivePerUnitDiscount(product) {
446
446
  (sum, discount) => sum + Number((discount == null ? void 0 : discount.amount) || 0),
447
447
  0
448
448
  );
449
- const hasPromoDiscountItem = discountList.some((item) => (item == null ? void 0 : item.type) === "promotion");
449
+ const hasPromoDiscountItem = discountList.some(
450
+ (item) => {
451
+ var _a, _b;
452
+ return (item == null ? void 0 : item.type) === "promotion" || (item == null ? void 0 : item.type) === "product" && ((_a = item == null ? void 0 : item.metadata) == null ? void 0 : _a.source) === "promotion" && ((_b = item == null ? void 0 : item.metadata) == null ? void 0 : _b.actionType) === "ITEM_REWARD";
453
+ }
454
+ );
450
455
  if (hasPromoDiscountItem)
451
456
  return fromList;
452
457
  const metadata = (product == null ? void 0 : product.metadata) || {};
@@ -49,5 +49,5 @@ export declare class Product extends BaseModule implements Module {
49
49
  getCategories(): ProductCategory[];
50
50
  setOtherParams(key: string, value: any): void;
51
51
  getOtherParams(): any;
52
- getProductType(): "duration" | "session" | "normal";
52
+ getProductType(): "normal" | "duration" | "session";
53
53
  }
@@ -36,6 +36,7 @@ export declare class RulesModule extends BaseModule implements Module, RulesModu
36
36
  excludeDiscountListByType(discountList: Discount[], type: string): Discount[];
37
37
  /** 手动改价场景:去掉 promotion,保留 type=product 等行内折扣 */
38
38
  resolveDiscountListForManualOverride(discountList: Discount[]): Discount[];
39
+ private isItemRewardProductDiscount;
39
40
  private getUnavailableReason;
40
41
  calcDiscount({ discountList, productList, holders, isFormSubject, orderTotalAmount }: {
41
42
  discountList: Discount[];