@pisell/pisellos 2.2.230 → 2.2.232

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 (34) hide show
  1. package/dist/model/strategy/adapter/promotion/index.js +9 -0
  2. package/dist/modules/Order/index.js +44 -29
  3. package/dist/modules/Order/types.d.ts +14 -2
  4. package/dist/modules/Order/types.js +8 -1
  5. package/dist/modules/ProductList/index.d.ts +9 -12
  6. package/dist/modules/ProductList/index.js +122 -59
  7. package/dist/modules/ProductList/types.d.ts +14 -0
  8. package/dist/server/index.d.ts +21 -0
  9. package/dist/server/index.js +154 -34
  10. package/dist/server/utils/small-ticket.js +113 -29
  11. package/dist/solution/BookingByStep/index.d.ts +1 -1
  12. package/dist/solution/BookingTicket/index.d.ts +9 -1
  13. package/dist/solution/BookingTicket/index.js +198 -158
  14. package/dist/solution/BookingTicket/types.d.ts +4 -0
  15. package/dist/solution/BookingTicket/utils/cartView.js +20 -6
  16. package/dist/solution/BookingTicket/utils/resolveBestAddTimePlan.d.ts +189 -0
  17. package/dist/solution/BookingTicket/utils/resolveBestAddTimePlan.js +429 -0
  18. package/lib/model/strategy/adapter/promotion/index.js +0 -49
  19. package/lib/modules/Order/index.js +18 -5
  20. package/lib/modules/Order/types.d.ts +14 -2
  21. package/lib/modules/ProductList/index.d.ts +9 -12
  22. package/lib/modules/ProductList/index.js +32 -4
  23. package/lib/modules/ProductList/types.d.ts +14 -0
  24. package/lib/server/index.d.ts +21 -0
  25. package/lib/server/index.js +107 -9
  26. package/lib/server/utils/small-ticket.js +78 -1
  27. package/lib/solution/BookingByStep/index.d.ts +1 -1
  28. package/lib/solution/BookingTicket/index.d.ts +9 -1
  29. package/lib/solution/BookingTicket/index.js +20 -1
  30. package/lib/solution/BookingTicket/types.d.ts +4 -0
  31. package/lib/solution/BookingTicket/utils/cartView.js +14 -7
  32. package/lib/solution/BookingTicket/utils/resolveBestAddTimePlan.d.ts +189 -0
  33. package/lib/solution/BookingTicket/utils/resolveBestAddTimePlan.js +241 -0
  34. package/package.json +1 -1
@@ -79,6 +79,10 @@ export interface ILoadProductsParams {
79
79
  with_count?: string[];
80
80
  /** 是否返回日程信息 */
81
81
  with_schedule?: number;
82
+ /** 扩展商品类型过滤 */
83
+ extension_type?: string[];
84
+ /** 商品发布状态 */
85
+ status?: string;
82
86
  }
83
87
  /**
84
88
  * 加载商品详情的参数
@@ -33,6 +33,17 @@ function indexProductsByUid(lines) {
33
33
  }, {});
34
34
  }
35
35
 
36
+ /**
37
+ * 判断商品行是否是促销赠品行。
38
+ *
39
+ * @example
40
+ * isGiftProductLine({ metadata: { _giftInfo: {} } } as any); // true
41
+ */
42
+ function isGiftProductLine(line) {
43
+ var _line$metadata2;
44
+ return Boolean(line === null || line === void 0 || (_line$metadata2 = line.metadata) === null || _line$metadata2 === void 0 ? void 0 : _line$metadata2._giftInfo);
45
+ }
46
+
36
47
  /**
37
48
  * 构建购物车 UI 视图:bookings 平铺附带关联商品;items 仅含未关联 booking 的商品行。
38
49
  *
@@ -47,29 +58,32 @@ export function buildCartView(lines, bookings) {
47
58
  var claimedUids = new Set();
48
59
  var cartBookings = bookingList.filter(function (booking) {
49
60
  return booking.parent_id !== 0;
50
- }).map(function (booking) {
61
+ }).reduce(function (acc, booking) {
51
62
  var uid = booking === null || booking === void 0 ? void 0 : booking.product_uid;
52
63
  var product = null;
53
64
  if (uid !== undefined && uid !== null && uid !== '') {
54
65
  var key = String(uid);
55
66
  if (!claimedUids.has(key)) {
56
67
  var line = linesByUid[key];
68
+ // 赠品预约只作为提交数据保留,不在购物车顶部预约卡片区展示。
69
+ if (isGiftProductLine(line)) return acc;
57
70
  if (line) {
58
71
  claimedUids.add(key);
59
72
  product = line;
60
73
  }
61
74
  }
62
75
  }
63
- return _objectSpread(_objectSpread({}, booking), {}, {
76
+ acc.push(_objectSpread(_objectSpread({}, booking), {}, {
64
77
  _extend: {
65
78
  product: product
66
79
  }
67
- });
68
- });
80
+ }));
81
+ return acc;
82
+ }, []);
69
83
  var items = productLines.filter(function (line) {
70
- var _line$metadata2, _line$metadata3;
84
+ var _line$metadata3;
71
85
  // 促销赠品行仅通过主商品 Gift 行展示,不在 items 单独占一行(对齐 legacy `isGift: !!_giftInfo`)
72
- if ((_line$metadata2 = line.metadata) !== null && _line$metadata2 !== void 0 && _line$metadata2._giftInfo) return false;
86
+ if (isGiftProductLine(line)) return false;
73
87
  var uid = (_line$metadata3 = line.metadata) === null || _line$metadata3 === void 0 ? void 0 : _line$metadata3.unique_identification_number;
74
88
  if (uid === undefined || uid === null || uid === '') return true;
75
89
  return !claimedUids.has(String(uid));
@@ -0,0 +1,189 @@
1
+ /**
2
+ * 计算指定加时时长下的最优购买方案。
3
+ *
4
+ * 输入的商品结构来自旧版 booking addTimeModal 的加时商品配置映射:
5
+ * - `minMinutes`:该商品第一次可选择的最小时长。
6
+ * - `maxMinutes`:单次购买该商品可覆盖的最大时长。
7
+ * - `unitMinutes`:超过最小时长后,每次递增的时长切片。
8
+ * - `price`:每个 `unitMinutes` 切片的价格。
9
+ * - `unlimited`:固定价格商品,可覆盖任意目标时长。
10
+ *
11
+ * 算法流程:
12
+ *
13
+ * ```
14
+ * products
15
+ * |
16
+ * v
17
+ * 将每个普通商品展开成有限候选档位
18
+ * 商品 A: min=30, max=60, unit=10, price=10
19
+ * => 30m/$30, 40m/$40, 50m/$50, 60m/$60
20
+ * |
21
+ * v
22
+ * 单独加入 unlimited 候选
23
+ * unlimited 商品 => targetMinutes / 固定商品价格
24
+ * |
25
+ * v
26
+ * 按 covered minutes 做动态规划
27
+ * dp[minutes] = 正好覆盖 `minutes` 时的最低价候选组合
28
+ * |
29
+ * v
30
+ * 从所有 dp[minutes >= targetMinutes] 中挑选最优方案
31
+ * ```
32
+ *
33
+ * 候选档位生成刻意对齐旧版 addTimeModal:
34
+ * `totalPrice = minutes / unitMinutes * price`.
35
+ * 例如 `minMinutes=30`、`unitMinutes=10`、`price=10` 时:
36
+ * - 30 分钟 => 30 / 10 * 10 = 30
37
+ * - 60 分钟 => 60 / 10 * 10 = 60
38
+ *
39
+ * 目标时长不要求精确命中某个档位,只需要覆盖即可。例如目标是 51 分钟时,
40
+ * 60 分钟档位可以作为候选。动态规划也允许跨商品组合,例如商品 A 的 30m
41
+ * 加上商品 B 的 30m。最终方案按以下优先级比较:
42
+ * 1. 总价更低;
43
+ * 2. 超出时长更少,即 `coveredMinutes - targetMinutes` 更小;
44
+ * 3. 结果行数更少,让购买方案尽量简单。
45
+ *
46
+ * 典型匹配示例:
47
+ *
48
+ * 示例 1:精确命中,直接选择更便宜的商品。
49
+ * ```
50
+ * target = 30
51
+ * 商品 A: min=30, max=30, unit=30, price=10 => 30m/$10
52
+ * 商品 B: min=30, max=30, unit=30, price=8 => 30m/$8
53
+ * 结果:选择商品 B,coveredMinutes=30,totalPrice=8
54
+ * ```
55
+ *
56
+ * 示例 2:目标时长落在两个档位之间,向上选择可覆盖的档位。
57
+ * ```
58
+ * target = 51
59
+ * 商品 A: min=30, max=120, unit=10, price=10
60
+ * 候选:30m/$30, 40m/$40, 50m/$50, 60m/$60 ...
61
+ * 结果:50m 不足以覆盖 51m,因此选择 60m/$60
62
+ * ```
63
+ *
64
+ * 示例 3:不同商品都能覆盖同一目标时长,比较最终价格。
65
+ * ```
66
+ * target = 51
67
+ * 商品 A: min=30, max=120, unit=10, price=10 => 60m/$60
68
+ * 商品 B: min=30, max=120, unit=30, price=25 => 60m/$50
69
+ * 结果:两个方案都覆盖 60m,但商品 B 更便宜,所以选择商品 B
70
+ * ```
71
+ *
72
+ * 示例 4:允许跨商品组合,组合价更低时会选择组合。
73
+ * ```
74
+ * target = 90
75
+ * 商品 A: min=30, max=30, unit=30, price=5 => 30m/$5
76
+ * 商品 B: min=60, max=60, unit=60, price=8 => 60m/$8
77
+ * 商品 C: min=90, max=90, unit=90, price=20 => 90m/$20
78
+ * 结果:商品 A + 商品 B = 90m/$13,比商品 C 更便宜
79
+ * ```
80
+ *
81
+ * 示例 5:允许重复购买同一个候选档位。
82
+ * ```
83
+ * target = 90
84
+ * 商品 A: min=30, max=30, unit=30, price=5 => 30m/$5
85
+ * 结果:购买 3 份商品 A,coveredMinutes=90,totalPrice=15,
86
+ * lines 中会合并为一行:minutes=30,quantity=3
87
+ * ```
88
+ *
89
+ * 示例 6:unlimited 可以覆盖任意目标时长,并参与价格比较。
90
+ * ```
91
+ * target = 180
92
+ * 商品 A: min=30, max=120, unit=30, price=10
93
+ * 商品 B: unlimited=true, price=35
94
+ * 普通商品可以组合成 180m,例如 120m + 60m,总价 $60
95
+ * unlimited 商品固定 $35
96
+ * 结果:选择 unlimited 商品,coveredMinutes=180,totalPrice=35
97
+ * ```
98
+ *
99
+ * 示例 7:unlimited 不一定优先,普通商品更便宜时选择普通商品。
100
+ * ```
101
+ * target = 60
102
+ * 商品 A: min=30, max=60, unit=30, price=10 => 60m/$20
103
+ * 商品 B: unlimited=true, price=50
104
+ * 结果:选择商品 A 的 60m 档位
105
+ * ```
106
+ *
107
+ * 示例 8:价格相同,优先选择超出时长更少的方案。
108
+ * ```
109
+ * target = 51
110
+ * 商品 A: 60m/$20
111
+ * 商品 B: 90m/$20
112
+ * 结果:选择 60m,因为 overage=9,小于 90m 的 overage=39
113
+ * ```
114
+ *
115
+ * 示例 9:价格和覆盖时长都相同,优先选择结果行数更少的方案。
116
+ * ```
117
+ * target = 60
118
+ * 商品 A: 60m/$20
119
+ * 商品 B: 30m/$10
120
+ * 商品 C: 30m/$10
121
+ * 结果:商品 A 单行方案和 B+C 两行方案价格、时长相同,选择商品 A
122
+ * ```
123
+ *
124
+ * 示例 10:没有任何有效商品时返回 `NO_PRODUCTS`。
125
+ * ```
126
+ * target = 30
127
+ * products = []
128
+ * 结果:available=false,unavailableReason='NO_PRODUCTS'
129
+ * ```
130
+ *
131
+ * 示例 11:有商品但没有可用候选时返回 `NO_COVERAGE`。
132
+ * ```
133
+ * target = 30
134
+ * 商品 A: price=0,或 minMinutes 缺失/无效
135
+ * 结果:available=false,unavailableReason='NO_COVERAGE'
136
+ * ```
137
+ *
138
+ * 示例 12:目标时长小于等于 0 时无需购买。
139
+ * ```
140
+ * target = 0
141
+ * 结果:available=true,coveredMinutes=0,totalPrice=0,lines=[]
142
+ * ```
143
+ *
144
+ * 搜索边界:
145
+ * 有限档位只需要搜索到 `targetMinutes + maxFiniteCandidateMinutes`。
146
+ * 这里的 `maxFiniteCandidateMinutes` 是所有普通候选档位里的最大时长。
147
+ * 例如目标是 51 分钟,最大普通档位是 120 分钟,则只搜索到 171 分钟。
148
+ * 如果某个组合覆盖了 180 分钟,那么移除其中任意一个普通档位后,剩余时长
149
+ * 仍然有机会覆盖 51 分钟,而且价格一定不会更高,所以 180 分钟这个组合不
150
+ * 可能是最优解。
151
+ */
152
+ export interface AddTimeProduct {
153
+ id: string | number;
154
+ title?: string;
155
+ price: number;
156
+ minMinutes?: number;
157
+ maxMinutes?: number;
158
+ unitMinutes?: number;
159
+ unlimited?: boolean;
160
+ }
161
+ export interface AddTimePlanLine {
162
+ productId: string | number;
163
+ productTitle?: string;
164
+ minutes: number;
165
+ quantity: number;
166
+ unitPrice: number;
167
+ totalPrice: number;
168
+ unlimited?: boolean;
169
+ }
170
+ export interface AddTimePlan {
171
+ available: boolean;
172
+ unavailableReason?: 'NO_PRODUCTS' | 'NO_COVERAGE';
173
+ targetMinutes: number;
174
+ coveredMinutes: number;
175
+ totalPrice: number;
176
+ lines: AddTimePlanLine[];
177
+ }
178
+ interface AddTimeCandidate {
179
+ productId: string | number;
180
+ productTitle?: string;
181
+ minutes: number;
182
+ unitPrice: number;
183
+ totalPrice: number;
184
+ unlimited?: boolean;
185
+ }
186
+ export declare const ADD_TIME_UNIT_MINUTES: readonly [10, 15, 20, 30, 45, 60];
187
+ export declare function buildAddTimeCandidates(products: AddTimeProduct[]): AddTimeCandidate[];
188
+ export declare function resolveBestAddTimePlan(products: AddTimeProduct[], targetMinutes: number): AddTimePlan;
189
+ export {};
@@ -0,0 +1,429 @@
1
+ function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
2
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
3
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
4
+ function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
5
+ function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : String(i); }
6
+ function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
7
+ function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }
8
+ function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
9
+ function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); }
10
+ function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }
11
+ function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; }
12
+ function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
13
+ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
14
+ /**
15
+ * 计算指定加时时长下的最优购买方案。
16
+ *
17
+ * 输入的商品结构来自旧版 booking addTimeModal 的加时商品配置映射:
18
+ * - `minMinutes`:该商品第一次可选择的最小时长。
19
+ * - `maxMinutes`:单次购买该商品可覆盖的最大时长。
20
+ * - `unitMinutes`:超过最小时长后,每次递增的时长切片。
21
+ * - `price`:每个 `unitMinutes` 切片的价格。
22
+ * - `unlimited`:固定价格商品,可覆盖任意目标时长。
23
+ *
24
+ * 算法流程:
25
+ *
26
+ * ```
27
+ * products
28
+ * |
29
+ * v
30
+ * 将每个普通商品展开成有限候选档位
31
+ * 商品 A: min=30, max=60, unit=10, price=10
32
+ * => 30m/$30, 40m/$40, 50m/$50, 60m/$60
33
+ * |
34
+ * v
35
+ * 单独加入 unlimited 候选
36
+ * unlimited 商品 => targetMinutes / 固定商品价格
37
+ * |
38
+ * v
39
+ * 按 covered minutes 做动态规划
40
+ * dp[minutes] = 正好覆盖 `minutes` 时的最低价候选组合
41
+ * |
42
+ * v
43
+ * 从所有 dp[minutes >= targetMinutes] 中挑选最优方案
44
+ * ```
45
+ *
46
+ * 候选档位生成刻意对齐旧版 addTimeModal:
47
+ * `totalPrice = minutes / unitMinutes * price`.
48
+ * 例如 `minMinutes=30`、`unitMinutes=10`、`price=10` 时:
49
+ * - 30 分钟 => 30 / 10 * 10 = 30
50
+ * - 60 分钟 => 60 / 10 * 10 = 60
51
+ *
52
+ * 目标时长不要求精确命中某个档位,只需要覆盖即可。例如目标是 51 分钟时,
53
+ * 60 分钟档位可以作为候选。动态规划也允许跨商品组合,例如商品 A 的 30m
54
+ * 加上商品 B 的 30m。最终方案按以下优先级比较:
55
+ * 1. 总价更低;
56
+ * 2. 超出时长更少,即 `coveredMinutes - targetMinutes` 更小;
57
+ * 3. 结果行数更少,让购买方案尽量简单。
58
+ *
59
+ * 典型匹配示例:
60
+ *
61
+ * 示例 1:精确命中,直接选择更便宜的商品。
62
+ * ```
63
+ * target = 30
64
+ * 商品 A: min=30, max=30, unit=30, price=10 => 30m/$10
65
+ * 商品 B: min=30, max=30, unit=30, price=8 => 30m/$8
66
+ * 结果:选择商品 B,coveredMinutes=30,totalPrice=8
67
+ * ```
68
+ *
69
+ * 示例 2:目标时长落在两个档位之间,向上选择可覆盖的档位。
70
+ * ```
71
+ * target = 51
72
+ * 商品 A: min=30, max=120, unit=10, price=10
73
+ * 候选:30m/$30, 40m/$40, 50m/$50, 60m/$60 ...
74
+ * 结果:50m 不足以覆盖 51m,因此选择 60m/$60
75
+ * ```
76
+ *
77
+ * 示例 3:不同商品都能覆盖同一目标时长,比较最终价格。
78
+ * ```
79
+ * target = 51
80
+ * 商品 A: min=30, max=120, unit=10, price=10 => 60m/$60
81
+ * 商品 B: min=30, max=120, unit=30, price=25 => 60m/$50
82
+ * 结果:两个方案都覆盖 60m,但商品 B 更便宜,所以选择商品 B
83
+ * ```
84
+ *
85
+ * 示例 4:允许跨商品组合,组合价更低时会选择组合。
86
+ * ```
87
+ * target = 90
88
+ * 商品 A: min=30, max=30, unit=30, price=5 => 30m/$5
89
+ * 商品 B: min=60, max=60, unit=60, price=8 => 60m/$8
90
+ * 商品 C: min=90, max=90, unit=90, price=20 => 90m/$20
91
+ * 结果:商品 A + 商品 B = 90m/$13,比商品 C 更便宜
92
+ * ```
93
+ *
94
+ * 示例 5:允许重复购买同一个候选档位。
95
+ * ```
96
+ * target = 90
97
+ * 商品 A: min=30, max=30, unit=30, price=5 => 30m/$5
98
+ * 结果:购买 3 份商品 A,coveredMinutes=90,totalPrice=15,
99
+ * lines 中会合并为一行:minutes=30,quantity=3
100
+ * ```
101
+ *
102
+ * 示例 6:unlimited 可以覆盖任意目标时长,并参与价格比较。
103
+ * ```
104
+ * target = 180
105
+ * 商品 A: min=30, max=120, unit=30, price=10
106
+ * 商品 B: unlimited=true, price=35
107
+ * 普通商品可以组合成 180m,例如 120m + 60m,总价 $60
108
+ * unlimited 商品固定 $35
109
+ * 结果:选择 unlimited 商品,coveredMinutes=180,totalPrice=35
110
+ * ```
111
+ *
112
+ * 示例 7:unlimited 不一定优先,普通商品更便宜时选择普通商品。
113
+ * ```
114
+ * target = 60
115
+ * 商品 A: min=30, max=60, unit=30, price=10 => 60m/$20
116
+ * 商品 B: unlimited=true, price=50
117
+ * 结果:选择商品 A 的 60m 档位
118
+ * ```
119
+ *
120
+ * 示例 8:价格相同,优先选择超出时长更少的方案。
121
+ * ```
122
+ * target = 51
123
+ * 商品 A: 60m/$20
124
+ * 商品 B: 90m/$20
125
+ * 结果:选择 60m,因为 overage=9,小于 90m 的 overage=39
126
+ * ```
127
+ *
128
+ * 示例 9:价格和覆盖时长都相同,优先选择结果行数更少的方案。
129
+ * ```
130
+ * target = 60
131
+ * 商品 A: 60m/$20
132
+ * 商品 B: 30m/$10
133
+ * 商品 C: 30m/$10
134
+ * 结果:商品 A 单行方案和 B+C 两行方案价格、时长相同,选择商品 A
135
+ * ```
136
+ *
137
+ * 示例 10:没有任何有效商品时返回 `NO_PRODUCTS`。
138
+ * ```
139
+ * target = 30
140
+ * products = []
141
+ * 结果:available=false,unavailableReason='NO_PRODUCTS'
142
+ * ```
143
+ *
144
+ * 示例 11:有商品但没有可用候选时返回 `NO_COVERAGE`。
145
+ * ```
146
+ * target = 30
147
+ * 商品 A: price=0,或 minMinutes 缺失/无效
148
+ * 结果:available=false,unavailableReason='NO_COVERAGE'
149
+ * ```
150
+ *
151
+ * 示例 12:目标时长小于等于 0 时无需购买。
152
+ * ```
153
+ * target = 0
154
+ * 结果:available=true,coveredMinutes=0,totalPrice=0,lines=[]
155
+ * ```
156
+ *
157
+ * 搜索边界:
158
+ * 有限档位只需要搜索到 `targetMinutes + maxFiniteCandidateMinutes`。
159
+ * 这里的 `maxFiniteCandidateMinutes` 是所有普通候选档位里的最大时长。
160
+ * 例如目标是 51 分钟,最大普通档位是 120 分钟,则只搜索到 171 分钟。
161
+ * 如果某个组合覆盖了 180 分钟,那么移除其中任意一个普通档位后,剩余时长
162
+ * 仍然有机会覆盖 51 分钟,而且价格一定不会更高,所以 180 分钟这个组合不
163
+ * 可能是最优解。
164
+ */
165
+
166
+ export var ADD_TIME_UNIT_MINUTES = [10, 15, 20, 30, 45, 60];
167
+ var toPositiveNumber = function toPositiveNumber(value) {
168
+ var n = Number(value);
169
+ return Number.isFinite(n) && n > 0 ? n : null;
170
+ };
171
+ var normalizeTargetMinutes = function normalizeTargetMinutes(minutes) {
172
+ var n = Number(minutes);
173
+ return Number.isFinite(n) ? Math.max(0, Math.ceil(n)) : 0;
174
+ };
175
+ var gcd = function gcd(a, b) {
176
+ var x = Math.abs(a);
177
+ var y = Math.abs(b);
178
+ while (y !== 0) {
179
+ var next = x % y;
180
+ x = y;
181
+ y = next;
182
+ }
183
+ return x;
184
+ };
185
+ var getDpStepMinutes = function getDpStepMinutes(candidates) {
186
+ return candidates.reduce(function (current, candidate) {
187
+ return gcd(current, candidate.minutes);
188
+ }, 0) || 1;
189
+ };
190
+ var comparePlans = function comparePlans(left, right) {
191
+ if (!left) return right;
192
+
193
+ // 第一优先级:总价更低。例:同样覆盖 60 分钟,$50 优先于 $60。
194
+ if (right.totalPrice !== left.totalPrice) {
195
+ return right.totalPrice < left.totalPrice ? right : left;
196
+ }
197
+
198
+ // 第二优先级:价格相同时,选择超出目标更少的方案。例:target=51 时,60m 优先于 90m。
199
+ var leftOverage = left.coveredMinutes - left.targetMinutes;
200
+ var rightOverage = right.coveredMinutes - right.targetMinutes;
201
+ if (rightOverage !== leftOverage) {
202
+ return rightOverage < leftOverage ? right : left;
203
+ }
204
+
205
+ // 第三优先级:价格和超出时长都相同时,选择购买行数更少的方案,减少下单复杂度。
206
+ return right.lines.length < left.lines.length ? right : left;
207
+ };
208
+ var toPlanLine = function toPlanLine(candidate) {
209
+ return {
210
+ productId: candidate.productId,
211
+ productTitle: candidate.productTitle,
212
+ minutes: Number.isFinite(candidate.minutes) ? candidate.minutes : 0,
213
+ quantity: 1,
214
+ unitPrice: candidate.unitPrice,
215
+ totalPrice: candidate.totalPrice,
216
+ unlimited: candidate.unlimited
217
+ };
218
+ };
219
+ var mergePlanLines = function mergePlanLines(candidates) {
220
+ var map = new Map();
221
+ var _iterator = _createForOfIteratorHelper(candidates),
222
+ _step;
223
+ try {
224
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
225
+ var candidate = _step.value;
226
+ // 同一个商品、同一个档位可以重复购买,合并成 quantity。例:30m 档买 3 次 => 一行 quantity=3。
227
+ var key = "".concat(candidate.productId, "-").concat(candidate.minutes, "-").concat(candidate.unlimited ? 'u' : 'n');
228
+ var existing = map.get(key);
229
+ if (existing) {
230
+ existing.quantity += 1;
231
+ existing.totalPrice += candidate.totalPrice;
232
+ continue;
233
+ }
234
+ map.set(key, toPlanLine(candidate));
235
+ }
236
+ } catch (err) {
237
+ _iterator.e(err);
238
+ } finally {
239
+ _iterator.f();
240
+ }
241
+ return Array.from(map.values());
242
+ };
243
+ var restoreDpCandidates = function restoreDpCandidates(dp, finiteCandidates, index) {
244
+ var candidates = [];
245
+ var currentIndex = index;
246
+ while (currentIndex > 0) {
247
+ var current = dp[currentIndex];
248
+ if (!current || current.candidateIndex < 0) break;
249
+ candidates.push(finiteCandidates[current.candidateIndex]);
250
+ currentIndex = current.prevIndex;
251
+ }
252
+ return candidates.reverse();
253
+ };
254
+ export function buildAddTimeCandidates(products) {
255
+ var candidates = [];
256
+ var _iterator2 = _createForOfIteratorHelper(products),
257
+ _step2;
258
+ try {
259
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
260
+ var _toPositiveNumber, _toPositiveNumber2;
261
+ var product = _step2.value;
262
+ // 价格无效的商品无法参与比价,直接跳过,避免生成 0 元或 NaN 候选。
263
+ var price = toPositiveNumber(product.price);
264
+ if (price == null) continue;
265
+ if (product.unlimited) {
266
+ // unlimited 是固定价商品,可覆盖任意目标时长;这里先用 Infinity 标记,最终按 targetMinutes 输出。
267
+ candidates.push({
268
+ productId: product.id,
269
+ productTitle: product.title,
270
+ minutes: Number.POSITIVE_INFINITY,
271
+ unitPrice: price,
272
+ totalPrice: price,
273
+ unlimited: true
274
+ });
275
+ continue;
276
+ }
277
+
278
+ // 普通商品必须有有效的起购时长,否则无法知道第一档应该从哪里开始。
279
+ var minMinutes = toPositiveNumber(product.minMinutes);
280
+ if (minMinutes == null) continue;
281
+
282
+ // 旧版配置中 max/unit 可能缺失:缺失时退化为只有 minMinutes 这一档。
283
+ var maxMinutes = (_toPositiveNumber = toPositiveNumber(product.maxMinutes)) !== null && _toPositiveNumber !== void 0 ? _toPositiveNumber : minMinutes;
284
+ var unitMinutes = (_toPositiveNumber2 = toPositiveNumber(product.unitMinutes)) !== null && _toPositiveNumber2 !== void 0 ? _toPositiveNumber2 : minMinutes;
285
+ // 当 maxMinutes 小于 minMinutes 时,至少保留 minMinutes 这一档,避免错误配置导致无候选。
286
+ var safeMax = Math.max(minMinutes, maxMinutes);
287
+
288
+ // 将一个商品展开为多个可购买档位。例:min=30,max=60,unit=10 => 30/40/50/60 四个候选。
289
+ for (var minutes = minMinutes; minutes <= safeMax; minutes += unitMinutes) {
290
+ candidates.push({
291
+ productId: product.id,
292
+ productTitle: product.title,
293
+ minutes: minutes,
294
+ unitPrice: price,
295
+ // 对齐旧版 addTimeModal:每个 unit_duration 切片收一次 price。例:60/10*10=$60。
296
+ totalPrice: minutes / unitMinutes * price
297
+ });
298
+ }
299
+ }
300
+ } catch (err) {
301
+ _iterator2.e(err);
302
+ } finally {
303
+ _iterator2.f();
304
+ }
305
+ return candidates;
306
+ }
307
+ export function resolveBestAddTimePlan(products, targetMinutes) {
308
+ var _best;
309
+ // 目标时长按分钟向上取整,避免 50.2 分钟被当成 50 分钟而覆盖不足。
310
+ var normalizedTarget = normalizeTargetMinutes(targetMinutes);
311
+ if (normalizedTarget <= 0) {
312
+ // 目标时长为 0 或负数时表示无需加时,直接返回 0 元可用方案。
313
+ return {
314
+ available: true,
315
+ targetMinutes: 0,
316
+ coveredMinutes: 0,
317
+ totalPrice: 0,
318
+ lines: []
319
+ };
320
+ }
321
+ if (!products.length) {
322
+ return {
323
+ available: false,
324
+ unavailableReason: 'NO_PRODUCTS',
325
+ targetMinutes: normalizedTarget,
326
+ coveredMinutes: 0,
327
+ totalPrice: 0,
328
+ lines: []
329
+ };
330
+ }
331
+ var candidates = buildAddTimeCandidates(products);
332
+ // 普通候选参与 DP 组合;unlimited 单独比较,因为它本身可以覆盖任意时长,不需要组合。
333
+ var finiteCandidates = candidates.filter(function (item) {
334
+ return Number.isFinite(item.minutes);
335
+ });
336
+ var unlimitedCandidates = candidates.filter(function (item) {
337
+ return item.unlimited;
338
+ });
339
+ // 搜索上限只需要多看一个最大档位。超过这个范围的组合,去掉其中一个档位后通常仍可覆盖且更便宜。
340
+ var maxFiniteMinutes = Math.max.apply(Math, [0].concat(_toConsumableArray(finiteCandidates.map(function (item) {
341
+ return item.minutes;
342
+ }))));
343
+ var searchLimit = normalizedTarget + maxFiniteMinutes;
344
+ var best = null;
345
+ var _iterator3 = _createForOfIteratorHelper(unlimitedCandidates),
346
+ _step3;
347
+ try {
348
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
349
+ var unlimited = _step3.value;
350
+ // unlimited 按目标时长输出 coveredMinutes,但价格保持商品固定价,并参与普通方案的统一比价。
351
+ best = comparePlans(best, {
352
+ available: true,
353
+ targetMinutes: normalizedTarget,
354
+ coveredMinutes: normalizedTarget,
355
+ totalPrice: unlimited.totalPrice,
356
+ lines: [toPlanLine(_objectSpread(_objectSpread({}, unlimited), {}, {
357
+ minutes: normalizedTarget
358
+ }))]
359
+ });
360
+ }
361
+ } catch (err) {
362
+ _iterator3.e(err);
363
+ } finally {
364
+ _iterator3.f();
365
+ }
366
+ if (finiteCandidates.length && searchLimit > 0) {
367
+ var stepMinutes = getDpStepMinutes(finiteCandidates);
368
+ var searchLimitIndex = Math.ceil(searchLimit / stepMinutes);
369
+ var targetIndex = Math.ceil(normalizedTarget / stepMinutes);
370
+ var finiteCandidateSteps = finiteCandidates.map(function (candidate) {
371
+ return candidate.minutes / stepMinutes;
372
+ });
373
+
374
+ // dp[x] 表示“刚好覆盖 x * stepMinutes 分钟”的当前最低价状态;状态只存前驱,最后再回溯生成明细。
375
+ var dp = [];
376
+ dp[0] = {
377
+ price: 0,
378
+ itemCount: 0,
379
+ prevIndex: -1,
380
+ candidateIndex: -1
381
+ };
382
+ for (var index = 0; index <= searchLimitIndex; index += 1) {
383
+ var current = dp[index];
384
+ // 当前压缩状态不可达时跳过。例:只有 30m 档且 step=30 时,dp[1] 才代表 30 分钟。
385
+ if (!current) continue;
386
+ for (var candidateIndex = 0; candidateIndex < finiteCandidates.length; candidateIndex += 1) {
387
+ // 尝试在当前组合后再买一个候选档位,形成新的覆盖时长。
388
+ var nextIndex = index + finiteCandidateSteps[candidateIndex];
389
+ if (nextIndex > searchLimitIndex) continue;
390
+ var candidate = finiteCandidates[candidateIndex];
391
+ var nextPrice = current.price + candidate.totalPrice;
392
+ var nextItemCount = current.itemCount + 1;
393
+ var prev = dp[nextIndex];
394
+
395
+ // 更新 dp[nextIndex]:价格更低则替换;价格相同则用更少档位,便于后续输出更简单的方案。
396
+ if (!prev || nextPrice < prev.price || nextPrice === prev.price && nextItemCount < prev.itemCount) {
397
+ dp[nextIndex] = {
398
+ price: nextPrice,
399
+ itemCount: nextItemCount,
400
+ prevIndex: index,
401
+ candidateIndex: candidateIndex
402
+ };
403
+ }
404
+ }
405
+ }
406
+
407
+ // 目标不要求精确命中:从 target 到 searchLimit 的所有可达方案里选最优。
408
+ for (var _index = targetIndex; _index <= searchLimitIndex; _index += 1) {
409
+ var _current = dp[_index];
410
+ if (!_current) continue;
411
+ var coveredMinutes = _index * stepMinutes;
412
+ best = comparePlans(best, {
413
+ available: true,
414
+ targetMinutes: normalizedTarget,
415
+ coveredMinutes: coveredMinutes,
416
+ totalPrice: _current.price,
417
+ lines: mergePlanLines(restoreDpCandidates(dp, finiteCandidates, _index))
418
+ });
419
+ }
420
+ }
421
+ return (_best = best) !== null && _best !== void 0 ? _best : {
422
+ available: false,
423
+ unavailableReason: 'NO_COVERAGE',
424
+ targetMinutes: normalizedTarget,
425
+ coveredMinutes: 0,
426
+ totalPrice: 0,
427
+ lines: []
428
+ };
429
+ }